diff --git a/crates/uv-distribution-types/src/requirement.rs b/crates/uv-distribution-types/src/requirement.rs index 19a761546..e0e5261b1 100644 --- a/crates/uv-distribution-types/src/requirement.rs +++ b/crates/uv-distribution-types/src/requirement.rs @@ -6,7 +6,7 @@ use std::str::FromStr; use thiserror::Error; use uv_cache_key::{CacheKey, CacheKeyHasher}; use uv_distribution_filename::DistExtension; -use uv_fs::{CWD, PortablePath, PortablePathBuf, relative_to}; +use uv_fs::{CWD, PortablePath, PortablePathBuf, try_relative_to_if}; use uv_git_types::{GitLfs, GitOid, GitReference, GitUrl, GitUrlParseError, OidParseError}; use uv_normalize::{ExtraName, GroupName, PackageName}; use uv_pep440::VersionSpecifiers; @@ -694,8 +694,7 @@ impl RequirementSource { ext, url, } => Ok(Self::Path { - install_path: relative_to(&install_path, path) - .or_else(|_| std::path::absolute(install_path))? + install_path: try_relative_to_if(&install_path, path, !url.was_given_absolute())? .into_boxed_path(), ext, url, @@ -707,8 +706,7 @@ impl RequirementSource { url, .. } => Ok(Self::Directory { - install_path: relative_to(&install_path, path) - .or_else(|_| std::path::absolute(install_path))? + install_path: try_relative_to_if(&install_path, path, !url.was_given_absolute())? .into_boxed_path(), editable, r#virtual, diff --git a/crates/uv-fs/src/path.rs b/crates/uv-fs/src/path.rs index 9253a24bb..7160fed47 100644 --- a/crates/uv-fs/src/path.rs +++ b/crates/uv-fs/src/path.rs @@ -318,6 +318,20 @@ pub fn relative_to( Ok(up.join(stripped)) } +/// Try to compute a path relative to `base` if `should_relativize` is true, otherwise return +/// the absolute path. Falls back to absolute if relativization fails. +pub fn try_relative_to_if( + path: impl AsRef, + base: impl AsRef, + should_relativize: bool, +) -> Result { + if should_relativize { + relative_to(&path, &base).or_else(|_| std::path::absolute(path.as_ref())) + } else { + std::path::absolute(path.as_ref()) + } +} + /// A path that can be serialized and deserialized in a portable way by converting Windows-style /// backslashes to forward slashes, and using a `.` for an empty path. /// diff --git a/crates/uv-pep508/src/verbatim_url.rs b/crates/uv-pep508/src/verbatim_url.rs index 965ef698f..ff424658b 100644 --- a/crates/uv-pep508/src/verbatim_url.rs +++ b/crates/uv-pep508/src/verbatim_url.rs @@ -213,6 +213,21 @@ impl VerbatimUrl { self.given.as_deref() } + /// Returns `true` if the `given` input was an absolute path or file URL. + pub fn was_given_absolute(&self) -> bool { + let Some(given) = &self.given else { + return false; + }; + + if let Some((scheme, _)) = split_scheme(given) { + if let Some(parsed_scheme) = Scheme::parse(scheme) { + return parsed_scheme.is_file(); + } + } + + Path::new(given.as_str()).is_absolute() + } + /// Return the underlying [`DisplaySafeUrl`]. pub fn raw(&self) -> &DisplaySafeUrl { &self.url diff --git a/crates/uv-resolver/src/lock/export/pylock_toml.rs b/crates/uv-resolver/src/lock/export/pylock_toml.rs index 97d812605..e2a792cc4 100644 --- a/crates/uv-resolver/src/lock/export/pylock_toml.rs +++ b/crates/uv-resolver/src/lock/export/pylock_toml.rs @@ -1,6 +1,6 @@ use std::borrow::Cow; use std::ffi::OsStr; -use std::path::Path; +use std::path::{Path, PathBuf}; use std::str::FromStr; use std::sync::Arc; @@ -27,7 +27,7 @@ use uv_distribution_types::{ RegistryBuiltDist, RegistryBuiltWheel, RegistrySourceDist, RemoteSource, RequiresPython, Resolution, ResolvedDist, SourceDist, ToUrlError, UrlString, }; -use uv_fs::{PortablePathBuf, relative_to}; +use uv_fs::{PortablePathBuf, try_relative_to_if}; use uv_git::{RepositoryReference, ResolvedRepositoryReference}; use uv_git_types::{GitLfs, GitOid, GitReference, GitUrl, GitUrlParseError}; use uv_normalize::{ExtraName, GroupName, PackageName}; @@ -411,9 +411,13 @@ impl<'lock> PylockToml { }); } Dist::Built(BuiltDist::Path(dist)) => { - let path = relative_to(&dist.install_path, install_path) - .map(Box::::from) - .unwrap_or_else(|_| dist.install_path.clone()); + let path = try_relative_to_if( + &dist.install_path, + install_path, + !dist.url.was_given_absolute(), + ) + .map(Box::::from) + .unwrap_or_else(|_| dist.install_path.clone()); package.archive = Some(PylockTomlArchive { url: None, path: Some(PortablePathBuf::from(path)), @@ -477,9 +481,13 @@ impl<'lock> PylockToml { }); } Dist::Source(SourceDist::Directory(dist)) => { - let path = relative_to(&dist.install_path, install_path) - .map(Box::::from) - .unwrap_or_else(|_| dist.install_path.clone()); + let path = try_relative_to_if( + &dist.install_path, + install_path, + !dist.url.was_given_absolute(), + ) + .map(Box::::from) + .unwrap_or_else(|_| dist.install_path.clone()); package.directory = Some(PylockTomlDirectory { path: PortablePathBuf::from(path), editable: dist.editable, @@ -499,9 +507,13 @@ impl<'lock> PylockToml { }); } Dist::Source(SourceDist::Path(dist)) => { - let path = relative_to(&dist.install_path, install_path) - .map(Box::::from) - .unwrap_or_else(|_| dist.install_path.clone()); + let path = try_relative_to_if( + &dist.install_path, + install_path, + !dist.url.was_given_absolute(), + ) + .map(Box::::from) + .unwrap_or_else(|_| dist.install_path.clone()); package.archive = Some(PylockTomlArchive { url: None, path: Some(PortablePathBuf::from(path)), @@ -761,8 +773,11 @@ impl<'lock> PylockToml { let directory = match &sdist { Some(SourceDist::Directory(sdist)) => Some(PylockTomlDirectory { path: PortablePathBuf::from( - relative_to(&sdist.install_path, target.install_path()) - .unwrap_or_else(|_| sdist.install_path.to_path_buf()) + sdist + .url + .given() + .map(PathBuf::from) + .unwrap_or_else(|| sdist.install_path.to_path_buf()) .into_boxed_path(), ), editable: match editable { @@ -804,8 +819,11 @@ impl<'lock> PylockToml { Some(SourceDist::Path(sdist)) => Some(PylockTomlArchive { url: None, path: Some(PortablePathBuf::from( - relative_to(&sdist.install_path, target.install_path()) - .unwrap_or_else(|_| sdist.install_path.to_path_buf()) + sdist + .url + .given() + .map(PathBuf::from) + .unwrap_or_else(|| sdist.install_path.to_path_buf()) .into_boxed_path(), )), size, @@ -817,11 +835,7 @@ impl<'lock> PylockToml { Source::Registry(..) => None, Source::Path(source) => package.wheels.first().map(|wheel| PylockTomlArchive { url: None, - path: Some(PortablePathBuf::from( - relative_to(source, target.install_path()) - .unwrap_or_else(|_| source.to_path_buf()) - .into_boxed_path(), - )), + path: Some(PortablePathBuf::from(source.clone())), size: wheel.size, upload_time: None, subdirectory: None, diff --git a/crates/uv-resolver/src/lock/mod.rs b/crates/uv-resolver/src/lock/mod.rs index da6c7cc9a..c56a33866 100644 --- a/crates/uv-resolver/src/lock/mod.rs +++ b/crates/uv-resolver/src/lock/mod.rs @@ -31,12 +31,14 @@ use uv_distribution_types::{ RemoteSource, Requirement, RequirementSource, RequiresPython, ResolvedDist, SimplifiedMarkerTree, StaticMetadata, ToUrlError, UrlString, }; -use uv_fs::{PortablePath, PortablePathBuf, Simplified, relative_to}; +use uv_fs::{PortablePath, PortablePathBuf, Simplified, try_relative_to_if}; use uv_git::{RepositoryReference, ResolvedRepositoryReference}; use uv_git_types::{GitLfs, GitOid, GitReference, GitUrl, GitUrlParseError}; use uv_normalize::{ExtraName, GroupName, PackageName}; use uv_pep440::Version; -use uv_pep508::{MarkerEnvironment, MarkerTree, VerbatimUrl, VerbatimUrlError, split_scheme}; +use uv_pep508::{ + MarkerEnvironment, MarkerTree, Scheme, VerbatimUrl, VerbatimUrlError, split_scheme, +}; use uv_platform_tags::{ AbiTag, IncompatibleTag, LanguageTag, PlatformTag, TagCompatibility, TagPriority, Tags, }; @@ -1640,8 +1642,7 @@ impl Lock { IndexUrl::Pypi(_) | IndexUrl::Url(_) => None, IndexUrl::Path(url) => { let path = url.to_file_path().ok()?; - let path = relative_to(&path, root) - .or_else(|_| std::path::absolute(path)) + let path = try_relative_to_if(&path, root, !url.was_given_absolute()) .ok()? .into_boxed_path(); Some(path) @@ -1689,9 +1690,7 @@ impl Lock { IndexUrl::Path(url) => { if let Some(locals) = locals.as_mut() { if let Some(path) = url.to_file_path().ok().and_then(|path| { - relative_to(&path, root) - .or_else(|_| std::path::absolute(path)) - .ok() + try_relative_to_if(&path, root, !url.was_given_absolute()).ok() }) { locals.insert(path.into_boxed_path()); } @@ -2034,9 +2033,7 @@ impl Lock { IndexUrl::Path(url) => { if let Some(locals) = locals.as_mut() { if let Some(path) = url.to_file_path().ok().and_then(|path| { - relative_to(&path, root) - .or_else(|_| std::path::absolute(path)) - .ok() + try_relative_to_if(&path, root, !url.was_given_absolute()).ok() }) { locals.insert(path.into_boxed_path()); } @@ -2797,10 +2794,11 @@ impl Package { return Ok(None); }; let install_path = absolute_path(workspace_root, path)?; + let given = path.to_str().expect("lock file paths must be UTF-8"); let path_dist = PathSourceDist { name: self.id.name.clone(), version: self.id.version.clone(), - url: verbatim_url(&install_path, &self.id)?, + url: verbatim_url(&install_path, &self.id)?.with_given(given), install_path: install_path.into_boxed_path(), ext, }; @@ -2808,9 +2806,10 @@ impl Package { } Source::Directory(path) => { let install_path = absolute_path(workspace_root, path)?; + let given = path.to_str().expect("lock file paths must be UTF-8"); let dir_dist = DirectorySourceDist { name: self.id.name.clone(), - url: verbatim_url(&install_path, &self.id)?, + url: verbatim_url(&install_path, &self.id)?.with_given(given), install_path: install_path.into_boxed_path(), editable: Some(false), r#virtual: Some(false), @@ -2819,9 +2818,10 @@ impl Package { } Source::Editable(path) => { let install_path = absolute_path(workspace_root, path)?; + let given = path.to_str().expect("lock file paths must be UTF-8"); let dir_dist = DirectorySourceDist { name: self.id.name.clone(), - url: verbatim_url(&install_path, &self.id)?, + url: verbatim_url(&install_path, &self.id)?.with_given(given), install_path: install_path.into_boxed_path(), editable: Some(true), r#virtual: Some(false), @@ -2830,9 +2830,10 @@ impl Package { } Source::Virtual(path) => { let install_path = absolute_path(workspace_root, path)?; + let given = path.to_str().expect("lock file paths must be UTF-8"); let dir_dist = DirectorySourceDist { name: self.id.name.clone(), - url: verbatim_url(&install_path, &self.id)?, + url: verbatim_url(&install_path, &self.id)?.with_given(given), install_path: install_path.into_boxed_path(), editable: Some(false), r#virtual: Some(true), @@ -3655,16 +3656,22 @@ impl Source { } fn from_path_built_dist(path_dist: &PathBuiltDist, root: &Path) -> Result { - let path = relative_to(&path_dist.install_path, root) - .or_else(|_| std::path::absolute(&path_dist.install_path)) - .map_err(LockErrorKind::DistributionRelativePath)?; + let path = try_relative_to_if( + &path_dist.install_path, + root, + !path_dist.url.was_given_absolute(), + ) + .map_err(LockErrorKind::DistributionRelativePath)?; Ok(Self::Path(path.into_boxed_path())) } fn from_path_source_dist(path_dist: &PathSourceDist, root: &Path) -> Result { - let path = relative_to(&path_dist.install_path, root) - .or_else(|_| std::path::absolute(&path_dist.install_path)) - .map_err(LockErrorKind::DistributionRelativePath)?; + let path = try_relative_to_if( + &path_dist.install_path, + root, + !path_dist.url.was_given_absolute(), + ) + .map_err(LockErrorKind::DistributionRelativePath)?; Ok(Self::Path(path.into_boxed_path())) } @@ -3672,9 +3679,12 @@ impl Source { directory_dist: &DirectorySourceDist, root: &Path, ) -> Result { - let path = relative_to(&directory_dist.install_path, root) - .or_else(|_| std::path::absolute(&directory_dist.install_path)) - .map_err(LockErrorKind::DistributionRelativePath)?; + let path = try_relative_to_if( + &directory_dist.install_path, + root, + !directory_dist.url.was_given_absolute(), + ) + .map_err(LockErrorKind::DistributionRelativePath)?; if directory_dist.editable.unwrap_or(false) { Ok(Self::Editable(path.into_boxed_path())) } else if directory_dist.r#virtual.unwrap_or(false) { @@ -3696,8 +3706,7 @@ impl Source { let path = url .to_file_path() .map_err(|()| LockErrorKind::UrlToPath { url: url.to_url() })?; - let path = relative_to(&path, root) - .or_else(|_| std::path::absolute(&path)) + let path = try_relative_to_if(&path, root, !url.was_given_absolute()) .map_err(LockErrorKind::IndexRelativePath)?; let source = RegistrySource::Path(path.into_boxed_path()); Ok(Self::Registry(source)) @@ -3984,7 +3993,7 @@ impl<'de> serde::de::Deserialize<'de> for RegistrySourceWire { where E: serde::de::Error, { - if split_scheme(value).is_some() { + if split_scheme(value).is_some_and(|(scheme, _)| Scheme::parse(scheme).is_some()) { Ok( serde::Deserialize::deserialize(serde::de::value::StrDeserializer::new( value, @@ -4264,10 +4273,10 @@ impl SourceDist { let reg_dist_path = url .to_file_path() .map_err(|()| LockErrorKind::UrlToPath { url })?; - let path = relative_to(®_dist_path, index_path) - .or_else(|_| std::path::absolute(®_dist_path)) - .map_err(LockErrorKind::DistributionRelativePath)? - .into_boxed_path(); + let path = + try_relative_to_if(®_dist_path, index_path, !path.was_given_absolute()) + .map_err(LockErrorKind::DistributionRelativePath)? + .into_boxed_path(); let hash = reg_dist.file.hashes.iter().max().cloned().map(Hash::from); let size = reg_dist.file.size; let upload_time = reg_dist @@ -4612,10 +4621,10 @@ impl Wheel { let wheel_path = wheel_url .to_file_path() .map_err(|()| LockErrorKind::UrlToPath { url: wheel_url })?; - let path = relative_to(&wheel_path, index_path) - .or_else(|_| std::path::absolute(&wheel_path)) - .map_err(LockErrorKind::DistributionRelativePath)? - .into_boxed_path(); + let path = + try_relative_to_if(&wheel_path, index_path, !path.was_given_absolute()) + .map_err(LockErrorKind::DistributionRelativePath)? + .into_boxed_path(); WheelWireSource::Path { path } } else { let url = normalize_file_location(&wheel.file.url) @@ -6789,4 +6798,29 @@ source = { editable = "path/to/dir" } let result: Result = toml::from_str(data); insta::assert_debug_snapshot!(result); } + + /// Windows drive letter paths like `C:/...` should be deserialized as local path registry + /// sources, not as URLs. The `C:` prefix must not be misinterpreted as a URL scheme. + #[test] + fn registry_source_windows_drive_letter() { + let data = r#" +version = 1 +requires-python = ">=3.12" + +[[package]] +name = "tqdm" +version = "1000.0.0" +source = { registry = "C:/Users/user/links" } +wheels = [ + { path = "C:/Users/user/links/tqdm-1000.0.0-py3-none-any.whl" }, +] +"#; + let lock: Lock = toml::from_str(data).unwrap(); + assert_eq!( + lock.packages[0].id.source, + Source::Registry(RegistrySource::Path( + Path::new("C:/Users/user/links").into() + )) + ); + } } diff --git a/crates/uv-workspace/src/workspace.rs b/crates/uv-workspace/src/workspace.rs index 72c318548..c404878df 100644 --- a/crates/uv-workspace/src/workspace.rs +++ b/crates/uv-workspace/src/workspace.rs @@ -366,9 +366,7 @@ impl Workspace { /// Returns the set of all workspace members. pub fn members_requirements(&self) -> impl Iterator + '_ { self.packages.iter().filter_map(|(name, member)| { - let url = VerbatimUrl::from_absolute_path(&member.root) - .expect("path is valid URL") - .with_given(member.root.to_string_lossy()); + let url = VerbatimUrl::from_absolute_path(&member.root).expect("path is valid URL"); Some(Requirement { name: member.pyproject_toml.project.as_ref()?.name.clone(), extras: Box::new([]), @@ -476,9 +474,7 @@ impl Workspace { /// Returns the set of all workspace member dependency groups. pub fn group_requirements(&self) -> impl Iterator + '_ { self.packages.iter().filter_map(|(name, member)| { - let url = VerbatimUrl::from_absolute_path(&member.root) - .expect("path is valid URL") - .with_given(member.root.to_string_lossy()); + let url = VerbatimUrl::from_absolute_path(&member.root).expect("path is valid URL"); let groups = { let mut groups = member diff --git a/crates/uv/tests/it/edit.rs b/crates/uv/tests/it/edit.rs index 641e22f57..e7f449956 100644 --- a/crates/uv/tests/it/edit.rs +++ b/crates/uv/tests/it/edit.rs @@ -3106,6 +3106,201 @@ fn add_path_adjacent_directory() -> Result<()> { Ok(()) } +/// Check relative and absolute path handling with `uv add`. +/// +/// TODO(tk): Currently `uv add` always relativizes paths in `pyproject.toml`, +/// this is a bug. +#[test] +fn add_relative_and_absolute_paths() -> Result<()> { + let context = uv_test::test_context!("3.12"); + + let project = context.temp_dir.child("project"); + project.child("pyproject.toml").write_str(indoc! {r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = [] + "#})?; + + // Create a dependency at a relative path (sibling directory). + let relative_dep = context.temp_dir.child("relative_dep"); + relative_dep.child("pyproject.toml").write_str(indoc! {r#" + [project] + name = "relative-dep" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = [] + + [build-system] + requires = ["uv_build>=0.7,<10000"] + build-backend = "uv_build" + "#})?; + relative_dep + .child("src") + .child("relative_dep") + .child("__init__.py") + .touch()?; + + // Create a dependency at an absolute path (using the full temp_dir path). + let absolute_dep = context.temp_dir.child("absolute_dep"); + absolute_dep.child("pyproject.toml").write_str(indoc! {r#" + [project] + name = "absolute-dep" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = [] + + [build-system] + requires = ["uv_build>=0.7,<10000"] + build-backend = "uv_build" + "#})?; + absolute_dep + .child("src") + .child("absolute_dep") + .child("__init__.py") + .touch()?; + + // Create a dependency that will be added via a file:// URL. + let file_url_dep = context.temp_dir.child("file_url_dep"); + file_url_dep.child("pyproject.toml").write_str(indoc! {r#" + [project] + name = "file-url-dep" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = [] + + [build-system] + requires = ["uv_build>=0.7,<10000"] + build-backend = "uv_build" + "#})?; + file_url_dep + .child("src") + .child("file_url_dep") + .child("__init__.py") + .touch()?; + + // Add the relative dependency using a relative path. + uv_snapshot!(context.filters(), context.add().arg("../relative_dep").current_dir(project.path()), @" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Using CPython 3.12.[X] interpreter at: [PYTHON-3.12] + Creating virtual environment at: .venv + Resolved 2 packages in [TIME] + Prepared 1 package in [TIME] + Installed 1 package in [TIME] + + relative-dep==0.1.0 (from file://[TEMP_DIR]/relative_dep) + "); + + // Add the absolute dependency using an absolute path. + uv_snapshot!(context.filters(), context.add().arg(absolute_dep.path()).current_dir(project.path()), @" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 3 packages in [TIME] + Prepared 1 package in [TIME] + Installed 1 package in [TIME] + + absolute-dep==0.1.0 (from file://[TEMP_DIR]/absolute_dep) + "); + + // Add a dependency using a file:// URL (also absolute). + let file_url = Url::from_file_path(file_url_dep.path()).unwrap(); + uv_snapshot!(context.filters(), context.add().arg(file_url.as_str()).current_dir(project.path()), @" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 4 packages in [TIME] + Prepared 1 package in [TIME] + Installed 1 package in [TIME] + + file-url-dep==0.1.0 (from file://[TEMP_DIR]/file_url_dep) + "); + + // Check pyproject.toml. + let pyproject_toml = fs_err::read_to_string(project.join("pyproject.toml"))?; + + insta::with_settings!({ + filters => context.filters(), + }, { + assert_snapshot!( + pyproject_toml, @r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = [ + "absolute-dep", + "file-url-dep", + "relative-dep", + ] + + [tool.uv.sources] + relative-dep = { path = "../relative_dep" } + absolute-dep = { path = "../absolute_dep" } + file-url-dep = { path = "../file_url_dep" } + "# + ); + }); + + // Check uv.lock. + let lock = fs_err::read_to_string(project.join("uv.lock"))?; + + insta::with_settings!({ + filters => context.filters(), + }, { + assert_snapshot!( + lock, @r#" + version = 1 + revision = 3 + requires-python = ">=3.12" + + [options] + exclude-newer = "2024-03-25T00:00:00Z" + + [[package]] + name = "absolute-dep" + version = "0.1.0" + source = { directory = "../absolute_dep" } + + [[package]] + name = "file-url-dep" + version = "0.1.0" + source = { directory = "../file_url_dep" } + + [[package]] + name = "project" + version = "0.1.0" + source = { virtual = "." } + dependencies = [ + { name = "absolute-dep" }, + { name = "file-url-dep" }, + { name = "relative-dep" }, + ] + + [package.metadata] + requires-dist = [ + { name = "absolute-dep", directory = "../absolute_dep" }, + { name = "file-url-dep", directory = "../file_url_dep" }, + { name = "relative-dep", directory = "../relative_dep" }, + ] + + [[package]] + name = "relative-dep" + version = "0.1.0" + source = { directory = "../relative_dep" } + "# + ); + }); + + Ok(()) +} + /// Update a requirement, modifying the source and extras. #[test] #[cfg(feature = "test-git")] diff --git a/crates/uv/tests/it/export.rs b/crates/uv/tests/it/export.rs index 4ed1b9fe3..1f1edc540 100644 --- a/crates/uv/tests/it/export.rs +++ b/crates/uv/tests/it/export.rs @@ -4746,6 +4746,96 @@ async fn pep_751_https_credentials() -> Result<()> { Ok(()) } +/// Check that relative and absolute paths are preserved in pylock.toml export. +/// +/// See: +#[test] +fn pep_751_relative_and_absolute_paths() -> Result<()> { + let context = uv_test::test_context!("3.12"); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str(&formatdoc! {r#" + [project] + name = "a" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["b", "c"] + + [tool.uv.sources] + b = {{ path = "b" }} + c = {{ path = '{}' }} + + [build-system] + requires = ["uv_build>=0.7,<10000"] + build-backend = "uv_build" + "#, + context.temp_dir.join("c").display() + })?; + context.temp_dir.child("a/__init__.py").touch()?; + context + .temp_dir + .child("b/pyproject.toml") + .write_str(indoc! {r#" + [project] + name = "b" + version = "0.1.0" + dependencies = [] + requires-python = ">=3.12" + license = {text = "MIT"} + + [build-system] + requires = ["uv_build>=0.7,<10000"] + build-backend = "uv_build" + "#})?; + context.temp_dir.child("b/b/__init__.py").touch()?; + context + .temp_dir + .child("c/pyproject.toml") + .write_str(indoc! {r#" + [project] + name = "c" + version = "0.1.0" + dependencies = [] + requires-python = ">=3.12" + license = {text = "MIT"} + + [build-system] + requires = ["uv_build>=0.7,<10000"] + build-backend = "uv_build" + "#})?; + context.temp_dir.child("c/c/__init__.py").touch()?; + + context.lock().assert().success(); + + uv_snapshot!(context.filters(), context.export().arg("--format").arg("pylock.toml"), @r#" + success: true + exit_code: 0 + ----- stdout ----- + # This file was autogenerated by uv via the following command: + # uv export --cache-dir [CACHE_DIR] --format pylock.toml + lock-version = "1.0" + created-by = "uv" + requires-python = ">=3.12" + + [[packages]] + name = "a" + directory = { path = ".", editable = true } + + [[packages]] + name = "b" + directory = { path = "b", editable = false } + + [[packages]] + name = "c" + directory = { path = "[TEMP_DIR]/c", editable = false } + + ----- stderr ----- + Resolved 3 packages in [TIME] + "#); + + Ok(()) +} + /// Support `UV_NO_EDITABLE=1 uv export`. /// /// diff --git a/crates/uv/tests/it/lock.rs b/crates/uv/tests/it/lock.rs index a7d0b66e0..05f3a0999 100644 --- a/crates/uv/tests/it/lock.rs +++ b/crates/uv/tests/it/lock.rs @@ -7702,7 +7702,7 @@ fn lock_relative_and_absolute_paths() -> Result<()> { [package.metadata] requires-dist = [ { name = "b", directory = "b" }, - { name = "c", directory = "c" }, + { name = "c", directory = "[TEMP_DIR]/c" }, ] [[package]] @@ -7713,7 +7713,7 @@ fn lock_relative_and_absolute_paths() -> Result<()> { [[package]] name = "c" version = "0.1.0" - source = { directory = "c" } + source = { directory = "[TEMP_DIR]/c" } "# ); }); @@ -7731,6 +7731,224 @@ fn lock_relative_and_absolute_paths() -> Result<()> { Ok(()) } +/// Check relative and absolute path handling in constraint-dependencies. +/// +/// When a user provides an absolute path in `constraint-dependencies`, it should be preserved +/// as absolute in the lockfile manifest. +/// +/// See: +#[test] +fn lock_constraint_dependency_absolute_path() -> Result<()> { + let context = uv_test::test_context!("3.12"); + + // Create a local sniffio package at an absolute path. + // We use sniffio because anyio depends on it, so the constraint will + // actually be used in the resolution and its path will appear in the + // lockfile package list. + let sniffio_pkg = context.temp_dir.child("sniffio_local"); + sniffio_pkg.child("pyproject.toml").write_str(indoc! {r#" + [project] + name = "sniffio" + version = "1.3.1" + requires-python = ">=3.12" + dependencies = [] + + [build-system] + requires = ["uv_build>=0.7,<10000"] + build-backend = "uv_build" + "#})?; + sniffio_pkg + .child("src") + .child("sniffio") + .child("__init__.py") + .touch()?; + + // Create the main project with a constraint-dependency using an absolute path. + let pyproject_toml = context.temp_dir.child("project").child("pyproject.toml"); + pyproject_toml.write_str(&formatdoc! {r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["anyio==3.7.0"] + + [tool.uv] + constraint-dependencies = ["sniffio @ {}"] + "#, + sniffio_pkg.portable_display() + })?; + + uv_snapshot!(context.filters(), context.lock().current_dir(context.temp_dir.join("project")), @" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Using CPython 3.12.[X] interpreter at: [PYTHON-3.12] + Resolved 4 packages in [TIME] + "); + + // Check the lockfile - the absolute path should stay absolute, and sniffio + // should be resolved from the local path rather than PyPI. + let lock = fs_err::read_to_string(context.temp_dir.join("project/uv.lock"))?; + + insta::with_settings!({ + filters => context.filters(), + }, { + assert_snapshot!( + lock, @r#" + version = 1 + revision = 3 + requires-python = ">=3.12" + + [options] + exclude-newer = "2024-03-25T00:00:00Z" + + [manifest] + constraints = [{ name = "sniffio", directory = "[TEMP_DIR]/sniffio_local" }] + + [[package]] + name = "anyio" + version = "3.7.0" + source = { registry = "https://pypi.org/simple" } + dependencies = [ + { name = "idna" }, + { name = "sniffio" }, + ] + sdist = { url = "https://files.pythonhosted.org/packages/c6/b3/fefbf7e78ab3b805dec67d698dc18dd505af7a18a8dd08868c9b4fa736b5/anyio-3.7.0.tar.gz", hash = "sha256:275d9973793619a5374e1c89a4f4ad3f4b0a5510a2b5b939444bee8f4c4d37ce", size = 142737, upload-time = "2023-05-27T11:12:46.688Z" } + wheels = [ + { url = "https://files.pythonhosted.org/packages/68/fe/7ce1926952c8a403b35029e194555558514b365ad77d75125f521a2bec62/anyio-3.7.0-py3-none-any.whl", hash = "sha256:eddca883c4175f14df8aedce21054bfca3adb70ffe76a9f607aef9d7fa2ea7f0", size = 80873, upload-time = "2023-05-27T11:12:44.474Z" }, + ] + + [[package]] + name = "idna" + version = "3.6" + source = { registry = "https://pypi.org/simple" } + sdist = { url = "https://files.pythonhosted.org/packages/bf/3f/ea4b9117521a1e9c50344b909be7886dd00a519552724809bb1f486986c2/idna-3.6.tar.gz", hash = "sha256:9ecdbbd083b06798ae1e86adcbfe8ab1479cf864e4ee30fe4e46a003d12491ca", size = 175426, upload-time = "2023-11-25T15:40:54.902Z" } + wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/e7/a82b05cf63a603df6e68d59ae6a68bf5064484a0718ea5033660af4b54a9/idna-3.6-py3-none-any.whl", hash = "sha256:c05567e9c24a6b9faaa835c4821bad0590fbb9d5779e7caa6e1cc4978e7eb24f", size = 61567, upload-time = "2023-11-25T15:40:52.604Z" }, + ] + + [[package]] + name = "project" + version = "0.1.0" + source = { virtual = "." } + dependencies = [ + { name = "anyio" }, + ] + + [package.metadata] + requires-dist = [{ name = "anyio", specifier = "==3.7.0" }] + + [[package]] + name = "sniffio" + version = "1.3.1" + source = { directory = "[TEMP_DIR]/sniffio_local" } + "# + ); + }); + + Ok(()) +} + +/// Check that absolute index paths in config files are preserved in lockfiles. +/// +/// When an index is specified with an absolute path in a config file (pyproject.toml), +/// that absolute path should be preserved in the lockfile. +/// +/// See: +#[test] +fn lock_index_absolute_path_from_config() -> Result<()> { + let context = uv_test::test_context!("3.12"); + + // Create a local flat index with a wheel. + let index_dir = context.temp_dir.child("local_index"); + fs_err::create_dir_all(&index_dir)?; + + for entry in fs_err::read_dir(context.workspace_root.join("test/links"))? { + let entry = entry?; + let path = entry.path(); + if path + .file_name() + .and_then(|file_name| file_name.to_str()) + .is_some_and(|file_name| file_name.starts_with("tqdm-1000")) + { + let dest = index_dir.join(path.file_name().unwrap()); + fs_err::copy(&path, &dest)?; + } + } + + // Create a project directory. + let project = context.temp_dir.child("project"); + fs_err::create_dir_all(&project)?; + + // Configure the index with an ABSOLUTE path in pyproject.toml. + let pyproject_toml = project.child("pyproject.toml"); + pyproject_toml.write_str(&formatdoc! {r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["tqdm==1000.0.0"] + + [[tool.uv.index]] + name = "local" + url = "{}" + format = "flat" + "#, + index_dir.portable_display() + })?; + + uv_snapshot!(context.filters(), context.lock().current_dir(&project), @" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Using CPython 3.12.[X] interpreter at: [PYTHON-3.12] + Resolved 2 packages in [TIME] + "); + + // Check the lockfile - the absolute path should stay absolute. + let lock = fs_err::read_to_string(project.join("uv.lock"))?; + + insta::with_settings!({ + filters => context.filters(), + }, { + assert_snapshot!( + lock, @r#" + version = 1 + revision = 3 + requires-python = ">=3.12" + + [options] + exclude-newer = "2024-03-25T00:00:00Z" + + [[package]] + name = "project" + version = "0.1.0" + source = { virtual = "." } + dependencies = [ + { name = "tqdm" }, + ] + + [package.metadata] + requires-dist = [{ name = "tqdm", specifier = "==1000.0.0" }] + + [[package]] + name = "tqdm" + version = "1000.0.0" + source = { registry = "[TEMP_DIR]/local_index" } + wheels = [ + { path = "[TEMP_DIR]/local_index/tqdm-1000.0.0-py3-none-any.whl" }, + ] + "# + ); + }); + + Ok(()) +} + /// Lock a project that includes cyclic dependencies. #[test] fn lock_cycles() -> Result<()> { @@ -8407,10 +8625,10 @@ fn lock_mixed_hashes() -> Result<()> { [[package]] name = "basic-package" version = "0.1.0" - source = { registry = "simple-html" } - sdist = { path = "basic-package/basic_package-0.1.0.tar.gz", hash = "sha256:af478ff91ec60856c99a540b8df13d756513bebb65bc301fb27e0d1f974532b4" } + source = { registry = "[TEMP_DIR]/simple-html" } + sdist = { path = "[TEMP_DIR]/simple-html/basic-package/basic_package-0.1.0.tar.gz", hash = "sha256:af478ff91ec60856c99a540b8df13d756513bebb65bc301fb27e0d1f974532b4" } wheels = [ - { path = "basic-package/basic_package-0.1.0-py3-none-any.whl", hash = "sha256:7b6229db79b5800e4e98a351b5628c1c8a944533a2d428aeeaa7275a30d4ea82" }, + { path = "[TEMP_DIR]/simple-html/basic-package/basic_package-0.1.0-py3-none-any.whl", hash = "sha256:7b6229db79b5800e4e98a351b5628c1c8a944533a2d428aeeaa7275a30d4ea82" }, ] [[package]] @@ -8489,10 +8707,10 @@ fn lock_mixed_hashes() -> Result<()> { [[package]] name = "basic-package" version = "0.1.0" - source = { registry = "simple-html" } - sdist = { path = "basic-package/basic_package-0.1.0.tar.gz", hash = "sha256:af478ff91ec60856c99a540b8df13d756513bebb65bc301fb27e0d1f974532b4" } + source = { registry = "[TEMP_DIR]/simple-html" } + sdist = { path = "[TEMP_DIR]/simple-html/basic-package/basic_package-0.1.0.tar.gz", hash = "sha256:af478ff91ec60856c99a540b8df13d756513bebb65bc301fb27e0d1f974532b4" } wheels = [ - { path = "basic-package/basic_package-0.1.0-py3-none-any.whl", hash = "sha512:765bde25938af485e492e25ee0e8cde262462565122c1301213a69bf9ceb2008e3997b652a604092a238c4b1a6a334e697ff3cee3c22f9a617cb14f34e26ef17" }, + { path = "[TEMP_DIR]/simple-html/basic-package/basic_package-0.1.0-py3-none-any.whl", hash = "sha512:765bde25938af485e492e25ee0e8cde262462565122c1301213a69bf9ceb2008e3997b652a604092a238c4b1a6a334e697ff3cee3c22f9a617cb14f34e26ef17" }, ] [[package]] @@ -8973,7 +9191,7 @@ fn lock_same_version_multiple_urls() -> Result<()> { [[package]] name = "dependency" version = "0.0.1" - source = { directory = "v1" } + source = { directory = "[TEMP_DIR]/v1" } resolution-markers = [ "sys_platform == 'darwin'", ] @@ -8987,7 +9205,7 @@ fn lock_same_version_multiple_urls() -> Result<()> { [[package]] name = "dependency" version = "0.0.1" - source = { directory = "v2" } + source = { directory = "[TEMP_DIR]/v2" } resolution-markers = [ "sys_platform != 'darwin'", ] @@ -9012,14 +9230,14 @@ fn lock_same_version_multiple_urls() -> Result<()> { version = "0.1.0" source = { virtual = "." } dependencies = [ - { name = "dependency", version = "0.0.1", source = { directory = "v1" }, marker = "sys_platform == 'darwin'" }, - { name = "dependency", version = "0.0.1", source = { directory = "v2" }, marker = "sys_platform != 'darwin'" }, + { name = "dependency", version = "0.0.1", source = { directory = "[TEMP_DIR]/v1" }, marker = "sys_platform == 'darwin'" }, + { name = "dependency", version = "0.0.1", source = { directory = "[TEMP_DIR]/v2" }, marker = "sys_platform != 'darwin'" }, ] [package.metadata] requires-dist = [ - { name = "dependency", marker = "sys_platform != 'darwin'", directory = "v2" }, - { name = "dependency", marker = "sys_platform == 'darwin'", directory = "v1" }, + { name = "dependency", marker = "sys_platform != 'darwin'", directory = "[TEMP_DIR]/v2" }, + { name = "dependency", marker = "sys_platform == 'darwin'", directory = "[TEMP_DIR]/v1" }, ] [[package]] @@ -11620,9 +11838,9 @@ fn lock_find_links_local_wheel() -> Result<()> { [[package]] name = "tqdm" version = "1000.0.0" - source = { registry = "../links" } + source = { registry = "[TEMP_DIR]/links" } wheels = [ - { path = "tqdm-1000.0.0-py3-none-any.whl" }, + { path = "[TEMP_DIR]/links/tqdm-1000.0.0-py3-none-any.whl" }, ] "# ); @@ -11971,8 +12189,8 @@ fn lock_find_links_local_sdist() -> Result<()> { [[package]] name = "tqdm" version = "999.0.0" - source = { registry = "../links" } - sdist = { path = "tqdm-999.0.0.tar.gz" } + source = { registry = "[TEMP_DIR]/links" } + sdist = { path = "[TEMP_DIR]/links/tqdm-999.0.0.tar.gz" } "# ); }); @@ -12271,9 +12489,9 @@ fn lock_find_links_explicit_index() -> Result<()> { [[package]] name = "tqdm" version = "1000.0.0" - source = { registry = "../links" } + source = { registry = "[TEMP_DIR]/links" } wheels = [ - { path = "tqdm-1000.0.0-py3-none-any.whl" }, + { path = "[TEMP_DIR]/links/tqdm-1000.0.0-py3-none-any.whl" }, ] "# ); @@ -12373,9 +12591,9 @@ fn lock_find_links_higher_priority_index() -> Result<()> { [[package]] name = "tqdm" version = "1000.0.0" - source = { registry = "../links" } + source = { registry = "[TEMP_DIR]/links" } wheels = [ - { path = "tqdm-1000.0.0-py3-none-any.whl" }, + { path = "[TEMP_DIR]/links/tqdm-1000.0.0-py3-none-any.whl" }, ] "# ); @@ -12590,10 +12808,10 @@ fn lock_local_index() -> Result<()> { [[package]] name = "basic-package" version = "0.1.0" - source = { registry = "simple-html" } - sdist = { path = "basic-package/basic_package-0.1.0.tar.gz", hash = "sha256:af478ff91ec60856c99a540b8df13d756513bebb65bc301fb27e0d1f974532b4" } + source = { registry = "[TEMP_DIR]/simple-html" } + sdist = { path = "[TEMP_DIR]/simple-html/basic-package/basic_package-0.1.0.tar.gz", hash = "sha256:af478ff91ec60856c99a540b8df13d756513bebb65bc301fb27e0d1f974532b4" } wheels = [ - { path = "basic-package/basic_package-0.1.0-py3-none-any.whl", hash = "sha256:7b6229db79b5800e4e98a351b5628c1c8a944533a2d428aeeaa7275a30d4ea82" }, + { path = "[TEMP_DIR]/simple-html/basic-package/basic_package-0.1.0-py3-none-any.whl", hash = "sha256:7b6229db79b5800e4e98a351b5628c1c8a944533a2d428aeeaa7275a30d4ea82" }, ] [[package]] @@ -12845,7 +13063,7 @@ fn lock_sources_archive() -> Result<()> { ] [package.metadata] - requires-dist = [{ name = "workspace", path = "workspace.zip" }] + requires-dist = [{ name = "workspace", path = "[TEMP_DIR]/workspace.zip" }] [[package]] name = "sniffio" @@ -12859,7 +13077,7 @@ fn lock_sources_archive() -> Result<()> { [[package]] name = "workspace" version = "0.1.0" - source = { path = "workspace.zip" } + source = { path = "[TEMP_DIR]/workspace.zip" } dependencies = [ { name = "anyio" }, ] @@ -12979,12 +13197,12 @@ fn lock_sources_source_tree() -> Result<()> { ] [package.metadata] - requires-dist = [{ name = "workspace", directory = "workspace" }] + requires-dist = [{ name = "workspace", directory = "[TEMP_DIR]/workspace" }] [[package]] name = "workspace" version = "0.1.0" - source = { directory = "workspace" } + source = { directory = "[TEMP_DIR]/workspace" } dependencies = [ { name = "anyio" }, ] diff --git a/crates/uv/tests/it/sync.rs b/crates/uv/tests/it/sync.rs index fdacea663..031b5e33d 100644 --- a/crates/uv/tests/it/sync.rs +++ b/crates/uv/tests/it/sync.rs @@ -10703,11 +10703,11 @@ fn sync_build_tag() -> Result<()> { [[package]] name = "build-tag" version = "1.0.0" - source = { registry = "links" } + source = { registry = "[TEMP_DIR]/links" } wheels = [ - { path = "build_tag-1.0.0-1-py2.py3-none-any.whl" }, - { path = "build_tag-1.0.0-3-py2.py3-none-any.whl" }, - { path = "build_tag-1.0.0-5-py2.py3-none-any.whl" }, + { path = "[TEMP_DIR]/links/build_tag-1.0.0-1-py2.py3-none-any.whl" }, + { path = "[TEMP_DIR]/links/build_tag-1.0.0-3-py2.py3-none-any.whl" }, + { path = "[TEMP_DIR]/links/build_tag-1.0.0-5-py2.py3-none-any.whl" }, ] [[package]]