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
This commit is contained in:
Tomasz Kramkowski
2026-03-13 17:42:03 +00:00
committed by GitHub
parent 9fa25fb25a
commit eec8048a0b
10 changed files with 672 additions and 98 deletions
@@ -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,
+14
View File
@@ -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<Path>,
base: impl AsRef<Path>,
should_relativize: bool,
) -> Result<PathBuf, std::io::Error> {
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.
///
+15
View File
@@ -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
@@ -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::<Path>::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::<Path>::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::<Path>::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::<Path>::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::<Path>::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::<Path>::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,
+68 -34
View File
@@ -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<Self, LockError> {
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<Self, LockError> {
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<Self, LockError> {
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(&reg_dist_path, index_path)
.or_else(|_| std::path::absolute(&reg_dist_path))
.map_err(LockErrorKind::DistributionRelativePath)?
.into_boxed_path();
let path =
try_relative_to_if(&reg_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<Lock, _> = 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()
))
);
}
}
+2 -6
View File
@@ -366,9 +366,7 @@ impl Workspace {
/// Returns the set of all workspace members.
pub fn members_requirements(&self) -> impl Iterator<Item = Requirement> + '_ {
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<Item = Requirement> + '_ {
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
+195
View File
@@ -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")]
+90
View File
@@ -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: <https://github.com/astral-sh/uv/issues/16514>
#[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`.
///
/// <https://github.com/astral-sh/uv/issues/15103>
+247 -29
View File
@@ -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: <https://github.com/astral-sh/uv/issues/17307>
#[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: <https://github.com/astral-sh/uv/issues/17307>
#[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" },
]
+4 -4
View File
@@ -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]]