mirror of
https://github.com/astral-sh/uv.git
synced 2025-07-07 21:35:00 +00:00
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:
parent
c4483017ac
commit
d8f1de6134
28 changed files with 524 additions and 167 deletions
|
@ -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,
|
||||
|
|
|
@ -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),
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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(),
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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, .. })
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
}),
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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)?
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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"))
|
||||
{
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
)?,
|
||||
}))
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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()),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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),
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
@ -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(())
|
||||
|
|
|
@ -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
2
uv.schema.json
generated
|
@ -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"
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue