From d8f1de61347b5dc18bfde977dc1b3c34f8a3de01 Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Wed, 12 Jun 2024 12:59:21 -0700 Subject: [PATCH] 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. --- crates/distribution-types/src/cached.rs | 4 +- crates/distribution-types/src/error.rs | 10 -- crates/distribution-types/src/lib.rs | 62 +++++--- crates/distribution-types/src/resolution.rs | 4 +- crates/pypi-types/src/parsed_url.rs | 150 ++++++++++++++---- crates/pypi-types/src/requirement.rs | 30 +++- crates/requirements-txt/src/lib.rs | 4 +- crates/requirements-txt/src/requirement.rs | 22 ++- ...ts_txt__test__parse-unix-bare-url.txt.snap | 12 +- ...ts_txt__test__parse-unix-editable.txt.snap | 24 +-- ...txt__test__parse-windows-bare-url.txt.snap | 12 +- ...txt__test__parse-windows-editable.txt.snap | 24 +-- crates/uv-distribution/src/error.rs | 6 +- .../uv-distribution/src/metadata/lowering.rs | 49 +++++- crates/uv-distribution/src/pyproject.rs | 2 +- crates/uv-distribution/src/source/mod.rs | 10 ++ crates/uv-distribution/src/workspace.rs | 2 +- crates/uv-installer/src/plan.rs | 52 +++--- crates/uv-installer/src/satisfies.rs | 50 +++++- crates/uv-requirements/src/lookahead.rs | 12 +- crates/uv-requirements/src/unnamed.rs | 26 +-- .../uv-resolver/src/pubgrub/dependencies.rs | 40 ++++- crates/uv-resolver/src/resolver/locals.rs | 1 + crates/uv-resolver/src/resolver/urls.rs | 32 +++- crates/uv-types/src/hash.rs | 3 +- crates/uv/tests/pip_compile.rs | 27 +++- crates/uv/tests/pip_sync.rs | 19 +++ uv.schema.json | 2 +- 28 files changed, 524 insertions(+), 167 deletions(-) diff --git a/crates/distribution-types/src/cached.rs b/crates/distribution-types/src/cached.rs index 7193bf593..624b8a727 100644 --- a/crates/distribution-types/src/cached.rs +++ b/crates/distribution-types/src/cached.rs @@ -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, diff --git a/crates/distribution-types/src/error.rs b/crates/distribution-types/src/error.rs index 80a2206b2..5cd07b05b 100644 --- a/crates/distribution-types/src/error.rs +++ b/crates/distribution-types/src/error.rs @@ -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), } diff --git a/crates/distribution-types/src/lib.rs b/crates/distribution-types/src/lib.rs index 87b0542a3..50d1abd03 100644 --- a/crates/distribution-types/src/lib.rs +++ b/crates/distribution-types/src/lib.rs @@ -329,7 +329,6 @@ impl Dist { url: VerbatimUrl, install_path: &Path, lock_path: &Path, - editable: bool, ) -> Result { // 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 { + // 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) diff --git a/crates/distribution-types/src/resolution.rs b/crates/distribution-types/src/resolution.rs index bb7f15d40..415338733 100644 --- a/crates/distribution-types/src/resolution.rs +++ b/crates/distribution-types/src/resolution.rs @@ -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(), diff --git a/crates/pypi-types/src/parsed_url.rs b/crates/pypi-types/src/parsed_url.rs index 56ccd463a..2b1df5cc2 100644 --- a/crates/pypi-types/src/parsed_url.rs +++ b/crates/pypi-types/src/parsed_url.rs @@ -44,10 +44,10 @@ impl Pep508Url for VerbatimParsedUrl { type Err = ParsedUrlError; fn parse_url(url: &str, working_dir: Option<&Path>) -> Result { - let verbatim_url = ::parse_url(url, working_dir)?; + let verbatim = ::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, ) -> Result { 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) -> Result { 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 @ ` 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 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 { 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 { + 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 { Ok(Self::LocalDirectory { url: value.url.to_string(), dir_info: DirInfo { @@ -394,6 +483,7 @@ impl From 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 for Url { } } +impl From for Url { + fn from(value: ParsedDirectoryUrl) -> Self { + value.url + } +} + impl From for Url { fn from(value: ParsedArchiveUrl) -> Self { let mut url = value.url; diff --git a/crates/pypi-types/src/requirement.rs b/crates/pypi-types/src/requirement.rs index 809dc1d99..c4f9512d6 100644 --- a/crates/pypi-types/src/requirement.rs +++ b/crates/pypi-types/src/requirement.rs @@ -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:///#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, .. }) } } diff --git a/crates/requirements-txt/src/lib.rs b/crates/requirements-txt/src/lib.rs index ffd0a4406..3ca2a5a6a 100644 --- a/crates/requirements-txt/src/lib.rs +++ b/crates/requirements-txt/src/lib.rs @@ -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, diff --git a/crates/requirements-txt/src/requirement.rs b/crates/requirements-txt/src/requirement.rs index 2837677d7..e466d4a99 100644 --- a/crates/requirements-txt/src/requirement.rs +++ b/crates/requirements-txt/src/requirement.rs @@ -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 }), diff --git a/crates/requirements-txt/src/snapshots/requirements_txt__test__parse-unix-bare-url.txt.snap b/crates/requirements-txt/src/snapshots/requirements_txt__test__parse-unix-bare-url.txt.snap index 5e826f4c3..5b354448d 100644 --- a/crates/requirements-txt/src/snapshots/requirements_txt__test__parse-unix-bare-url.txt.snap +++ b/crates/requirements-txt/src/snapshots/requirements_txt__test__parse-unix-bare-url.txt.snap @@ -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, diff --git a/crates/requirements-txt/src/snapshots/requirements_txt__test__parse-unix-editable.txt.snap b/crates/requirements-txt/src/snapshots/requirements_txt__test__parse-unix-editable.txt.snap index 6b13c153b..958a8d6bf 100644 --- a/crates/requirements-txt/src/snapshots/requirements_txt__test__parse-unix-editable.txt.snap +++ b/crates/requirements-txt/src/snapshots/requirements_txt__test__parse-unix-editable.txt.snap @@ -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, diff --git a/crates/requirements-txt/src/snapshots/requirements_txt__test__parse-windows-bare-url.txt.snap b/crates/requirements-txt/src/snapshots/requirements_txt__test__parse-windows-bare-url.txt.snap index 53ff4c233..ddf5d5c52 100644 --- a/crates/requirements-txt/src/snapshots/requirements_txt__test__parse-windows-bare-url.txt.snap +++ b/crates/requirements-txt/src/snapshots/requirements_txt__test__parse-windows-bare-url.txt.snap @@ -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, diff --git a/crates/requirements-txt/src/snapshots/requirements_txt__test__parse-windows-editable.txt.snap b/crates/requirements-txt/src/snapshots/requirements_txt__test__parse-windows-editable.txt.snap index 1ba08e3be..97b6ddded 100644 --- a/crates/requirements-txt/src/snapshots/requirements_txt__test__parse-windows-editable.txt.snap +++ b/crates/requirements-txt/src/snapshots/requirements_txt__test__parse-windows-editable.txt.snap @@ -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, diff --git a/crates/uv-distribution/src/error.rs b/crates/uv-distribution/src/error.rs index 80ed26c27..76cecb56e 100644 --- a/crates/uv-distribution/src/error.rs +++ b/crates/uv-distribution/src/error.rs @@ -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. diff --git a/crates/uv-distribution/src/metadata/lowering.rs b/crates/uv-distribution/src/metadata/lowering.rs index 3ac3d4cce..3f3629d6a 100644 --- a/crates/uv-distribution/src/metadata/lowering.rs +++ b/crates/uv-distribution/src/metadata/lowering.rs @@ -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, 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, + project_dir: &Path, + workspace_root: &Path, + editable: bool, +) -> Result { + 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, diff --git a/crates/uv-distribution/src/pyproject.rs b/crates/uv-distribution/src/pyproject.rs index 984481179..462892c78 100644 --- a/crates/uv-distribution/src/pyproject.rs +++ b/crates/uv-distribution/src/pyproject.rs @@ -148,7 +148,7 @@ pub enum Source { subdirectory: Option, }, /// 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, diff --git a/crates/uv-distribution/src/source/mod.rs b/crates/uv-distribution/src/source/mod.rs index a67690d27..17e8fe065 100644 --- a/crates/uv-distribution/src/source/mod.rs +++ b/crates/uv-distribution/src/source/mod.rs @@ -831,6 +831,11 @@ impl<'a, T: BuildContext> SourceDistributionBuilder<'a, T> { cache_shard: &CacheShard, hashes: HashPolicy<'_>, ) -> Result { + // 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 { + // 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)? diff --git a/crates/uv-distribution/src/workspace.rs b/crates/uv-distribution/src/workspace.rs index 0a09c2fa8..3d395aa82 100644 --- a/crates/uv-distribution/src/workspace.rs +++ b/crates/uv-distribution/src/workspace.rs @@ -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 diff --git a/crates/uv-installer/src/plan.rs b/crates/uv-installer/src/plan.rs index 322e0b99b..9ba4b77a3 100644 --- a/crates/uv-installer/src/plan.rs +++ b/crates/uv-installer/src/plan.rs @@ -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")) { diff --git a/crates/uv-installer/src/satisfies.rs b/crates/uv-installer/src/satisfies.rs index c19a9c0fe..d12a2dfc0 100644 --- a/crates/uv-installer/src/satisfies.rs +++ b/crates/uv-installer/src/satisfies.rs @@ -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) } } diff --git a/crates/uv-requirements/src/lookahead.rs b/crates/uv-requirements/src/lookahead.rs index 83a3e018a..27c26ce57 100644 --- a/crates/uv-requirements/src/lookahead.rs +++ b/crates/uv-requirements/src/lookahead.rs @@ -276,12 +276,22 @@ fn required_dist(requirement: &Requirement) -> Result, 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, )?, })) diff --git a/crates/uv-requirements/src/unnamed.rs b/crates/uv-requirements/src/unnamed.rs index 74cf0d0de..39365537b 100644 --- a/crates/uv-requirements/src/unnamed.rs +++ b/crates/uv-requirements/src/unnamed.rs @@ -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::(&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), diff --git a/crates/uv-resolver/src/pubgrub/dependencies.rs b/crates/uv-resolver/src/pubgrub/dependencies.rs index e01f73d8f..f532a529c 100644 --- a/crates/uv-resolver/src/pubgrub/dependencies.rs +++ b/crates/uv-resolver/src/pubgrub/dependencies.rs @@ -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, diff --git a/crates/uv-resolver/src/resolver/locals.rs b/crates/uv-resolver/src/resolver/locals.rs index bc32f4abe..079085ba2 100644 --- a/crates/uv-resolver/src/resolver/locals.rs +++ b/crates/uv-resolver/src/resolver/locals.rs @@ -196,6 +196,7 @@ fn iter_locals(source: &RequirementSource) -> Box + .into_iter() .filter(pep440_rs::Version::is_local), ), + RequirementSource::Directory { .. } => Box::new(iter::empty()), } } diff --git a/crates/uv-resolver/src/resolver/urls.rs b/crates/uv-resolver/src/resolver/urls.rs index fa5500494..eb787e60d 100644 --- a/crates/uv-resolver/src/resolver/urls.rs +++ b/crates/uv-resolver/src/resolver/urls.rs @@ -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, } } diff --git a/crates/uv-types/src/hash.rs b/crates/uv-types/src/hash.rs index 2f3229efd..88d9e0da1 100644 --- a/crates/uv-types/src/hash.rs +++ b/crates/uv-types/src/hash.rs @@ -150,7 +150,8 @@ fn uv_requirement_to_package_id(requirement: &Requirement) -> Result PackageId::from_url(url), + | RequirementSource::Path { url, .. } + | RequirementSource::Directory { url, .. } => PackageId::from_url(url), }) } diff --git a/crates/uv/tests/pip_compile.rs b/crates/uv/tests/pip_compile.rs index b7576a82f..195609531 100644 --- a/crates/uv/tests/pip_compile.rs +++ b/crates/uv/tests/pip_compile.rs @@ -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(()) diff --git a/crates/uv/tests/pip_sync.rs b/crates/uv/tests/pip_sync.rs index d57885dc0..aa3327a1a 100644 --- a/crates/uv/tests/pip_sync.rs +++ b/crates/uv/tests/pip_sync.rs @@ -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(()) } diff --git a/uv.schema.json b/uv.schema.json index 0dd2e0923..3d58d690b 100644 --- a/uv.schema.json +++ b/uv.schema.json @@ -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"