diff --git a/crates/distribution-types/src/buildable.rs b/crates/distribution-types/src/buildable.rs index b6e3bd076..bf681325f 100644 --- a/crates/distribution-types/src/buildable.rs +++ b/crates/distribution-types/src/buildable.rs @@ -166,7 +166,8 @@ impl<'a> From<&'a PathSourceDist> for PathSourceUrl<'a> { #[derive(Debug, Clone)] pub struct DirectorySourceUrl<'a> { pub url: &'a Url, - pub path: Cow<'a, Path>, + pub install_path: Cow<'a, Path>, + pub lock_path: Cow<'a, Path>, pub editable: bool, } @@ -180,7 +181,8 @@ impl<'a> From<&'a DirectorySourceDist> for DirectorySourceUrl<'a> { fn from(dist: &'a DirectorySourceDist) -> Self { Self { url: &dist.url, - path: Cow::Borrowed(&dist.install_path), + install_path: Cow::Borrowed(&dist.install_path), + lock_path: Cow::Borrowed(&dist.lock_path), editable: dist.editable, } } diff --git a/crates/pep508-rs/src/verbatim_url.rs b/crates/pep508-rs/src/verbatim_url.rs index abe769683..a91a0d402 100644 --- a/crates/pep508-rs/src/verbatim_url.rs +++ b/crates/pep508-rs/src/verbatim_url.rs @@ -8,7 +8,7 @@ use regex::Regex; use thiserror::Error; use url::{ParseError, Url}; -use uv_fs::{normalize_path, normalize_url_path}; +use uv_fs::{normalize_absolute_path, normalize_url_path}; use crate::Pep508Url; @@ -42,7 +42,7 @@ impl VerbatimUrl { let path = path.as_ref(); // Normalize the path. - let path = normalize_path(path) + let path = normalize_absolute_path(path) .map_err(|err| VerbatimUrlError::Normalization(path.to_path_buf(), err))?; // Extract the fragment, if it exists. @@ -83,7 +83,7 @@ impl VerbatimUrl { }; // Normalize the path. - let path = normalize_path(&path) + let path = normalize_absolute_path(&path) .map_err(|err| VerbatimUrlError::Normalization(path.clone(), err))?; // Extract the fragment, if it exists. @@ -113,7 +113,7 @@ impl VerbatimUrl { }; // Normalize the path. - let Ok(path) = normalize_path(&path) else { + let Ok(path) = normalize_absolute_path(&path) else { return Err(VerbatimUrlError::WorkingDirectory(path)); }; diff --git a/crates/uv-distribution/src/metadata/lowering.rs b/crates/uv-distribution/src/metadata/lowering.rs index 6d7a9626c..c0c741c25 100644 --- a/crates/uv-distribution/src/metadata/lowering.rs +++ b/crates/uv-distribution/src/metadata/lowering.rs @@ -201,19 +201,37 @@ pub(crate) fn lower_requirement( if matches!(requirement.version_or_url, Some(VersionOrUrl::Url(_))) { return Err(LoweringError::ConflictingUrls); } - let path = workspace + let member = workspace .packages() .get(&requirement.name) .ok_or(LoweringError::UndeclaredWorkspacePackage)? .clone(); - // The lockfile is relative to the workspace root. - let relative_to_workspace = relative_to(path.root(), workspace.install_path()) + + // Say we have: + // ``` + // root + // ├── main_workspace <- We want to the path from here ... + // │ ├── pyproject.toml + // │ └── uv.lock + // └──current_workspace + // └── packages + // └── current_package <- ... to here. + // └── pyproject.toml + // ``` + // The path we need in the lockfile: `../current_workspace/packages/current_project` + // member root: `/root/current_workspace/packages/current_project` + // workspace install root: `/root/current_workspace` + // relative to workspace: `packages/current_project` + // workspace lock root: `../current_workspace` + // relative to main workspace: `../current_workspace/packages/current_project` + let relative_to_workspace = relative_to(member.root(), workspace.install_path()) .map_err(LoweringError::RelativeTo)?; - let url = VerbatimUrl::parse_absolute_path(path.root())? - .with_given(relative_to_workspace.to_string_lossy()); + let relative_to_main_workspace = workspace.lock_path().join(relative_to_workspace); + let url = VerbatimUrl::parse_absolute_path(member.root())? + .with_given(relative_to_main_workspace.to_string_lossy()); RequirementSource::Directory { - install_path: path.root().clone(), - lock_path: relative_to_workspace, + install_path: member.root().clone(), + lock_path: relative_to_main_workspace, url, editable: editable.unwrap_or(true), } diff --git a/crates/uv-distribution/src/metadata/mod.rs b/crates/uv-distribution/src/metadata/mod.rs index 93b890dc7..e3ef11f74 100644 --- a/crates/uv-distribution/src/metadata/mod.rs +++ b/crates/uv-distribution/src/metadata/mod.rs @@ -57,7 +57,8 @@ impl Metadata { /// dependencies. pub async fn from_workspace( metadata: Metadata23, - project_root: &Path, + install_path: &Path, + lock_path: &Path, preview_mode: PreviewMode, ) -> Result { // Lower the requirements. @@ -66,13 +67,14 @@ impl Metadata { requires_dist, provides_extras, dev_dependencies, - } = RequiresDist::from_workspace( + } = RequiresDist::from_project_maybe_workspace( pypi_types::RequiresDist { name: metadata.name, requires_dist: metadata.requires_dist, provides_extras: metadata.provides_extras, }, - project_root, + install_path, + lock_path, preview_mode, ) .await?; diff --git a/crates/uv-distribution/src/metadata/requires_dist.rs b/crates/uv-distribution/src/metadata/requires_dist.rs index 2ae390449..e724e0883 100644 --- a/crates/uv-distribution/src/metadata/requires_dist.rs +++ b/crates/uv-distribution/src/metadata/requires_dist.rs @@ -42,15 +42,16 @@ impl RequiresDist { /// Lower by considering `tool.uv` in `pyproject.toml` if present, used for Git and directory /// dependencies. - pub async fn from_workspace( + pub async fn from_project_maybe_workspace( metadata: pypi_types::RequiresDist, - project_root: &Path, + install_path: &Path, + lock_path: &Path, preview_mode: PreviewMode, ) -> Result { // TODO(konsti): Limit discovery for Git checkouts to Git root. // TODO(konsti): Cache workspace discovery. let Some(project_workspace) = - ProjectWorkspace::from_maybe_project_root(project_root, None).await? + ProjectWorkspace::from_maybe_project_root(install_path, lock_path, None).await? else { return Ok(Self::from_metadata23(metadata)); }; @@ -58,7 +59,7 @@ impl RequiresDist { Self::from_project_workspace(metadata, &project_workspace, preview_mode) } - pub fn from_project_workspace( + fn from_project_workspace( metadata: pypi_types::RequiresDist, project_workspace: &ProjectWorkspace, preview_mode: PreviewMode, @@ -159,6 +160,7 @@ mod test { let pyproject_toml = PyProjectToml::from_string(contents.to_string())?; let path = Path::new("pyproject.toml"); let project_workspace = ProjectWorkspace::from_project( + path, path, pyproject_toml .project diff --git a/crates/uv-distribution/src/source/mod.rs b/crates/uv-distribution/src/source/mod.rs index db46e584b..d4cec15d0 100644 --- a/crates/uv-distribution/src/source/mod.rs +++ b/crates/uv-distribution/src/source/mod.rs @@ -423,8 +423,13 @@ impl<'a, T: BuildContext> SourceDistributionBuilder<'a, T> { /// Return the [`RequiresDist`] from a `pyproject.toml`, if it can be statically extracted. pub(crate) async fn requires_dist(&self, project_root: &Path) -> Result { let requires_dist = read_requires_dist(project_root).await?; - let requires_dist = - RequiresDist::from_workspace(requires_dist, project_root, self.preview_mode).await?; + let requires_dist = RequiresDist::from_project_maybe_workspace( + requires_dist, + project_root, + project_root, + self.preview_mode, + ) + .await?; Ok(requires_dist) } @@ -916,7 +921,7 @@ impl<'a, T: BuildContext> SourceDistributionBuilder<'a, T> { .map(|reporter| reporter.on_build_start(source)); let (disk_filename, filename, metadata) = self - .build_distribution(source, &resource.path, None, &cache_shard) + .build_distribution(source, &resource.install_path, None, &cache_shard) .await?; if let Some(task) = task { @@ -978,14 +983,19 @@ impl<'a, T: BuildContext> SourceDistributionBuilder<'a, T> { if let Some(metadata) = read_cached_metadata(&metadata_entry).await? { debug!("Using cached metadata for: {source}"); return Ok(ArchiveMetadata::from( - Metadata::from_workspace(metadata, resource.path.as_ref(), self.preview_mode) - .await?, + Metadata::from_workspace( + metadata, + resource.install_path.as_ref(), + resource.lock_path.as_ref(), + self.preview_mode, + ) + .await?, )); } // If the backend supports `prepare_metadata_for_build_wheel`, use it. if let Some(metadata) = self - .build_metadata(source, &resource.path, None) + .build_metadata(source, &resource.install_path, None) .boxed_local() .await? { @@ -998,8 +1008,13 @@ impl<'a, T: BuildContext> SourceDistributionBuilder<'a, T> { .map_err(Error::CacheWrite)?; return Ok(ArchiveMetadata::from( - Metadata::from_workspace(metadata, resource.path.as_ref(), self.preview_mode) - .await?, + Metadata::from_workspace( + metadata, + resource.install_path.as_ref(), + resource.lock_path.as_ref(), + self.preview_mode, + ) + .await?, )); } @@ -1010,7 +1025,7 @@ impl<'a, T: BuildContext> SourceDistributionBuilder<'a, T> { .map(|reporter| reporter.on_build_start(source)); let (_disk_filename, _filename, metadata) = self - .build_distribution(source, &resource.path, None, &cache_shard) + .build_distribution(source, &resource.install_path, None, &cache_shard) .await?; if let Some(task) = task { @@ -1025,7 +1040,13 @@ impl<'a, T: BuildContext> SourceDistributionBuilder<'a, T> { .map_err(Error::CacheWrite)?; Ok(ArchiveMetadata::from( - Metadata::from_workspace(metadata, resource.path.as_ref(), self.preview_mode).await?, + Metadata::from_workspace( + metadata, + resource.install_path.as_ref(), + resource.lock_path.as_ref(), + self.preview_mode, + ) + .await?, )) } @@ -1036,15 +1057,17 @@ impl<'a, T: BuildContext> SourceDistributionBuilder<'a, T> { cache_shard: &CacheShard, ) -> Result { // Verify that the source tree exists. - if !resource.path.is_dir() { + if !resource.install_path.is_dir() { return Err(Error::NotFound(resource.url.clone())); } // Determine the last-modified time of the source distribution. let Some(modified) = - ArchiveTimestamp::from_source_tree(&resource.path).map_err(Error::CacheRead)? + ArchiveTimestamp::from_source_tree(&resource.install_path).map_err(Error::CacheRead)? else { - return Err(Error::DirWithoutEntrypoint(resource.path.to_path_buf())); + return Err(Error::DirWithoutEntrypoint( + resource.install_path.to_path_buf(), + )); }; // Read the existing metadata from the cache. We treat source trees as if `--refresh` is @@ -1225,7 +1248,13 @@ impl<'a, T: BuildContext> SourceDistributionBuilder<'a, T> { if let Some(metadata) = read_cached_metadata(&metadata_entry).await? { debug!("Using cached metadata for: {source}"); return Ok(ArchiveMetadata::from( - Metadata::from_workspace(metadata, fetch.path(), self.preview_mode).await?, + Metadata::from_workspace( + metadata, + fetch.path(), + fetch.path(), + self.preview_mode, + ) + .await?, )); } } @@ -1245,7 +1274,8 @@ impl<'a, T: BuildContext> SourceDistributionBuilder<'a, T> { .map_err(Error::CacheWrite)?; return Ok(ArchiveMetadata::from( - Metadata::from_workspace(metadata, fetch.path(), self.preview_mode).await?, + Metadata::from_workspace(metadata, fetch.path(), fetch.path(), self.preview_mode) + .await?, )); } @@ -1271,7 +1301,8 @@ impl<'a, T: BuildContext> SourceDistributionBuilder<'a, T> { .map_err(Error::CacheWrite)?; Ok(ArchiveMetadata::from( - Metadata::from_workspace(metadata, fetch.path(), self.preview_mode).await?, + Metadata::from_workspace(metadata, fetch.path(), fetch.path(), self.preview_mode) + .await?, )) } diff --git a/crates/uv-distribution/src/workspace.rs b/crates/uv-distribution/src/workspace.rs index c104fafa8..b2b425642 100644 --- a/crates/uv-distribution/src/workspace.rs +++ b/crates/uv-distribution/src/workspace.rs @@ -10,7 +10,7 @@ use tracing::{debug, trace}; use pep508_rs::{RequirementOrigin, VerbatimUrl}; use pypi_types::{Requirement, RequirementSource}; -use uv_fs::{absolutize_path, Simplified}; +use uv_fs::{absolutize_path, normalize_path, relative_to, Simplified}; use uv_normalize::PackageName; use uv_warnings::warn_user; @@ -46,20 +46,29 @@ pub enum WorkspaceError { #[derive(Debug, Clone)] #[cfg_attr(test, derive(serde::Serialize))] pub struct Workspace { - /// The path to the workspace root, the directory containing the top level `pyproject.toml` with + /// The path to the workspace root. + /// + /// The workspace root is the directory containing the top level `pyproject.toml` with /// the `uv.tool.workspace`, or the `pyproject.toml` in an implicit single workspace project. install_path: PathBuf, + /// The same path as `install_path`, but relative to the main workspace. + /// + /// We use this value to compute relative paths for workspace-to-workspace dependencies. It's an + /// empty path for the main workspace. + lock_path: PathBuf, /// The members of the workspace. packages: BTreeMap, - /// The sources table from the workspace `pyproject.toml`. It is overridden by the project - /// sources. + /// The sources table from the workspace `pyproject.toml`. + /// + /// This table is overridden by the project sources. sources: BTreeMap, } impl Workspace { /// Find the workspace containing the given path. /// - /// Unlike the [`ProjectWorkspace`] discovery, this does not require a current project. + /// Unlike the [`ProjectWorkspace`] discovery, this does not require a current project. It also + /// always uses absolute path, i.e. this method only supports discovering the main workspace. /// /// Steps of workspace discovery: Start by looking at the closest `pyproject.toml`: /// * If it's an explicit workspace root: Collect workspace from this root, we're done. @@ -71,20 +80,21 @@ impl Workspace { path: &Path, stop_discovery_at: Option<&Path>, ) -> Result { - let project_root = path + let path = absolutize_path(path) + .map_err(WorkspaceError::Normalize)? + .to_path_buf(); + + let project_path = path .ancestors() .find(|path| path.join("pyproject.toml").is_file()) - .ok_or(WorkspaceError::MissingPyprojectToml)?; + .ok_or(WorkspaceError::MissingPyprojectToml)? + .to_path_buf(); - let pyproject_path = project_root.join("pyproject.toml"); + let pyproject_path = project_path.join("pyproject.toml"); let contents = fs_err::tokio::read_to_string(&pyproject_path).await?; let pyproject_toml = PyProjectToml::from_string(contents) .map_err(|err| WorkspaceError::Toml(pyproject_path.clone(), Box::new(err)))?; - let project_path = absolutize_path(project_root) - .map_err(WorkspaceError::Normalize)? - .to_path_buf(); - // Check if the project is explicitly marked as unmanaged. if pyproject_toml .tool @@ -150,6 +160,8 @@ impl Workspace { pyproject_toml, }); Self::collect_members( + workspace_root.clone(), + // This method supports only absolute paths. workspace_root, workspace_definition, workspace_pyproject_toml, @@ -248,6 +260,12 @@ impl Workspace { &self.install_path } + /// The same path as `install_path()`, but relative to the main workspace. We use this value + /// to compute relative paths for workspace-to-workspace dependencies. + pub fn lock_path(&self) -> &PathBuf { + &self.lock_path + } + /// The path to the workspace virtual environment. pub fn venv(&self) -> PathBuf { self.install_path.join(".venv") @@ -266,6 +284,7 @@ impl Workspace { /// Collect the workspace member projects from the `members` and `excludes` entries. async fn collect_members( workspace_root: PathBuf, + lock_path: PathBuf, workspace_definition: ToolUvWorkspace, workspace_pyproject_toml: PyProjectToml, current_project: Option, @@ -387,6 +406,7 @@ impl Workspace { Ok(Workspace { install_path: workspace_root, + lock_path, packages: workspace_members, sources: workspace_sources, }) @@ -541,7 +561,7 @@ impl ProjectWorkspace { } /// Discover the workspace starting from the directory containing the `pyproject.toml`. - pub async fn from_project_root( + async fn from_project_root( project_root: &Path, stop_discovery_at: Option<&Path>, ) -> Result { @@ -557,17 +577,25 @@ impl ProjectWorkspace { .clone() .ok_or_else(|| WorkspaceError::MissingProject(pyproject_path.clone()))?; - Self::from_project(project_root, &project, &pyproject_toml, stop_discovery_at).await + Self::from_project( + project_root, + Path::new(""), + &project, + &pyproject_toml, + stop_discovery_at, + ) + .await } /// If the current directory contains a `pyproject.toml` with a `project` table, discover the /// workspace and return it, otherwise it is a dynamic path dependency and we return `Ok(None)`. pub async fn from_maybe_project_root( - project_root: &Path, + install_path: &Path, + lock_path: &Path, stop_discovery_at: Option<&Path>, ) -> Result, WorkspaceError> { // Read the `pyproject.toml`. - let pyproject_path = project_root.join("pyproject.toml"); + let pyproject_path = install_path.join("pyproject.toml"); let Ok(contents) = fs_err::tokio::read_to_string(&pyproject_path).await else { // No `pyproject.toml`, but there may still be a `setup.py` or `setup.cfg`. return Ok(None); @@ -582,7 +610,14 @@ impl ProjectWorkspace { }; Ok(Some( - Self::from_project(project_root, &project, &pyproject_toml, stop_discovery_at).await?, + Self::from_project( + install_path, + lock_path, + &project, + &pyproject_toml, + stop_discovery_at, + ) + .await?, )) } @@ -609,12 +644,13 @@ impl ProjectWorkspace { /// Find the workspace for a project. pub async fn from_project( - project_path: &Path, + install_path: &Path, + lock_path: &Path, project: &Project, project_pyproject_toml: &PyProjectToml, stop_discovery_at: Option<&Path>, ) -> Result { - let project_path = absolutize_path(project_path) + let project_path = absolutize_path(install_path) .map_err(WorkspaceError::Normalize)? .to_path_buf(); @@ -669,6 +705,8 @@ impl ProjectWorkspace { project_name: project.name.clone(), workspace: Workspace { install_path: project_path.clone(), + // The workspace and the project are the same, so the relative path is, too. + lock_path: lock_path.to_path_buf(), packages: current_project_as_members, // There may be package sources, but we don't need to duplicate them into the // workspace sources. @@ -682,8 +720,28 @@ impl ProjectWorkspace { workspace_root.simplified_display() ); + // Say we have: + // ``` + // root + // ├── main_workspace <- The reference point + // │ ├── pyproject.toml + // │ └── uv.lock + // └──current_workspace <- We want this relative to the main workspace + // └── packages + // └── current_package <- We have this relative to the main workspace + // └── pyproject.toml + // ``` + // The lock path we need: `../current_workspace` + // workspace root: `/root/current_workspace` + // project path: `/root/current_workspace/packages/current_project` + // relative to workspace: `../..` + // lock path: `../current_workspace` + let up_to_root = relative_to(&workspace_root, &project_path)?; + let lock_path = normalize_path(&lock_path.join(up_to_root)); + let workspace = Workspace::collect_members( workspace_root, + lock_path, workspace_definition, workspace_pyproject_toml, Some(current_project), @@ -901,10 +959,17 @@ impl VirtualProject { /// /// Similar to calling [`ProjectWorkspace::discover`] with a fallback to [`Workspace::discover`], /// but avoids rereading the `pyproject.toml` (and relying on error-handling as control flow). + /// + /// This method requires an absolute path and panics otherwise, i.e. this method only supports + /// discovering the main workspace. pub async fn discover( path: &Path, stop_discovery_at: Option<&Path>, ) -> Result { + assert!( + path.is_absolute(), + "virtual project discovery with relative path" + ); let project_root = path .ancestors() .take_while(|path| { @@ -931,6 +996,7 @@ impl VirtualProject { // If the `pyproject.toml` contains a `[project]` table, it's a project. let project = ProjectWorkspace::from_project( project_root, + Path::new(""), project, &pyproject_toml, stop_discovery_at, @@ -950,6 +1016,7 @@ impl VirtualProject { let workspace = Workspace::collect_members( project_path, + PathBuf::new(), workspace.clone(), pyproject_toml, None, @@ -1032,6 +1099,7 @@ mod tests { "project_name": "bird-feeder", "workspace": { "install_path": "[ROOT]/albatross-in-example/examples/bird-feeder", + "lock_path": "", "packages": { "bird-feeder": { "root": "[ROOT]/albatross-in-example/examples/bird-feeder", @@ -1067,6 +1135,7 @@ mod tests { "project_name": "bird-feeder", "workspace": { "install_path": "[ROOT]/albatross-project-in-excluded/excluded/bird-feeder", + "lock_path": "", "packages": { "bird-feeder": { "root": "[ROOT]/albatross-project-in-excluded/excluded/bird-feeder", @@ -1101,6 +1170,7 @@ mod tests { "project_name": "albatross", "workspace": { "install_path": "[ROOT]/albatross-root-workspace", + "lock_path": "", "packages": { "albatross": { "root": "[ROOT]/albatross-root-workspace", @@ -1159,6 +1229,7 @@ mod tests { "project_name": "albatross", "workspace": { "install_path": "[ROOT]/albatross-virtual-workspace", + "lock_path": "../..", "packages": { "albatross": { "root": "[ROOT]/albatross-virtual-workspace/packages/albatross", @@ -1211,6 +1282,7 @@ mod tests { "project_name": "albatross", "workspace": { "install_path": "[ROOT]/albatross-just-project", + "lock_path": "", "packages": { "albatross": { "root": "[ROOT]/albatross-just-project", diff --git a/crates/uv-fs/src/path.rs b/crates/uv-fs/src/path.rs index 2a48259f6..fe683cd91 100644 --- a/crates/uv-fs/src/path.rs +++ b/crates/uv-fs/src/path.rs @@ -160,7 +160,7 @@ pub fn normalize_url_path(path: &str) -> Cow<'_, str> { /// /// When a relative path is provided with `..` components that extend beyond the base directory. /// For example, `./a/../../b` cannot be normalized because it escapes the base directory. -pub fn normalize_path(path: &Path) -> Result { +pub fn normalize_absolute_path(path: &Path) -> Result { let mut components = path.components().peekable(); let mut ret = if let Some(c @ Component::Prefix(..)) = components.peek().copied() { components.next(); @@ -180,7 +180,10 @@ pub fn normalize_path(path: &Path) -> Result { if !ret.pop() { return Err(std::io::Error::new( std::io::ErrorKind::InvalidInput, - "cannot normalize a relative path beyond the base directory", + format!( + "cannot normalize a relative path beyond the base directory: {}", + path.display() + ), )); } } @@ -192,6 +195,52 @@ pub fn normalize_path(path: &Path) -> Result { Ok(ret) } +/// Normalize a path, removing things like `.` and `..`. +/// +/// Unlike [`normalize_absolute_path`], this works with relative paths and does never error. +/// +/// Note that we can theoretically go beyond the root dir here (e.g. `/usr/../../foo` becomes +/// `/../foo`), but that's not a (correctness) problem, we will fail later with a file not found +/// error with a path computed from the user's input. +/// +/// # Examples +/// +/// In: `../../workspace-git-path-dep-test/packages/c/../../packages/d` +/// Out: `../../workspace-git-path-dep-test/packages/d` +/// +/// In: `workspace-git-path-dep-test/packages/c/../../packages/d` +/// Out: `workspace-git-path-dep-test/packages/d` +/// +/// In: `./a/../../b` +/// Out: `../b` +pub fn normalize_path(path: &Path) -> PathBuf { + let mut normalized = PathBuf::new(); + for component in path.components() { + match component { + Component::Prefix(_) | Component::RootDir | Component::Normal(_) => { + // Preserve filesystem roots and regular path components. + normalized.push(component); + } + Component::ParentDir => { + match normalized.components().last() { + None | Some(Component::ParentDir | Component::RootDir) => { + // Preserve leading and above-root `..` + normalized.push(component); + } + Some(Component::Normal(_) | Component::Prefix(_) | Component::CurDir) => { + // Remove inner `..` + normalized.pop(); + } + } + } + Component::CurDir => { + // Remove `.` + } + } + } + normalized +} + /// Convert a path to an absolute path, relative to the current working directory. /// /// Unlike [`std::fs::canonicalize`], this function does not resolve symlinks and does not require @@ -402,16 +451,16 @@ mod tests { #[test] fn test_normalize_path() { let path = Path::new("/a/b/../c/./d"); - let normalized = normalize_path(path).unwrap(); + let normalized = normalize_absolute_path(path).unwrap(); assert_eq!(normalized, Path::new("/a/c/d")); let path = Path::new("/a/../c/./d"); - let normalized = normalize_path(path).unwrap(); + let normalized = normalize_absolute_path(path).unwrap(); assert_eq!(normalized, Path::new("/c/d")); // This should be an error. let path = Path::new("/a/../../c/./d"); - let err = normalize_path(path).unwrap_err(); + let err = normalize_absolute_path(path).unwrap_err(); assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput); } @@ -442,4 +491,23 @@ mod tests { Path::new("../../../bin/foo_launcher") ); } + + #[test] + fn test_normalize_relative() { + let cases = [ + ( + "../../workspace-git-path-dep-test/packages/c/../../packages/d", + "../../workspace-git-path-dep-test/packages/d", + ), + ( + "workspace-git-path-dep-test/packages/c/../../packages/d", + "workspace-git-path-dep-test/packages/d", + ), + ("./a/../../b", "../b"), + ("/usr/../../foo", "/../foo"), + ]; + for (input, expected) in cases { + assert_eq!(normalize_path(Path::new(input)), Path::new(expected)); + } + } } diff --git a/crates/uv-requirements/src/source_tree.rs b/crates/uv-requirements/src/source_tree.rs index ab3f53d00..02df83dc2 100644 --- a/crates/uv-requirements/src/source_tree.rs +++ b/crates/uv-requirements/src/source_tree.rs @@ -172,7 +172,8 @@ impl<'a, Context: BuildContext> SourceTreeResolver<'a, Context> { }; let source = SourceUrl::Directory(DirectorySourceUrl { url: &url, - path: Cow::Borrowed(source_tree), + install_path: Cow::Borrowed(source_tree), + lock_path: Cow::Borrowed(source_tree), editable: false, }); diff --git a/crates/uv-requirements/src/unnamed.rs b/crates/uv-requirements/src/unnamed.rs index da06d131d..8690899b2 100644 --- a/crates/uv-requirements/src/unnamed.rs +++ b/crates/uv-requirements/src/unnamed.rs @@ -255,7 +255,8 @@ impl<'a, Context: BuildContext> NamedRequirementsResolver<'a, Context> { SourceUrl::Directory(DirectorySourceUrl { url: &requirement.url.verbatim, - path: Cow::Borrowed(&parsed_directory_url.install_path), + install_path: Cow::Borrowed(&parsed_directory_url.install_path), + lock_path: Cow::Borrowed(&parsed_directory_url.lock_path), editable: parsed_directory_url.editable, }) } diff --git a/crates/uv/tests/branching_urls.rs b/crates/uv/tests/branching_urls.rs index d11beede5..969935890 100644 --- a/crates/uv/tests/branching_urls.rs +++ b/crates/uv/tests/branching_urls.rs @@ -1,36 +1,13 @@ use std::env; -use std::path::Path; use anyhow::Result; -use indoc::{formatdoc, indoc}; +use indoc::indoc; use insta::assert_snapshot; -use crate::common::{uv_snapshot, TestContext}; +use crate::common::{make_project, uv_snapshot, TestContext}; mod common; -/// Create a stub package `name` in `dir` with the given `pyproject.toml` body. -fn make_project(dir: &Path, name: &str, body: &str) -> Result<()> { - let pyproject_toml = formatdoc! {r#" - [project] - name = "{name}" - version = "0.1.0" - description = "Test package for direct URLs in branches" - requires-python = ">=3.11,<3.13" - {body} - - [build-system] - requires = ["flit_core>=3.8,<4"] - build-backend = "flit_core.buildapi" - "# - }; - fs_err::create_dir_all(dir)?; - fs_err::write(dir.join("pyproject.toml"), pyproject_toml)?; - fs_err::create_dir(dir.join(name))?; - fs_err::write(dir.join(name).join("__init__.py"), "")?; - Ok(()) -} - /// The root package has diverging URLs for disjoint markers: /// ```toml /// dependencies = [ diff --git a/crates/uv/tests/common/mod.rs b/crates/uv/tests/common/mod.rs index b41d0ae74..d3056a894 100644 --- a/crates/uv/tests/common/mod.rs +++ b/crates/uv/tests/common/mod.rs @@ -12,6 +12,7 @@ use std::str::FromStr; use assert_cmd::assert::{Assert, OutputAssertExt}; use assert_fs::assert::PathAssert; use assert_fs::fixture::{ChildPath, PathChild, PathCreateDir, SymlinkToFile}; +use indoc::formatdoc; use predicates::prelude::predicate; use regex::Regex; @@ -929,6 +930,28 @@ pub fn copy_dir_ignore(src: impl AsRef, dst: impl AsRef) -> anyhow:: Ok(()) } +/// Create a stub package `name` in `dir` with the given `pyproject.toml` body. +pub fn make_project(dir: &Path, name: &str, body: &str) -> anyhow::Result<()> { + let pyproject_toml = formatdoc! {r#" + [project] + name = "{name}" + version = "0.1.0" + description = "Test package for direct URLs in branches" + requires-python = ">=3.11,<3.13" + {body} + + [build-system] + requires = ["flit_core>=3.8,<4"] + build-backend = "flit_core.buildapi" + "# + }; + fs_err::create_dir_all(dir)?; + fs_err::write(dir.join("pyproject.toml"), pyproject_toml)?; + fs_err::create_dir(dir.join(name))?; + fs_err::write(dir.join(name).join("__init__.py"), "")?; + Ok(()) +} + /// Utility macro to return the name of the current function. /// /// https://stackoverflow.com/a/40234666/3549270 diff --git a/crates/uv/tests/workspace.rs b/crates/uv/tests/workspace.rs index cf8ed1d2a..0a243a8e6 100644 --- a/crates/uv/tests/workspace.rs +++ b/crates/uv/tests/workspace.rs @@ -1,11 +1,16 @@ +use std::collections::BTreeMap; use std::env; use std::path::{Path, PathBuf}; use std::process::Command; use anyhow::Result; use assert_cmd::assert::OutputAssertExt; +use assert_fs::fixture::{FileWriteStr, PathChild}; +use indoc::indoc; +use insta::assert_json_snapshot; +use serde::{Deserialize, Serialize}; -use crate::common::{copy_dir_ignore, uv_snapshot, TestContext}; +use crate::common::{copy_dir_ignore, make_project, uv_snapshot, TestContext}; mod common; @@ -565,3 +570,123 @@ fn workspace_lock_idempotence_virtual_workspace() -> Result<()> { )?; Ok(()) } + +/// Extract just the sources from the lock file, to test path resolution. +#[derive(Deserialize, Serialize)] +struct SourceLock { + distribution: Vec, +} + +impl SourceLock { + fn sources(self) -> BTreeMap { + self.distribution + .into_iter() + .map(|distribution| (distribution.name, distribution.source)) + .collect() + } +} + +#[derive(Deserialize, Serialize)] +struct Distribution { + name: String, + source: toml::Value, +} + +/// Test path dependencies from one workspace into another. +/// +/// We have a main workspace with packages `a` and `b`, and a second workspace with `c`, `d` and +/// `e`. We have `a -> b`, `b -> c`, `c -> d`. `e` should not be installed. +#[test] +fn workspace_to_workspace_paths_dependencies() -> Result<()> { + let context = TestContext::new("3.12"); + + // Build the main workspace ... + let main_workspace = context.temp_dir.child("main-workspace"); + main_workspace + .child("pyproject.toml") + .write_str(indoc! {r#" + [tool.uv.workspace] + members = ["packages/*"] + "#})?; + + // ... with a ... + let deps = indoc! {r#" + dependencies = ["b"] + + [tool.uv.sources] + b = { workspace = true } + "#}; + make_project(&main_workspace.join("packages").join("a"), "a", deps)?; + + // ... and b. + let deps = indoc! {r#" + dependencies = ["c"] + + [tool.uv.sources] + c = { path = "../../../other-workspace/packages/c", editable = true } + "#}; + make_project(&main_workspace.join("packages").join("b"), "b", deps)?; + + // Build the second workspace ... + let other_workspace = context.temp_dir.child("other-workspace"); + other_workspace + .child("pyproject.toml") + .write_str(indoc! {r#" + [tool.uv.workspace] + members = ["packages/*"] + "#})?; + + // ... with c ... + let deps = indoc! {r#" + dependencies = ["d"] + + [tool.uv.sources] + d = { workspace = true } + "#}; + make_project(&other_workspace.join("packages").join("c"), "c", deps)?; + + // ... and d ... + let deps = indoc! {r" + dependencies = [] + "}; + make_project(&other_workspace.join("packages").join("d"), "d", deps)?; + + // ... and e. + let deps = indoc! {r#" + dependencies = ["numpy>=2.0.0,<3"] + "#}; + make_project(&other_workspace.join("packages").join("e"), "e", deps)?; + + uv_snapshot!(context.filters(), context.lock().arg("--preview").current_dir(&main_workspace), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Using Python 3.12.[X] interpreter at: [PYTHON-3.12] + Resolved 4 packages in [TIME] + "### + ); + + let lock: SourceLock = + toml::from_str(&fs_err::read_to_string(main_workspace.join("uv.lock"))?)?; + + assert_json_snapshot!(lock.sources(), @r###" + { + "a": { + "editable": "packages/a" + }, + "b": { + "editable": "packages/b" + }, + "c": { + "editable": "../other-workspace/packages/c" + }, + "d": { + "editable": "../other-workspace/packages/d" + } + } + "###); + + Ok(()) +}