Use separate path types for directories and files (#4285)

## Summary

This is what I consider to be the "real" fix for #8072. We now treat
directory and path URLs as separate `ParsedUrl` types and
`RequirementSource` types. This removes a lot of `.is_dir()` forking
within the `ParsedUrl::Path` arms and makes some states impossible
(e.g., you can't have a `.whl` path that is editable). It _also_ fixes
the `direct_url.json` for direct URLs that refer to files. Previously,
we wrote out to these as if they were installed as directories, which is
just wrong.
This commit is contained in:
Charlie Marsh 2024-06-12 12:59:21 -07:00 committed by GitHub
parent c4483017ac
commit d8f1de6134
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
28 changed files with 524 additions and 167 deletions

View file

@ -4,7 +4,7 @@ use anyhow::{anyhow, Result};
use distribution_filename::WheelFilename;
use pep508_rs::VerbatimUrl;
use pypi_types::{HashDigest, ParsedPathUrl};
use pypi_types::{HashDigest, ParsedDirectoryUrl};
use uv_normalize::PackageName;
use crate::{
@ -120,7 +120,7 @@ impl CachedDist {
.url
.to_file_path()
.map_err(|()| anyhow!("Invalid path in file URL"))?;
Ok(Some(ParsedUrl::Path(ParsedPathUrl {
Ok(Some(ParsedUrl::Directory(ParsedDirectoryUrl {
url: dist.url.raw().clone(),
install_path: path.clone(),
lock_path: path,

View file

@ -1,6 +1,5 @@
use url::Url;
use pep508_rs::VerbatimUrl;
use uv_normalize::PackageName;
#[derive(thiserror::Error, Debug)]
@ -14,21 +13,12 @@ pub enum Error {
#[error(transparent)]
WheelFilename(#[from] distribution_filename::WheelFilenameError),
#[error("Unable to extract file path from URL: {0}")]
MissingFilePath(Url),
#[error("Could not extract path segments from URL: {0}")]
MissingPathSegments(Url),
#[error("Distribution not found at: {0}")]
NotFound(Url),
#[error("Unsupported scheme `{0}` on URL: {1} ({2})")]
UnsupportedScheme(String, String, String),
#[error("Requested package name `{0}` does not match `{1}` in the distribution filename: {2}")]
PackageNameMismatch(PackageName, PackageName, String),
#[error("Only directories can be installed as editable, not filenames: `{0}`")]
EditableFile(VerbatimUrl),
}

View file

@ -329,7 +329,6 @@ impl Dist {
url: VerbatimUrl,
install_path: &Path,
lock_path: &Path,
editable: bool,
) -> Result<Dist, Error> {
// Store the canonicalized path, which also serves to validate that it exists.
let canonicalized_path = match install_path.canonicalize() {
@ -340,16 +339,8 @@ impl Dist {
Err(err) => return Err(err.into()),
};
// Determine whether the path represents an archive or a directory.
if canonicalized_path.is_dir() {
Ok(Self::Source(SourceDist::Directory(DirectorySourceDist {
name,
install_path: canonicalized_path.clone(),
lock_path: lock_path.to_path_buf(),
editable,
url,
})))
} else if canonicalized_path
// Determine whether the path represents a built or source distribution.
if canonicalized_path
.extension()
.is_some_and(|ext| ext.eq_ignore_ascii_case("whl"))
{
@ -362,30 +353,48 @@ impl Dist {
url.verbatim().to_string(),
));
}
if editable {
return Err(Error::EditableFile(url));
}
Ok(Self::Built(BuiltDist::Path(PathBuiltDist {
filename,
path: canonicalized_path,
url,
})))
} else {
if editable {
return Err(Error::EditableFile(url));
}
Ok(Self::Source(SourceDist::Path(PathSourceDist {
name,
install_path: canonicalized_path.clone(),
lock_path: canonicalized_path,
lock_path: lock_path.to_path_buf(),
url,
})))
}
}
/// A local source tree from a `file://` URL.
pub fn from_directory_url(
name: PackageName,
url: VerbatimUrl,
install_path: &Path,
lock_path: &Path,
editable: bool,
) -> Result<Dist, Error> {
// Store the canonicalized path, which also serves to validate that it exists.
let canonicalized_path = match install_path.canonicalize() {
Ok(path) => path,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
return Err(Error::NotFound(url.to_url()));
}
Err(err) => return Err(err.into()),
};
// Determine whether the path represents an archive or a directory.
Ok(Self::Source(SourceDist::Directory(DirectorySourceDist {
name,
install_path: canonicalized_path.clone(),
lock_path: lock_path.to_path_buf(),
editable,
url,
})))
}
/// A remote source distribution from a `git+https://` or `git+ssh://` url.
pub fn from_git_url(
name: PackageName,
@ -407,12 +416,15 @@ impl Dist {
ParsedUrl::Archive(archive) => {
Self::from_http_url(name, url.verbatim, archive.url, archive.subdirectory)
}
ParsedUrl::Path(file) => Self::from_file_url(
ParsedUrl::Path(file) => {
Self::from_file_url(name, url.verbatim, &file.install_path, &file.lock_path)
}
ParsedUrl::Directory(directory) => Self::from_directory_url(
name,
url.verbatim,
&file.install_path,
&file.lock_path,
file.editable,
&directory.install_path,
&directory.lock_path,
directory.editable,
),
ParsedUrl::Git(git) => {
Self::from_git_url(name, url.verbatim, git.url, git.subdirectory)

View file

@ -144,7 +144,6 @@ impl From<&ResolvedDist> for Requirement {
install_path: wheel.path.clone(),
lock_path: wheel.path.clone(),
url: wheel.url.clone(),
editable: false,
},
Dist::Source(SourceDist::Registry(sdist)) => RequirementSource::Registry {
specifier: pep440_rs::VersionSpecifiers::from(
@ -172,9 +171,8 @@ impl From<&ResolvedDist> for Requirement {
install_path: sdist.install_path.clone(),
lock_path: sdist.lock_path.clone(),
url: sdist.url.clone(),
editable: false,
},
Dist::Source(SourceDist::Directory(sdist)) => RequirementSource::Path {
Dist::Source(SourceDist::Directory(sdist)) => RequirementSource::Directory {
install_path: sdist.install_path.clone(),
lock_path: sdist.lock_path.clone(),
url: sdist.url.clone(),

View file

@ -44,10 +44,10 @@ impl Pep508Url for VerbatimParsedUrl {
type Err = ParsedUrlError;
fn parse_url(url: &str, working_dir: Option<&Path>) -> Result<Self, Self::Err> {
let verbatim_url = <VerbatimUrl as Pep508Url>::parse_url(url, working_dir)?;
let verbatim = <VerbatimUrl as Pep508Url>::parse_url(url, working_dir)?;
Ok(Self {
parsed_url: ParsedUrl::try_from(verbatim_url.to_url())?,
verbatim: verbatim_url,
parsed_url: ParsedUrl::try_from(verbatim.to_url())?,
verbatim,
})
}
}
@ -58,28 +58,56 @@ impl UnnamedRequirementUrl for VerbatimParsedUrl {
working_dir: impl AsRef<Path>,
) -> Result<Self, Self::Err> {
let verbatim = VerbatimUrl::parse_path(&path, &working_dir)?;
let parsed_path_url = ParsedPathUrl {
url: verbatim.to_url(),
install_path: verbatim.as_path()?,
lock_path: path.as_ref().to_path_buf(),
editable: false,
let verbatim_path = verbatim.as_path()?;
let is_dir = if let Ok(metadata) = verbatim_path.metadata() {
metadata.is_dir()
} else {
verbatim_path.extension().is_none()
};
let parsed_url = if is_dir {
ParsedUrl::Directory(ParsedDirectoryUrl {
url: verbatim.to_url(),
install_path: verbatim.as_path()?,
lock_path: path.as_ref().to_path_buf(),
editable: false,
})
} else {
ParsedUrl::Path(ParsedPathUrl {
url: verbatim.to_url(),
install_path: verbatim.as_path()?,
lock_path: path.as_ref().to_path_buf(),
})
};
Ok(Self {
parsed_url: ParsedUrl::Path(parsed_path_url),
parsed_url,
verbatim,
})
}
fn parse_absolute_path(path: impl AsRef<Path>) -> Result<Self, Self::Err> {
let verbatim = VerbatimUrl::parse_absolute_path(&path)?;
let parsed_path_url = ParsedPathUrl {
url: verbatim.to_url(),
install_path: verbatim.as_path()?,
lock_path: path.as_ref().to_path_buf(),
editable: false,
let verbatim_path = verbatim.as_path()?;
let is_dir = if let Ok(metadata) = verbatim_path.metadata() {
metadata.is_dir()
} else {
verbatim_path.extension().is_none()
};
let parsed_url = if is_dir {
ParsedUrl::Directory(ParsedDirectoryUrl {
url: verbatim.to_url(),
install_path: verbatim.as_path()?,
lock_path: path.as_ref().to_path_buf(),
editable: false,
})
} else {
ParsedUrl::Path(ParsedPathUrl {
url: verbatim.to_url(),
install_path: verbatim.as_path()?,
lock_path: path.as_ref().to_path_buf(),
})
};
Ok(Self {
parsed_url: ParsedUrl::Path(parsed_path_url),
parsed_url,
verbatim,
})
}
@ -150,8 +178,10 @@ impl<'de> serde::de::Deserialize<'de> for VerbatimParsedUrl {
/// A URL in a requirement `foo @ <url>` must be one of the above.
#[derive(Debug, Clone, Eq, PartialEq, PartialOrd, Hash, Ord)]
pub enum ParsedUrl {
/// The direct URL is a path to a local directory or file.
/// The direct URL is a path to a local file.
Path(ParsedPathUrl),
/// The direct URL is a path to a local directory.
Directory(ParsedDirectoryUrl),
/// The direct URL is path to a Git repository.
Git(ParsedGitUrl),
/// The direct URL is a URL to a source archive (e.g., a `.tar.gz` file) or built archive
@ -162,16 +192,46 @@ pub enum ParsedUrl {
impl ParsedUrl {
/// Returns `true` if the URL is editable.
pub fn is_editable(&self) -> bool {
matches!(self, Self::Path(ParsedPathUrl { editable: true, .. }))
matches!(
self,
Self::Directory(ParsedDirectoryUrl { editable: true, .. })
)
}
}
/// A local path url
/// A local path URL for a file (i.e., a built or source distribution).
///
/// Examples:
/// * `file:///home/ferris/my_project/my_project-0.1.0.tar.gz`
/// * `file:///home/ferris/my_project/my_project-0.1.0-py3-none-any.whl`
#[derive(Debug, Clone, Eq, PartialEq, PartialOrd, Hash, Ord)]
pub struct ParsedPathUrl {
pub url: Url,
/// The resolved, absolute path to the distribution which we use for installing.
pub install_path: PathBuf,
/// The absolute path or path relative to the workspace root pointing to the distribution
/// which we use for locking. Unlike `given` on the verbatim URL all environment variables
/// are resolved, and unlike the install path, we did not yet join it on the base directory.
pub lock_path: PathBuf,
}
impl ParsedPathUrl {
/// Construct a [`ParsedPathUrl`] from a path requirement source.
pub fn from_source(install_path: PathBuf, lock_path: PathBuf, url: Url) -> Self {
Self {
url,
install_path,
lock_path,
}
}
}
/// A local path URL for a source directory.
///
/// Examples:
/// * `file:///home/ferris/my_project`
#[derive(Debug, Clone, Eq, PartialEq, PartialOrd, Hash, Ord)]
pub struct ParsedPathUrl {
pub struct ParsedDirectoryUrl {
pub url: Url,
/// The resolved, absolute path to the distribution which we use for installing.
pub install_path: PathBuf,
@ -182,8 +242,8 @@ pub struct ParsedPathUrl {
pub editable: bool,
}
impl ParsedPathUrl {
/// Construct a [`ParsedPathUrl`] from a path requirement source.
impl ParsedDirectoryUrl {
/// Construct a [`ParsedDirectoryUrl`] from a path requirement source.
pub fn from_source(
install_path: PathBuf,
lock_path: PathBuf,
@ -322,12 +382,25 @@ impl TryFrom<Url> for ParsedUrl {
let path = url
.to_file_path()
.map_err(|()| ParsedUrlError::InvalidFileUrl(url.clone()))?;
Ok(Self::Path(ParsedPathUrl {
url,
install_path: path.clone(),
lock_path: path,
editable: false,
}))
let is_dir = if let Ok(metadata) = path.metadata() {
metadata.is_dir()
} else {
path.extension().is_none()
};
if is_dir {
Ok(Self::Directory(ParsedDirectoryUrl {
url,
install_path: path.clone(),
lock_path: path,
editable: false,
}))
} else {
Ok(Self::Path(ParsedPathUrl {
url,
install_path: path.clone(),
lock_path: path,
}))
}
} else {
Ok(Self::Archive(ParsedArchiveUrl::from(url)))
}
@ -340,6 +413,7 @@ impl TryFrom<&ParsedUrl> for DirectUrl {
fn try_from(value: &ParsedUrl) -> Result<Self, Self::Error> {
match value {
ParsedUrl::Path(value) => Self::try_from(value),
ParsedUrl::Directory(value) => Self::try_from(value),
ParsedUrl::Git(value) => Self::try_from(value),
ParsedUrl::Archive(value) => Self::try_from(value),
}
@ -350,6 +424,21 @@ impl TryFrom<&ParsedPathUrl> for DirectUrl {
type Error = ParsedUrlError;
fn try_from(value: &ParsedPathUrl) -> Result<Self, Self::Error> {
Ok(Self::ArchiveUrl {
url: value.url.to_string(),
archive_info: ArchiveInfo {
hash: None,
hashes: None,
},
subdirectory: None,
})
}
}
impl TryFrom<&ParsedDirectoryUrl> for DirectUrl {
type Error = ParsedUrlError;
fn try_from(value: &ParsedDirectoryUrl) -> Result<Self, Self::Error> {
Ok(Self::LocalDirectory {
url: value.url.to_string(),
dir_info: DirInfo {
@ -394,6 +483,7 @@ impl From<ParsedUrl> for Url {
fn from(value: ParsedUrl) -> Self {
match value {
ParsedUrl::Path(value) => value.into(),
ParsedUrl::Directory(value) => value.into(),
ParsedUrl::Git(value) => value.into(),
ParsedUrl::Archive(value) => value.into(),
}
@ -406,6 +496,12 @@ impl From<ParsedPathUrl> for Url {
}
}
impl From<ParsedDirectoryUrl> for Url {
fn from(value: ParsedDirectoryUrl) -> Self {
value.url
}
}
impl From<ParsedArchiveUrl> for Url {
fn from(value: ParsedArchiveUrl) -> Self {
let mut url = value.url;

View file

@ -114,6 +114,9 @@ impl Display for Requirement {
RequirementSource::Path { url, .. } => {
write!(f, " @ {url}")?;
}
RequirementSource::Directory { url, .. } => {
write!(f, " @ {url}")?;
}
}
if let Some(marker) = &self.marker {
write!(f, " ; {marker}")?;
@ -166,10 +169,22 @@ pub enum RequirementSource {
url: VerbatimUrl,
},
/// A local built or source distribution, either from a path or a `file://` URL. It can either
/// be a binary distribution (a `.whl` file), a source distribution archive (a `.zip` or
/// `.tag.gz` file) or a source tree (a directory with a pyproject.toml in, or a legacy
/// source distribution with only a setup.py but non pyproject.toml in it).
/// be a binary distribution (a `.whl` file) or a source distribution archive (a `.zip` or
/// `.tar.gz` file).
Path {
/// The resolved, absolute path to the distribution which we use for installing.
install_path: PathBuf,
/// The absolute path or path relative to the workspace root pointing to the distribution
/// which we use for locking. Unlike `given` on the verbatim URL all environment variables
/// are resolved, and unlike the install path, we did not yet join it on the base directory.
lock_path: PathBuf,
/// The PEP 508 style URL in the format
/// `file:///<path>#subdirectory=<subdirectory>`.
url: VerbatimUrl,
},
/// A local source tree (a directory with a pyproject.toml in, or a legacy
/// source distribution with only a setup.py but non pyproject.toml in it).
Directory {
/// The resolved, absolute path to the distribution which we use for installing.
install_path: PathBuf,
/// The absolute path or path relative to the workspace root pointing to the distribution
@ -193,7 +208,12 @@ impl RequirementSource {
install_path: local_file.install_path.clone(),
lock_path: local_file.lock_path.clone(),
url,
editable: local_file.editable,
},
ParsedUrl::Directory(directory) => RequirementSource::Directory {
install_path: directory.install_path.clone(),
lock_path: directory.lock_path.clone(),
editable: directory.editable,
url,
},
ParsedUrl::Git(git) => RequirementSource::Git {
url,
@ -212,6 +232,6 @@ impl RequirementSource {
/// Returns `true` if the source is editable.
pub fn is_editable(&self) -> bool {
matches!(self, Self::Path { editable: true, .. })
matches!(self, Self::Directory { editable: true, .. })
}
}

View file

@ -1830,8 +1830,8 @@ mod test {
requirement: Unnamed(
UnnamedRequirement {
url: VerbatimParsedUrl {
parsed_url: Path(
ParsedPathUrl {
parsed_url: Directory(
ParsedDirectoryUrl {
url: Url {
scheme: "file",
cannot_be_a_base: false,

View file

@ -3,7 +3,7 @@ use std::path::Path;
use pep508_rs::{
Pep508Error, Pep508ErrorSource, RequirementOrigin, TracingReporter, UnnamedRequirement,
};
use pypi_types::{ParsedPathUrl, ParsedUrl, VerbatimParsedUrl};
use pypi_types::{ParsedDirectoryUrl, ParsedUrl, VerbatimParsedUrl};
use uv_normalize::PackageName;
#[derive(Debug, thiserror::Error)]
@ -14,12 +14,18 @@ pub enum EditableError {
#[error("Editable `{0}` must refer to a local directory, not a versioned package")]
Versioned(PackageName),
#[error("Editable `{0}` must refer to a local directory, not an archive: `{1}`")]
File(PackageName, String),
#[error("Editable `{0}` must refer to a local directory, not an HTTPS URL: `{1}`")]
Https(PackageName, String),
#[error("Editable `{0}` must refer to a local directory, not a Git URL: `{1}`")]
Git(PackageName, String),
#[error("Editable must refer to a local directory, not an archive: `{0}`")]
UnnamedFile(String),
#[error("Editable must refer to a local directory, not an HTTPS URL: `{0}`")]
UnnamedHttps(String),
@ -68,7 +74,10 @@ impl RequirementsTxtRequirement {
};
let parsed_url = match url.parsed_url {
ParsedUrl::Path(parsed_url) => parsed_url,
ParsedUrl::Directory(parsed_url) => parsed_url,
ParsedUrl::Path(_) => {
return Err(EditableError::File(requirement.name, url.to_string()))
}
ParsedUrl::Archive(_) => {
return Err(EditableError::Https(requirement.name, url.to_string()))
}
@ -80,7 +89,7 @@ impl RequirementsTxtRequirement {
Ok(Self::Named(pep508_rs::Requirement {
version_or_url: Some(pep508_rs::VersionOrUrl::Url(VerbatimParsedUrl {
verbatim: url.verbatim,
parsed_url: ParsedUrl::Path(ParsedPathUrl {
parsed_url: ParsedUrl::Directory(ParsedDirectoryUrl {
editable: true,
..parsed_url
}),
@ -90,7 +99,10 @@ impl RequirementsTxtRequirement {
}
RequirementsTxtRequirement::Unnamed(requirement) => {
let parsed_url = match requirement.url.parsed_url {
ParsedUrl::Path(parsed_url) => parsed_url,
ParsedUrl::Directory(parsed_url) => parsed_url,
ParsedUrl::Path(_) => {
return Err(EditableError::UnnamedFile(requirement.to_string()))
}
ParsedUrl::Archive(_) => {
return Err(EditableError::UnnamedHttps(requirement.to_string()))
}
@ -102,7 +114,7 @@ impl RequirementsTxtRequirement {
Ok(Self::Unnamed(UnnamedRequirement {
url: VerbatimParsedUrl {
verbatim: requirement.url.verbatim,
parsed_url: ParsedUrl::Path(ParsedPathUrl {
parsed_url: ParsedUrl::Directory(ParsedDirectoryUrl {
editable: true,
..parsed_url
}),

View file

@ -8,8 +8,8 @@ RequirementsTxt {
requirement: Unnamed(
UnnamedRequirement {
url: VerbatimParsedUrl {
parsed_url: Path(
ParsedPathUrl {
parsed_url: Directory(
ParsedDirectoryUrl {
url: Url {
scheme: "file",
cannot_be_a_base: false,
@ -58,8 +58,8 @@ RequirementsTxt {
requirement: Unnamed(
UnnamedRequirement {
url: VerbatimParsedUrl {
parsed_url: Path(
ParsedPathUrl {
parsed_url: Directory(
ParsedDirectoryUrl {
url: Url {
scheme: "file",
cannot_be_a_base: false,
@ -112,8 +112,8 @@ RequirementsTxt {
requirement: Unnamed(
UnnamedRequirement {
url: VerbatimParsedUrl {
parsed_url: Path(
ParsedPathUrl {
parsed_url: Directory(
ParsedDirectoryUrl {
url: Url {
scheme: "file",
cannot_be_a_base: false,

View file

@ -10,8 +10,8 @@ RequirementsTxt {
requirement: Unnamed(
UnnamedRequirement {
url: VerbatimParsedUrl {
parsed_url: Path(
ParsedPathUrl {
parsed_url: Directory(
ParsedDirectoryUrl {
url: Url {
scheme: "file",
cannot_be_a_base: false,
@ -67,8 +67,8 @@ RequirementsTxt {
requirement: Unnamed(
UnnamedRequirement {
url: VerbatimParsedUrl {
parsed_url: Path(
ParsedPathUrl {
parsed_url: Directory(
ParsedDirectoryUrl {
url: Url {
scheme: "file",
cannot_be_a_base: false,
@ -124,8 +124,8 @@ RequirementsTxt {
requirement: Unnamed(
UnnamedRequirement {
url: VerbatimParsedUrl {
parsed_url: Path(
ParsedPathUrl {
parsed_url: Directory(
ParsedDirectoryUrl {
url: Url {
scheme: "file",
cannot_be_a_base: false,
@ -202,8 +202,8 @@ RequirementsTxt {
requirement: Unnamed(
UnnamedRequirement {
url: VerbatimParsedUrl {
parsed_url: Path(
ParsedPathUrl {
parsed_url: Directory(
ParsedDirectoryUrl {
url: Url {
scheme: "file",
cannot_be_a_base: false,
@ -280,8 +280,8 @@ RequirementsTxt {
requirement: Unnamed(
UnnamedRequirement {
url: VerbatimParsedUrl {
parsed_url: Path(
ParsedPathUrl {
parsed_url: Directory(
ParsedDirectoryUrl {
url: Url {
scheme: "file",
cannot_be_a_base: false,
@ -351,8 +351,8 @@ RequirementsTxt {
requirement: Unnamed(
UnnamedRequirement {
url: VerbatimParsedUrl {
parsed_url: Path(
ParsedPathUrl {
parsed_url: Directory(
ParsedDirectoryUrl {
url: Url {
scheme: "file",
cannot_be_a_base: false,

View file

@ -8,8 +8,8 @@ RequirementsTxt {
requirement: Unnamed(
UnnamedRequirement {
url: VerbatimParsedUrl {
parsed_url: Path(
ParsedPathUrl {
parsed_url: Directory(
ParsedDirectoryUrl {
url: Url {
scheme: "file",
cannot_be_a_base: false,
@ -58,8 +58,8 @@ RequirementsTxt {
requirement: Unnamed(
UnnamedRequirement {
url: VerbatimParsedUrl {
parsed_url: Path(
ParsedPathUrl {
parsed_url: Directory(
ParsedDirectoryUrl {
url: Url {
scheme: "file",
cannot_be_a_base: false,
@ -112,8 +112,8 @@ RequirementsTxt {
requirement: Unnamed(
UnnamedRequirement {
url: VerbatimParsedUrl {
parsed_url: Path(
ParsedPathUrl {
parsed_url: Directory(
ParsedDirectoryUrl {
url: Url {
scheme: "file",
cannot_be_a_base: false,

View file

@ -10,8 +10,8 @@ RequirementsTxt {
requirement: Unnamed(
UnnamedRequirement {
url: VerbatimParsedUrl {
parsed_url: Path(
ParsedPathUrl {
parsed_url: Directory(
ParsedDirectoryUrl {
url: Url {
scheme: "file",
cannot_be_a_base: false,
@ -67,8 +67,8 @@ RequirementsTxt {
requirement: Unnamed(
UnnamedRequirement {
url: VerbatimParsedUrl {
parsed_url: Path(
ParsedPathUrl {
parsed_url: Directory(
ParsedDirectoryUrl {
url: Url {
scheme: "file",
cannot_be_a_base: false,
@ -124,8 +124,8 @@ RequirementsTxt {
requirement: Unnamed(
UnnamedRequirement {
url: VerbatimParsedUrl {
parsed_url: Path(
ParsedPathUrl {
parsed_url: Directory(
ParsedDirectoryUrl {
url: Url {
scheme: "file",
cannot_be_a_base: false,
@ -202,8 +202,8 @@ RequirementsTxt {
requirement: Unnamed(
UnnamedRequirement {
url: VerbatimParsedUrl {
parsed_url: Path(
ParsedPathUrl {
parsed_url: Directory(
ParsedDirectoryUrl {
url: Url {
scheme: "file",
cannot_be_a_base: false,
@ -280,8 +280,8 @@ RequirementsTxt {
requirement: Unnamed(
UnnamedRequirement {
url: VerbatimParsedUrl {
parsed_url: Path(
ParsedPathUrl {
parsed_url: Directory(
ParsedDirectoryUrl {
url: Url {
scheme: "file",
cannot_be_a_base: false,
@ -351,8 +351,8 @@ RequirementsTxt {
requirement: Unnamed(
UnnamedRequirement {
url: VerbatimParsedUrl {
parsed_url: Path(
ParsedPathUrl {
parsed_url: Directory(
ParsedDirectoryUrl {
url: Url {
scheme: "file",
cannot_be_a_base: false,

View file

@ -65,12 +65,10 @@ pub enum Error {
DistInfo(#[from] install_wheel_rs::Error),
#[error("Failed to read zip archive from built wheel")]
Zip(#[from] ZipError),
#[error("Source distribution directory contains neither readable pyproject.toml nor setup.py: `{}`", _0.user_display())]
#[error("Source distribution directory contains neither readable `pyproject.toml` nor `setup.py`: `{}`", _0.user_display())]
DirWithoutEntrypoint(PathBuf),
#[error("Failed to extract archive")]
Extract(#[from] uv_extract::Error),
#[error("Source distribution not found at: {0}")]
NotFound(PathBuf),
#[error("The source distribution is missing a `PKG-INFO` file")]
MissingPkgInfo,
#[error("Failed to extract static metadata from `PKG-INFO`")]
@ -83,6 +81,8 @@ pub enum Error {
UnsupportedScheme(String),
#[error(transparent)]
MetadataLowering(#[from] MetadataError),
#[error("Distribution not found at: {0}")]
NotFound(Url),
/// A generic request middleware error happened while making a request.
/// Refer to the error message for more details.

View file

@ -42,6 +42,8 @@ pub enum LoweringError {
WorkspaceFalse,
#[error("`tool.uv.sources` is a preview feature; use `--preview` or set `UV_PREVIEW=1` to enable it")]
MissingPreview,
#[error("Editable must refer to a local directory, not a file: `{0}`")]
EditableFile(String),
}
/// Combine `project.dependencies` or `project.optional-dependencies` with `tool.uv.sources`.
@ -204,7 +206,7 @@ pub(crate) fn lower_requirement(
.get(&requirement.name)
.ok_or(LoweringError::UndeclaredWorkspacePackage)?
.clone();
path_source(
directory_source(
path.root(),
workspace.root(),
workspace.root(),
@ -225,7 +227,7 @@ pub(crate) fn lower_requirement(
})
}
/// Convert a path string to a path section.
/// Convert a path string to a file or directory source.
fn path_source(
path: impl AsRef<Path>,
project_dir: &Path,
@ -242,7 +244,48 @@ fn path_source(
let ascend_to_workspace = project_dir
.strip_prefix(workspace_root)
.expect("Project must be below workspace root");
Ok(RequirementSource::Path {
let is_dir = if let Ok(metadata) = path_buf.metadata() {
metadata.is_dir()
} else {
path_buf.extension().is_none()
};
if is_dir {
Ok(RequirementSource::Directory {
install_path: path_buf,
lock_path: ascend_to_workspace.join(project_dir),
url,
editable,
})
} else {
if editable {
return Err(LoweringError::EditableFile(url.to_string()));
}
Ok(RequirementSource::Path {
install_path: path_buf,
lock_path: ascend_to_workspace.join(project_dir),
url,
})
}
}
/// Convert a path string to a directory source.
fn directory_source(
path: impl AsRef<Path>,
project_dir: &Path,
workspace_root: &Path,
editable: bool,
) -> Result<RequirementSource, LoweringError> {
let url = VerbatimUrl::parse_path(path.as_ref(), project_dir)?
.with_given(path.as_ref().to_string_lossy());
let path_buf = path.as_ref().to_path_buf();
let path_buf = path_buf
.absolutize_from(project_dir)
.map_err(|err| LoweringError::Absolutize(path.as_ref().to_path_buf(), err))?
.to_path_buf();
let ascend_to_workspace = project_dir
.strip_prefix(workspace_root)
.expect("Project must be below workspace root");
Ok(RequirementSource::Directory {
install_path: path_buf,
lock_path: ascend_to_workspace.join(project_dir),
url,

View file

@ -148,7 +148,7 @@ pub enum Source {
subdirectory: Option<String>,
},
/// The path to a dependency, either a wheel (a `.whl` file), source distribution (a `.zip` or
/// `.tag.gz` file), or source tree (i.e., a directory containing a `pyproject.toml` or
/// `.tar.gz` file), or source tree (i.e., a directory containing a `pyproject.toml` or
/// `setup.py` file in the root).
Path {
path: String,

View file

@ -831,6 +831,11 @@ impl<'a, T: BuildContext> SourceDistributionBuilder<'a, T> {
cache_shard: &CacheShard,
hashes: HashPolicy<'_>,
) -> Result<Revision, Error> {
// Verify that the archive exists.
if !resource.path.is_file() {
return Err(Error::NotFound(resource.url.clone()));
}
// Determine the last-modified time of the source distribution.
let modified = ArchiveTimestamp::from_file(&resource.path).map_err(Error::CacheRead)?;
@ -1036,6 +1041,11 @@ impl<'a, T: BuildContext> SourceDistributionBuilder<'a, T> {
resource: &DirectorySourceUrl<'_>,
cache_shard: &CacheShard,
) -> Result<Revision, Error> {
// Verify that the source tree exists.
if !resource.path.is_dir() {
return Err(Error::NotFound(resource.url.clone()));
}
// Determine the last-modified time of the source distribution.
let Some(modified) =
ArchiveTimestamp::from_source_tree(&resource.path).map_err(Error::CacheRead)?

View file

@ -176,7 +176,7 @@ impl Workspace {
name: project.name.clone(),
extras,
marker: None,
source: RequirementSource::Path {
source: RequirementSource::Directory {
install_path: member.root.clone(),
lock_path: member
.root

View file

@ -272,7 +272,8 @@ impl<'a> Planner<'a> {
continue;
}
}
RequirementSource::Path {
RequirementSource::Directory {
url,
editable,
install_path,
@ -287,25 +288,40 @@ impl<'a> Planner<'a> {
Err(err) => return Err(err.into()),
};
// Check if we have a wheel or a source distribution.
if path.is_dir() {
let sdist = DirectorySourceDist {
name: requirement.name.clone(),
url: url.clone(),
install_path: path,
lock_path: lock_path.clone(),
editable: *editable,
};
let sdist = DirectorySourceDist {
name: requirement.name.clone(),
url: url.clone(),
install_path: path,
lock_path: lock_path.clone(),
editable: *editable,
};
// Find the most-compatible wheel from the cache, since we don't know
// the filename in advance.
if let Some(wheel) = built_index.directory(&sdist)? {
let cached_dist = wheel.into_url_dist(url.clone());
debug!("Path source requirement already cached: {cached_dist}");
cached.push(CachedDist::Url(cached_dist));
continue;
// Find the most-compatible wheel from the cache, since we don't know
// the filename in advance.
if let Some(wheel) = built_index.directory(&sdist)? {
let cached_dist = wheel.into_url_dist(url.clone());
debug!("Directory source requirement already cached: {cached_dist}");
cached.push(CachedDist::Url(cached_dist));
continue;
}
}
RequirementSource::Path {
url,
install_path,
lock_path,
} => {
// Store the canonicalized path, which also serves to validate that it exists.
let path = match install_path.canonicalize() {
Ok(path) => path,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
return Err(Error::NotFound(url.to_url()).into());
}
} else if path
Err(err) => return Err(err.into()),
};
// Check if we have a wheel or a source distribution.
if path
.extension()
.is_some_and(|ext| ext.eq_ignore_ascii_case("whl"))
{

View file

@ -149,6 +149,52 @@ impl RequirementSatisfaction {
Ok(Self::Satisfied)
}
RequirementSource::Path {
install_path: requested_path,
lock_path: _,
url: _,
} => {
let InstalledDist::Url(InstalledDirectUrlDist { direct_url, .. }) = &distribution
else {
return Ok(Self::Mismatch);
};
let DirectUrl::ArchiveUrl {
url: installed_url,
archive_info: _,
subdirectory: None,
} = direct_url.as_ref()
else {
return Ok(Self::Mismatch);
};
let Some(installed_path) = Url::parse(installed_url)
.ok()
.and_then(|url| url.to_file_path().ok())
else {
return Ok(Self::Mismatch);
};
if !(*requested_path == installed_path
|| is_same_file(requested_path, &installed_path).unwrap_or(false))
{
trace!(
"Path mismatch: {:?} vs. {:?}",
requested_path,
installed_path
);
return Ok(Self::Satisfied);
}
if !ArchiveTimestamp::up_to_date_with(
requested_path,
ArchiveTarget::Install(distribution),
)? {
trace!("Installed package is out of date");
return Ok(Self::OutOfDate);
}
Ok(Self::Satisfied)
}
RequirementSource::Directory {
install_path: requested_path,
lock_path: _,
editable: requested_editable,
@ -205,13 +251,11 @@ impl RequirementSatisfaction {
}
// Does the package have dynamic metadata?
// TODO(charlie): Separate `RequirementSource` into `Path` and `Directory`.
if requested_path.is_dir() && is_dynamic(requested_path) {
if is_dynamic(requested_path) {
trace!("Dependency is dynamic");
return Ok(Self::Dynamic);
}
// Otherwise, assume the requirement is up-to-date.
Ok(Self::Satisfied)
}
}

View file

@ -276,12 +276,22 @@ fn required_dist(requirement: &Requirement) -> Result<Option<Dist>, distribution
install_path,
lock_path,
url,
editable,
} => Dist::from_file_url(
requirement.name.clone(),
url.clone(),
install_path,
lock_path,
)?,
RequirementSource::Directory {
install_path,
lock_path,
url,
editable,
} => Dist::from_directory_url(
requirement.name.clone(),
url.clone(),
install_path,
lock_path,
*editable,
)?,
}))

View file

@ -139,15 +139,16 @@ impl<'a, Context: BuildContext> NamedRequirementsResolver<'a, Context> {
let source = match &requirement.url.parsed_url {
// If the path points to a directory, attempt to read the name from static metadata.
ParsedUrl::Path(parsed_path_url) if parsed_path_url.install_path.is_dir() => {
ParsedUrl::Directory(parsed_directory_url) => {
// Attempt to read a `PKG-INFO` from the directory.
if let Some(metadata) = fs_err::read(parsed_path_url.install_path.join("PKG-INFO"))
.ok()
.and_then(|contents| Metadata10::parse_pkg_info(&contents).ok())
if let Some(metadata) =
fs_err::read(parsed_directory_url.install_path.join("PKG-INFO"))
.ok()
.and_then(|contents| Metadata10::parse_pkg_info(&contents).ok())
{
debug!(
"Found PKG-INFO metadata for {path} ({name})",
path = parsed_path_url.install_path.display(),
path = parsed_directory_url.install_path.display(),
name = metadata.name
);
return Ok(pep508_rs::Requirement {
@ -160,7 +161,7 @@ impl<'a, Context: BuildContext> NamedRequirementsResolver<'a, Context> {
}
// Attempt to read a `pyproject.toml` file.
let project_path = parsed_path_url.install_path.join("pyproject.toml");
let project_path = parsed_directory_url.install_path.join("pyproject.toml");
if let Some(pyproject) = fs_err::read_to_string(project_path)
.ok()
.and_then(|contents| toml::from_str::<PyProjectToml>(&contents).ok())
@ -169,7 +170,7 @@ impl<'a, Context: BuildContext> NamedRequirementsResolver<'a, Context> {
if let Some(project) = pyproject.project {
debug!(
"Found PEP 621 metadata for {path} in `pyproject.toml` ({name})",
path = parsed_path_url.install_path.display(),
path = parsed_directory_url.install_path.display(),
name = project.name
);
return Ok(pep508_rs::Requirement {
@ -187,7 +188,7 @@ impl<'a, Context: BuildContext> NamedRequirementsResolver<'a, Context> {
if let Some(name) = poetry.name {
debug!(
"Found Poetry metadata for {path} in `pyproject.toml` ({name})",
path = parsed_path_url.install_path.display(),
path = parsed_directory_url.install_path.display(),
name = name
);
return Ok(pep508_rs::Requirement {
@ -204,7 +205,7 @@ impl<'a, Context: BuildContext> NamedRequirementsResolver<'a, Context> {
// Attempt to read a `setup.cfg` from the directory.
if let Some(setup_cfg) =
fs_err::read_to_string(parsed_path_url.install_path.join("setup.cfg"))
fs_err::read_to_string(parsed_directory_url.install_path.join("setup.cfg"))
.ok()
.and_then(|contents| {
let mut ini = Ini::new_cs();
@ -217,7 +218,7 @@ impl<'a, Context: BuildContext> NamedRequirementsResolver<'a, Context> {
if let Ok(name) = PackageName::from_str(name) {
debug!(
"Found setuptools metadata for {path} in `setup.cfg` ({name})",
path = parsed_path_url.install_path.display(),
path = parsed_directory_url.install_path.display(),
name = name
);
return Ok(pep508_rs::Requirement {
@ -234,11 +235,10 @@ impl<'a, Context: BuildContext> NamedRequirementsResolver<'a, Context> {
SourceUrl::Directory(DirectorySourceUrl {
url: &requirement.url.verbatim,
path: Cow::Borrowed(&parsed_path_url.install_path),
editable: parsed_path_url.editable,
path: Cow::Borrowed(&parsed_directory_url.install_path),
editable: parsed_directory_url.editable,
})
}
// If it's not a directory, assume it's a file.
ParsedUrl::Path(parsed_path_url) => SourceUrl::Path(PathSourceUrl {
url: &requirement.url.verbatim,
path: Cow::Borrowed(&parsed_path_url.install_path),

View file

@ -10,7 +10,8 @@ use distribution_types::Verbatim;
use pep440_rs::Version;
use pep508_rs::{MarkerEnvironment, MarkerTree};
use pypi_types::{
ParsedArchiveUrl, ParsedGitUrl, ParsedPathUrl, ParsedUrl, Requirement, RequirementSource,
ParsedArchiveUrl, ParsedDirectoryUrl, ParsedGitUrl, ParsedPathUrl, ParsedUrl, Requirement,
RequirementSource,
};
use uv_configuration::{Constraints, Overrides};
use uv_git::GitResolver;
@ -346,7 +347,6 @@ impl PubGrubRequirement {
})
}
RequirementSource::Path {
editable,
url,
install_path,
lock_path,
@ -359,6 +359,42 @@ impl PubGrubRequirement {
};
let parsed_url = ParsedUrl::Path(ParsedPathUrl::from_source(
install_path.clone(),
lock_path.clone(),
url.to_url(),
));
if !Urls::same_resource(&expected.parsed_url, &parsed_url, git) {
return Err(ResolveError::ConflictingUrlsTransitive(
requirement.name.clone(),
expected.verbatim.verbatim().to_string(),
url.verbatim().to_string(),
));
}
Ok(Self {
package: PubGrubPackage::from_url(
requirement.name.clone(),
extra,
requirement.marker.clone(),
expected.clone(),
),
version: Range::full(),
})
}
RequirementSource::Directory {
editable,
url,
install_path,
lock_path,
} => {
let Some(expected) = urls.get(&requirement.name) else {
return Err(ResolveError::DisallowedUrl(
requirement.name.clone(),
url.to_string(),
));
};
let parsed_url = ParsedUrl::Directory(ParsedDirectoryUrl::from_source(
install_path.clone(),
lock_path.clone(),
*editable,

View file

@ -196,6 +196,7 @@ fn iter_locals(source: &RequirementSource) -> Box<dyn Iterator<Item = Version> +
.into_iter()
.filter(pep440_rs::Version::is_local),
),
RequirementSource::Directory { .. } => Box::new(iter::empty()),
}
}

View file

@ -6,7 +6,8 @@ use cache_key::CanonicalUrl;
use distribution_types::Verbatim;
use pep508_rs::MarkerEnvironment;
use pypi_types::{
ParsedArchiveUrl, ParsedGitUrl, ParsedPathUrl, ParsedUrl, RequirementSource, VerbatimParsedUrl,
ParsedArchiveUrl, ParsedDirectoryUrl, ParsedGitUrl, ParsedPathUrl, ParsedUrl,
RequirementSource, VerbatimParsedUrl,
};
use uv_git::GitResolver;
use uv_normalize::PackageName;
@ -55,11 +56,34 @@ impl Urls {
RequirementSource::Path {
install_path,
lock_path,
editable,
url,
} => {
let url = VerbatimParsedUrl {
parsed_url: ParsedUrl::Path(ParsedPathUrl::from_source(
install_path.clone(),
lock_path.clone(),
url.to_url(),
)),
verbatim: url.clone(),
};
if let Some(previous) = urls.insert(requirement.name.clone(), url.clone()) {
if !Self::same_resource(&previous.parsed_url, &url.parsed_url, git) {
return Err(ResolveError::ConflictingUrlsDirect(
requirement.name.clone(),
previous.verbatim.verbatim().to_string(),
url.verbatim.verbatim().to_string(),
));
}
}
}
RequirementSource::Directory {
install_path,
lock_path,
editable,
url,
} => {
let url = VerbatimParsedUrl {
parsed_url: ParsedUrl::Directory(ParsedDirectoryUrl::from_source(
install_path.clone(),
lock_path.clone(),
*editable,
@ -145,6 +169,10 @@ impl Urls {
a.install_path == b.install_path
|| is_same_file(&a.install_path, &b.install_path).unwrap_or(false)
}
(ParsedUrl::Directory(a), ParsedUrl::Directory(b)) => {
a.install_path == b.install_path
|| is_same_file(&a.install_path, &b.install_path).unwrap_or(false)
}
_ => false,
}
}

View file

@ -150,7 +150,8 @@ fn uv_requirement_to_package_id(requirement: &Requirement) -> Result<PackageId,
}
RequirementSource::Url { url, .. }
| RequirementSource::Git { url, .. }
| RequirementSource::Path { url, .. } => PackageId::from_url(url),
| RequirementSource::Path { url, .. }
| RequirementSource::Directory { url, .. } => PackageId::from_url(url),
})
}

View file

@ -4791,9 +4791,9 @@ fn missing_path_requirement() -> Result<()> {
Ok(())
}
/// Attempt to resolve an editable requirement at a path that doesn't exist.
/// Attempt to resolve an editable requirement at a file path that doesn't exist.
#[test]
fn missing_editable_requirement() -> Result<()> {
fn missing_editable_file() -> Result<()> {
let context = TestContext::new("3.12");
let requirements_in = context.temp_dir.child("requirements.in");
requirements_in.write_str("-e foo/anyio-3.7.0.tar.gz")?;
@ -4805,7 +4805,28 @@ fn missing_editable_requirement() -> Result<()> {
----- stdout -----
----- stderr -----
error: Distribution not found at: file://[TEMP_DIR]/foo/anyio-3.7.0.tar.gz
error: Unsupported editable requirement in `requirements.in`
Caused by: Editable must refer to a local directory, not an archive: `file://[TEMP_DIR]/foo/anyio-3.7.0.tar.gz`
"###);
Ok(())
}
/// Attempt to resolve an editable requirement at a directory path that doesn't exist.
#[test]
fn missing_editable_directory() -> Result<()> {
let context = TestContext::new("3.12");
let requirements_in = context.temp_dir.child("requirements.in");
requirements_in.write_str("-e foo/bar")?;
uv_snapshot!(context.filters(), context.compile()
.arg("requirements.in"), @r###"
success: false
exit_code: 2
----- stdout -----
----- stderr -----
error: Distribution not found at: file://[TEMP_DIR]/foo/bar
"###);
Ok(())

View file

@ -1081,6 +1081,25 @@ fn install_local_wheel() -> Result<()> {
context.assert_command("import tomli").success();
// Reinstall without the package name.
let requirements_txt = context.temp_dir.child("requirements.txt");
requirements_txt.write_str(&format!("{}", Url::from_file_path(archive.path()).unwrap()))?;
uv_snapshot!(context.filters(), sync_without_exclude_newer(&context)
.arg("requirements.txt")
.arg("--strict"), @r###"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Resolved 1 package in [TIME]
Audited 1 package in [TIME]
"###
);
context.assert_command("import tomli").success();
Ok(())
}

2
uv.schema.json generated
View file

@ -714,7 +714,7 @@
"additionalProperties": false
},
{
"description": "The path to a dependency, either a wheel (a `.whl` file), source distribution (a `.zip` or `.tag.gz` file), or source tree (i.e., a directory containing a `pyproject.toml` or `setup.py` file in the root).",
"description": "The path to a dependency, either a wheel (a `.whl` file), source distribution (a `.zip` or `.tar.gz` file), or source tree (i.e., a directory containing a `pyproject.toml` or `setup.py` file in the root).",
"type": "object",
"required": [
"path"