From eec8048a0b1c88cb69227cd9f77ce28c2fc61c88 Mon Sep 17 00:00:00 2001 From: Tomasz Kramkowski Date: Fri, 13 Mar 2026 17:42:03 +0000 Subject: [PATCH] Preserve absolute/relative paths in lockfiles (#18176) ## Summary Attempt to track and preserve relative/absolute paths when read from files. File URLs are treated as absolute. Synthetic VerbatimUrls shouldn't have a `given`, and are treated as relative. This means that paths passed as absolute will be output as absolute, although they may get normalized. Paths passed as relative will be output as relative but they may be relative to a different location (so that they continue to work going forwards). Previously in various places we'd either make things absolute unconditionally or relative unconditionally. Cases which should now be fixed: - uv.lock - Path dependencies, indexes, and find-links were always converted to relative paths. - pylock.toml export (from_resolution path) - Paths were always relativized. Now preserves the user's original format. - pylock.toml export (from_lock path) - Relative paths from the lock file were being converted to absolute paths. Now uses the path exactly as stored in the lock file. Also noteworthy is the bugfix for a windows misbehaviour. See the commit message for some more information. Note: For now the `uv add` side of this has been split off as a breaking change. ## Test Plan Added missing tests, updated existing. I believe all the changed tests are all now correct and were previously demonstrating buggy behaviour. Well, at least if you are on board with the idea that we should keep relative paths relative and absolute paths and / file URLs absolute. ## Related Issues/PRs * Closes https://github.com/astral-sh/uv/issues/15055 * Closes https://github.com/astral-sh/uv/issues/16602 * Closes https://github.com/astral-sh/uv/issues/16514 * Closes https://github.com/astral-sh/uv/pull/15870 --- .../uv-distribution-types/src/requirement.rs | 8 +- crates/uv-fs/src/path.rs | 14 + crates/uv-pep508/src/verbatim_url.rs | 15 + .../src/lock/export/pylock_toml.rs | 54 ++-- crates/uv-resolver/src/lock/mod.rs | 102 ++++--- crates/uv-workspace/src/workspace.rs | 8 +- crates/uv/tests/it/edit.rs | 195 +++++++++++++ crates/uv/tests/it/export.rs | 90 ++++++ crates/uv/tests/it/lock.rs | 276 ++++++++++++++++-- crates/uv/tests/it/sync.rs | 8 +- 10 files changed, 672 insertions(+), 98 deletions(-) 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]]