From 7c2b1f17b67c835f4226a302ac888c0b9c9c3ae3 Mon Sep 17 00:00:00 2001 From: konstin Date: Thu, 5 Mar 2026 11:51:41 +0100 Subject: [PATCH 01/20] Remove `ProjectDiscovery` This doesn't actually change workspace discovery (anymore), so we can error after discovery. --- .../src/metadata/dependency_groups.rs | 1 - .../src/metadata/requires_dist.rs | 1 - crates/uv-workspace/src/lib.rs | 5 +-- crates/uv-workspace/src/workspace.rs | 45 +++++-------------- crates/uv/src/commands/project/version.rs | 31 ++++++------- 5 files changed, 28 insertions(+), 55 deletions(-) diff --git a/crates/uv-distribution/src/metadata/dependency_groups.rs b/crates/uv-distribution/src/metadata/dependency_groups.rs index d6c4b0a13b433..081f6885a61e0 100644 --- a/crates/uv-distribution/src/metadata/dependency_groups.rs +++ b/crates/uv-distribution/src/metadata/dependency_groups.rs @@ -80,7 +80,6 @@ impl SourcedDependencyGroups { } else { MemberDiscovery::None }, - ..DiscoveryOptions::default() }; // The subsequent API takes an absolute path to the dir the pyproject is in diff --git a/crates/uv-distribution/src/metadata/requires_dist.rs b/crates/uv-distribution/src/metadata/requires_dist.rs index 52ad681ffd205..0566d48148c3f 100644 --- a/crates/uv-distribution/src/metadata/requires_dist.rs +++ b/crates/uv-distribution/src/metadata/requires_dist.rs @@ -51,7 +51,6 @@ impl RequiresDist { } else { MemberDiscovery::None }, - ..DiscoveryOptions::default() }; let Some(project_workspace) = ProjectWorkspace::from_maybe_project_root(install_path, &discovery, cache).await? diff --git a/crates/uv-workspace/src/lib.rs b/crates/uv-workspace/src/lib.rs index 5a30721aa6312..c15a8b0075814 100644 --- a/crates/uv-workspace/src/lib.rs +++ b/crates/uv-workspace/src/lib.rs @@ -1,7 +1,6 @@ pub use workspace::{ - DiscoveryOptions, Editability, MemberDiscovery, ProjectDiscovery, ProjectWorkspace, - RequiresPythonSources, VirtualProject, Workspace, WorkspaceCache, WorkspaceError, - WorkspaceMember, + DiscoveryOptions, Editability, MemberDiscovery, ProjectWorkspace, RequiresPythonSources, + VirtualProject, Workspace, WorkspaceCache, WorkspaceError, WorkspaceMember, }; pub mod dependency_groups; diff --git a/crates/uv-workspace/src/workspace.rs b/crates/uv-workspace/src/workspace.rs index 425bb8a25bd24..eb92ccead3518 100644 --- a/crates/uv-workspace/src/workspace.rs +++ b/crates/uv-workspace/src/workspace.rs @@ -100,39 +100,12 @@ pub enum MemberDiscovery { Ignore(BTreeSet), } -/// Whether a "project" must be defined via a `[project]` table. -#[derive(Debug, Default, Clone, Hash, PartialEq, Eq)] -pub enum ProjectDiscovery { - /// The `[project]` table is optional; when missing, the target is treated as a virtual - /// project with only dependency groups. - #[default] - Optional, - /// A `[project]` table must be defined. - /// - /// If not defined, discovery will fail. - Required, -} - -impl ProjectDiscovery { - /// Whether a `[project]` table is required. - fn allows_implicit_workspace(&self) -> bool { - matches!(self, Self::Optional) - } - - /// Whether a non-project workspace root is allowed. - fn allows_non_project_workspace(&self) -> bool { - matches!(self, Self::Optional) - } -} - #[derive(Debug, Default, Clone, Hash, PartialEq, Eq)] pub struct DiscoveryOptions { /// The path to stop discovery at. pub stop_discovery_at: Option, /// The strategy to use when discovering workspace members. pub members: MemberDiscovery, - /// The strategy to use when discovering the project. - pub project: ProjectDiscovery, } pub type RequiresPythonSources = BTreeMap<(PackageName, Option), VersionSpecifiers>; @@ -1760,7 +1733,6 @@ impl VirtualProject { .as_ref() .and_then(|tool| tool.uv.as_ref()) .and_then(|uv| uv.workspace.as_ref()) - .filter(|_| options.project.allows_non_project_workspace()) { // Otherwise, if it contains a `tool.uv.workspace` table, it's a non-project workspace // root. @@ -1779,7 +1751,7 @@ impl VirtualProject { .await?; Ok(Self::NonProject(workspace)) - } else if options.project.allows_implicit_workspace() { + } else { // Otherwise it's a pyproject.toml that maybe contains dependency-groups // that we want to treat like a project/workspace to handle those uniformly let project_path = std::path::absolute(project_root) @@ -1797,8 +1769,6 @@ impl VirtualProject { .await?; Ok(Self::NonProject(workspace)) - } else { - Err(WorkspaceError::MissingProject(pyproject_path)) } } @@ -1810,10 +1780,15 @@ impl VirtualProject { package: PackageName, ) -> Result { let workspace = Workspace::discover(path, options, cache).await?; - let project_workspace = Workspace::with_current_project(workspace.clone(), package.clone()); - Ok(Self::Project(project_workspace.ok_or_else(|| { - WorkspaceError::NoSuchMember(package.clone(), workspace.install_path) - })?)) + let Some(project_workspace) = + Workspace::with_current_project(workspace.clone(), package.clone()) + else { + return Err(WorkspaceError::NoSuchMember( + package, + workspace.install_path.clone(), + )); + }; + Ok(Self::Project(project_workspace)) } /// Update the `pyproject.toml` for the current project. diff --git a/crates/uv/src/commands/project/version.rs b/crates/uv/src/commands/project/version.rs index 5bca3d64c0b50..1e1d2f1fd4882 100644 --- a/crates/uv/src/commands/project/version.rs +++ b/crates/uv/src/commands/project/version.rs @@ -2,7 +2,7 @@ use std::fmt::Write; use std::path::Path; use std::str::FromStr; -use anyhow::{Result, anyhow}; +use anyhow::{Result, anyhow, bail}; use owo_colors::OwoColorize; use thiserror::Error; @@ -413,26 +413,27 @@ async fn find_target( let project = if let Some(package) = package { VirtualProject::discover_with_package( project_dir, - &DiscoveryOptions { - project: uv_workspace::ProjectDiscovery::Required, - ..DiscoveryOptions::default() - }, + &DiscoveryOptions::default(), workspace_cache, package.clone(), ) .await .map_err(|err| hint_uv_self_version(err, explicit_project))? } else { - VirtualProject::discover( - project_dir, - &DiscoveryOptions { - project: uv_workspace::ProjectDiscovery::Required, - ..DiscoveryOptions::default() - }, - workspace_cache, - ) - .await - .map_err(|err| hint_uv_self_version(err, explicit_project))? + let project = + VirtualProject::discover(project_dir, &DiscoveryOptions::default(), workspace_cache) + .await + .map_err(|err| hint_uv_self_version(err, explicit_project))?; + match &project { + VirtualProject::Project(_) => project, + VirtualProject::NonProject(workspace) => bail!( + "No `project` table found in: {}", + workspace + .install_path() + .join("pyproject.toml") + .simplified_display() + ), + } }; Ok(project) } From 81f38c842e557e71896e488b751a6d6aefcd8a9c Mon Sep 17 00:00:00 2001 From: konstin Date: Wed, 4 Mar 2026 15:47:22 +0100 Subject: [PATCH 02/20] Hold `Workspace` in `Arc` This will allow caching. --- crates/uv-workspace/src/workspace.rs | 90 ++++++++++++++---------- crates/uv/src/commands/build_frontend.rs | 2 +- crates/uv/src/commands/project/init.rs | 4 +- 3 files changed, 56 insertions(+), 40 deletions(-) diff --git a/crates/uv-workspace/src/workspace.rs b/crates/uv-workspace/src/workspace.rs index eb92ccead3518..773d2bc91b395 100644 --- a/crates/uv-workspace/src/workspace.rs +++ b/crates/uv-workspace/src/workspace.rs @@ -164,7 +164,7 @@ impl Workspace { path: &Path, options: &DiscoveryOptions, cache: &WorkspaceCache, - ) -> Result { + ) -> Result, WorkspaceError> { let path = std::path::absolute(path) .map_err(WorkspaceError::Normalize)? .clone(); @@ -259,12 +259,15 @@ impl Workspace { /// Set the current project to the given workspace member. /// /// Returns `None` if the package is not part of the workspace. - fn with_current_project(self, package_name: PackageName) -> Option { - let member = self.packages.get(&package_name)?; + fn with_current_project( + slf: Arc, + package_name: PackageName, + ) -> Option { + let member = slf.packages.get(&package_name)?; Some(ProjectWorkspace { project_root: member.root().clone(), project_name: package_name, - workspace: self, + workspace: slf, }) } @@ -272,17 +275,27 @@ impl Workspace { /// /// Assumes that the project name is unchanged in the updated [`PyProjectToml`]. fn update_member( - self, + slf: Arc, package_name: &PackageName, pyproject_toml: PyProjectToml, - ) -> Result, WorkspaceError> { - let mut packages = self.packages; + ) -> Result>, WorkspaceError> { + let slf = Arc::try_unwrap(slf).unwrap_or_else(|slf| { + if cfg!(debug_assertions) { + panic!( + "Cannot modify workspace still in use with {} references", + Arc::strong_count(&slf) + ); + } else { + (*slf).clone() + } + }); + let mut packages = slf.packages; let Some(member) = Arc::make_mut(&mut packages).get_mut(package_name) else { return Ok(None); }; - if member.root == self.install_path { + if member.root == slf.install_path { // If the member is also the workspace root, update _both_ the member entry and the // root `pyproject.toml`. let workspace_pyproject_toml = pyproject_toml.clone(); @@ -306,26 +319,28 @@ impl Workspace { &workspace_pyproject_toml, )?; - Ok(Some(Self { + let workspace = Self { pyproject_toml: workspace_pyproject_toml, sources: workspace_sources, packages, required_members, - ..self - })) + ..slf + }; + Ok(Some(Arc::new(workspace))) } else { // Set the `pyproject.toml` for the member. member.pyproject_toml = pyproject_toml; // Recompute required_members with the updated member data let required_members = - Self::collect_required_members(&packages, &self.sources, &self.pyproject_toml)?; + Self::collect_required_members(&packages, &slf.sources, &slf.pyproject_toml)?; - Ok(Some(Self { + let workspace = Self { packages, required_members, - ..self - })) + ..slf + }; + Ok(Some(Arc::new(workspace))) } } @@ -634,7 +649,7 @@ impl Workspace { } /// Returns the set of dependency exclusions for the workspace. - pub fn exclude_dependencies(&self) -> Vec { + pub fn exclude_dependencies(&self) -> Vec { let Some(excludes) = self .pyproject_toml .tool @@ -823,7 +838,7 @@ impl Workspace { current_project: Option, options: &DiscoveryOptions, cache: &WorkspaceCache, - ) -> Result { + ) -> Result, WorkspaceError> { let cache_key = WorkspaceCacheKey { workspace_root: workspace_root.clone(), discovery_options: options.clone(), @@ -912,14 +927,15 @@ impl Workspace { ); } - Ok(Self { + let workspace = Self { install_path: workspace_root, packages: workspace_members, required_members, sources: workspace_sources, indexes: workspace_indexes, pyproject_toml: workspace_pyproject_toml, - }) + }; + Ok(Arc::new(workspace)) } async fn collect_members_only( @@ -1261,7 +1277,7 @@ pub struct ProjectWorkspace { /// The name of the package. project_name: PackageName, /// The workspace the project is part of. - workspace: Workspace, + workspace: Arc, } impl ProjectWorkspace { @@ -1371,9 +1387,8 @@ impl ProjectWorkspace { /// /// Assumes that the project name is unchanged in the updated [`PyProjectToml`]. fn update_member(self, pyproject_toml: PyProjectToml) -> Result, WorkspaceError> { - let Some(workspace) = self - .workspace - .update_member(&self.project_name, pyproject_toml)? + let Some(workspace) = + Workspace::update_member(self.workspace, &self.project_name, pyproject_toml)? else { return Ok(None); }; @@ -1448,19 +1463,20 @@ impl ProjectWorkspace { project_pyproject_toml, )?; + let workspace = Workspace { + install_path: project_path.to_path_buf(), + packages: current_project_as_members, + required_members, + // There may be package sources, but we don't need to duplicate them into the + // workspace sources. + sources: workspace_sources, + indexes: Vec::default(), + pyproject_toml: project_pyproject_toml.clone(), + }; return Ok(Self { project_root: project_path.to_path_buf(), project_name: project.name.clone(), - workspace: Workspace { - install_path: project_path.to_path_buf(), - packages: current_project_as_members, - required_members, - // There may be package sources, but we don't need to duplicate them into the - // workspace sources. - sources: workspace_sources, - indexes: Vec::default(), - pyproject_toml: project_pyproject_toml.clone(), - }, + workspace: Arc::new(workspace), }); }; @@ -1672,7 +1688,7 @@ pub enum VirtualProject { /// A project (which could be a workspace root or member). Project(ProjectWorkspace), /// A non-project workspace root. - NonProject(Workspace), + NonProject(Arc), } impl VirtualProject { @@ -1808,10 +1824,10 @@ impl VirtualProject { Self::NonProject(workspace) => { // If this is a non-project workspace root, then by definition the root isn't a // member, so we can just update the top-level `pyproject.toml`. - Some(Self::NonProject(Workspace { + Some(Self::NonProject(Arc::new(Workspace { pyproject_toml, - ..workspace.clone() - })) + ..(*workspace).clone() + }))) } }) } diff --git a/crates/uv/src/commands/build_frontend.rs b/crates/uv/src/commands/build_frontend.rs index 22577d62680cb..b1f128b55d4da 100644 --- a/crates/uv/src/commands/build_frontend.rs +++ b/crates/uv/src/commands/build_frontend.rs @@ -363,7 +363,7 @@ async fn build_impl( python_request, install_mirrors.clone(), no_config, - workspace.as_ref(), + workspace.as_deref(), python_preference, python_downloads, cache, diff --git a/crates/uv/src/commands/project/init.rs b/crates/uv/src/commands/project/init.rs index 60ef86f68f6d1..d79ffa4dd3f40 100644 --- a/crates/uv/src/commands/project/init.rs +++ b/crates/uv/src/commands/project/init.rs @@ -370,7 +370,7 @@ async fn init_project( &VersionFileDiscoveryOptions::default() .with_stop_discovery_at( workspace - .as_ref() + .as_deref() .map(Workspace::install_path) .map(PathBuf::as_ref), ) @@ -392,7 +392,7 @@ async fn init_project( python_preference, python_downloads, cache, - workspace.as_ref(), + workspace.as_deref(), &reporter, python_request, ) From 825874ba586c9304fd367831552e3d48115421b0 Mon Sep 17 00:00:00 2001 From: konstin Date: Thu, 21 May 2026 20:32:28 +0200 Subject: [PATCH 03/20] Cache workspace discovery --- Cargo.lock | 1 + crates/uv-workspace/Cargo.toml | 1 + crates/uv-workspace/src/workspace.rs | 461 ++++++++++++++++++---- crates/uv/src/commands/project/add.rs | 15 +- crates/uv/src/commands/project/remove.rs | 5 +- crates/uv/src/commands/project/version.rs | 10 +- crates/uv/tests/it/lock.rs | 3 - 7 files changed, 407 insertions(+), 89 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c31b42a751156..deecdfe27c8cd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7466,6 +7466,7 @@ dependencies = [ "uv-git-types", "uv-macros", "uv-normalize", + "uv-once-map", "uv-options-metadata", "uv-pep440", "uv-pep508", diff --git a/crates/uv-workspace/Cargo.toml b/crates/uv-workspace/Cargo.toml index 142741ad66130..91331f628a737 100644 --- a/crates/uv-workspace/Cargo.toml +++ b/crates/uv-workspace/Cargo.toml @@ -25,6 +25,7 @@ uv-fs = { workspace = true, features = ["tokio"] } uv-git-types = { workspace = true } uv-macros = { workspace = true } uv-normalize = { workspace = true } +uv-once-map = { workspace = true } uv-options-metadata = { workspace = true } uv-pep440 = { workspace = true } uv-pep508 = { workspace = true } diff --git a/crates/uv-workspace/src/workspace.rs b/crates/uv-workspace/src/workspace.rs index 773d2bc91b395..032b6461bcd63 100644 --- a/crates/uv-workspace/src/workspace.rs +++ b/crates/uv-workspace/src/workspace.rs @@ -1,18 +1,20 @@ //! Resolve the current [`ProjectWorkspace`] or [`Workspace`]. use std::collections::{BTreeMap, BTreeSet}; +use std::hash::BuildHasherDefault; use std::path::{Path, PathBuf}; -use std::sync::{Arc, Mutex}; +use std::sync::Arc; use glob::{GlobError, PatternError, glob}; use itertools::Itertools; -use rustc_hash::{FxHashMap, FxHashSet}; +use rustc_hash::{FxHashSet, FxHasher}; use tracing::{debug, trace, warn}; use uv_configuration::DependencyGroupsWithDefaults; use uv_distribution_types::{Index, Requirement, RequirementSource}; use uv_fs::{CWD, Simplified, normalize_path}; use uv_normalize::{DEV_DEPENDENCIES, GroupName, PackageName}; +use uv_once_map::OnceMap; use uv_pep440::VersionSpecifiers; use uv_pep508::{MarkerTree, VerbatimUrl}; use uv_pypi_types::{ConflictError, Conflicts, SupportedEnvironments, VerbatimParsedUrl}; @@ -25,22 +27,64 @@ use crate::pyproject::{ }; type WorkspaceMembers = Arc>; - -/// Cache key for workspace discovery. -/// -/// Given this key, the discovered workspace member list is the same. -#[derive(Debug, Default, Clone, Hash, PartialEq, Eq)] -struct WorkspaceCacheKey { - workspace_root: PathBuf, - discovery_options: DiscoveryOptions, -} +type FxOnceMap = OnceMap>; +/// `None` means there was an error during discovery. +type CachedWorkspace = Option>; /// Cache for workspace discovery. /// /// Avoid re-reading the `pyproject.toml` files in a workspace for each member by caching the /// workspace members by their workspace root. +/// +/// The cache contains references to the workspace root from each workspace member. +/// +/// The cache makes assumptions about [`DiscoveryOptions`]: +/// * For a given discovery path, `stop_discovery_at` either always or never sits between a project +/// and a workspace root. This means that we don't need to key discovery on `stop_discovery_at`. +/// * TODO(konsti): Support caching for [`MemberDiscovery`] modes that aren't `All`. #[derive(Debug, Default, Clone)] -pub struct WorkspaceCache(Arc>>); +pub struct WorkspaceCache { + workspaces: Arc>, +} + +impl WorkspaceCache { + fn insert(&self, workspace: Arc) { + for package in workspace.packages.values() { + self.workspaces + .done(package.root.clone(), Some(workspace.clone())); + } + self.workspaces + .done(workspace.install_path.clone(), Some(workspace)); + } + + // Handle an error without leaving the map dangling. + fn insert_none(&self, install_path: PathBuf) { + self.workspaces.done(install_path, None); + } + + /// Register workspace discovery for a root, or wait for an in-flight discovery. + async fn register_or_wait(&self, workspace_root: &PathBuf) -> Option> { + self.workspaces + .register_or_wait(workspace_root) + .await + .flatten() + } + + /// Get the cached workspace, if any, from the path to the workspace root or to a member root. + fn get(&self, path: &Path) -> Option> { + self.workspaces.get(path).flatten() + } + + /// Remove all cached workspace entries for the given workspace root. Used before modifying the + /// workspace. + pub fn remove(&self, install_path: &Path) { + if let Some(Some(workspace)) = self.workspaces.remove(install_path) { + for member in workspace.packages.values() { + self.workspaces.remove(&member.root); + } + } + } +} #[derive(thiserror::Error, Debug)] pub enum WorkspaceError { @@ -83,6 +127,8 @@ pub enum WorkspaceError { Toml(PathBuf, #[source] Box), #[error(transparent)] Conflicts(#[from] ConflictError), + // On Windows and Unix, this is not a regular IO failure, but requires e.g. `current_dir` to + // fail. #[error("Failed to normalize workspace member path")] Normalize(#[source] std::io::Error), } @@ -176,6 +222,12 @@ impl Workspace { .ok_or(WorkspaceError::MissingPyprojectToml)? .to_path_buf(); + if options.members == MemberDiscovery::All + && let Some(workspace) = cache.get(&path) + { + return Ok(workspace); + } + 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, &pyproject_path) @@ -229,6 +281,13 @@ impl Workspace { ) }; + if options.members == MemberDiscovery::All { + // Ensure that workspace discovery runs only once for any given workspace root. + if let Some(workspace) = cache.register_or_wait(&workspace_root).await { + return Ok(workspace); + } + } + debug!( "Found workspace root: `{}`", workspace_root.simplified_display() @@ -245,7 +304,7 @@ impl Workspace { pyproject_toml, }); - Self::collect_members( + match Self::build( workspace_root.clone(), workspace_definition, workspace_pyproject_toml, @@ -254,32 +313,43 @@ impl Workspace { cache, ) .await + { + Ok(workspace) => Ok(workspace), + Err(error) => { + if options.members == MemberDiscovery::All { + cache.insert_none(workspace_root); + } + Err(error) + } + } } /// Set the current project to the given workspace member. /// /// Returns `None` if the package is not part of the workspace. fn with_current_project( - slf: Arc, + self: Arc, package_name: PackageName, ) -> Option { - let member = slf.packages.get(&package_name)?; + let member = self.packages.get(&package_name)?; Some(ProjectWorkspace { project_root: member.root().clone(), project_name: package_name, - workspace: slf, + workspace: self, }) } /// Set the [`ProjectWorkspace`] for a given workspace member. /// - /// Assumes that the project name is unchanged in the updated [`PyProjectToml`]. + /// Assumes that the project name is unchanged in the updated [`PyProjectToml`], and that the + /// caller holds the only reference to this workspace (to avoid a situation where another part + /// of uv still holds a reference to the old workspace structure). fn update_member( - slf: Arc, + self: Arc, package_name: &PackageName, pyproject_toml: PyProjectToml, ) -> Result>, WorkspaceError> { - let slf = Arc::try_unwrap(slf).unwrap_or_else(|slf| { + let slf = Arc::try_unwrap(self).unwrap_or_else(|slf| { if cfg!(debug_assertions) { panic!( "Cannot modify workspace still in use with {} references", @@ -830,8 +900,8 @@ impl Workspace { } } - /// Collect the workspace member projects from the `members` and `excludes` entries. - async fn collect_members( + /// Collect the workspace member projects and build the workspace object. + async fn build( workspace_root: PathBuf, workspace_definition: ToolUvWorkspace, workspace_pyproject_toml: PyProjectToml, @@ -839,44 +909,26 @@ impl Workspace { options: &DiscoveryOptions, cache: &WorkspaceCache, ) -> Result, WorkspaceError> { - let cache_key = WorkspaceCacheKey { - workspace_root: workspace_root.clone(), - discovery_options: options.clone(), - }; - let cache_entry = { - // Acquire the lock for the minimal required region - let cache = cache.0.lock().expect("there was a panic in another thread"); - cache.get(&cache_key).cloned() - }; - let mut workspace_members = if let Some(workspace_members) = cache_entry { - trace!( - "Cached workspace members for: `{}`", - &workspace_root.simplified_display() - ); - workspace_members - } else { - trace!( - "Discovering workspace members for: `{}`", - &workspace_root.simplified_display() - ); - let workspace_members = Self::collect_members_only( - &workspace_root, - &workspace_definition, - &workspace_pyproject_toml, - options, - ) - .await?; - { - // Acquire the lock for the minimal required region - let mut cache = cache.0.lock().expect("there was a panic in another thread"); - cache.insert(cache_key, Arc::new(workspace_members.clone())); - } - Arc::new(workspace_members) - }; + trace!( + "Discovering workspace members for: `{}`", + &workspace_root.simplified_display() + ); + let workspace_members = Self::collect_members_only( + &workspace_root, + &workspace_definition, + &workspace_pyproject_toml, + options, + ) + .await?; + let mut workspace_members = Arc::new(workspace_members); // For the cases such as `MemberDiscovery::None`, add the current project if missing. if let Some(root_member) = current_project { if !workspace_members.contains_key(&root_member.project.name) { + assert!(matches!( + options.members, + MemberDiscovery::None | MemberDiscovery::Ignore(_) + )); debug!( "Adding current workspace member: `{}`", root_member.root.simplified_display() @@ -935,7 +987,11 @@ impl Workspace { indexes: workspace_indexes, pyproject_toml: workspace_pyproject_toml, }; - Ok(Arc::new(workspace)) + let workspace = Arc::new(workspace); + if options.members == MemberDiscovery::All { + cache.insert(workspace.clone()); + } + Ok(workspace) } async fn collect_members_only( @@ -951,11 +1007,6 @@ impl Workspace { // Add the project at the workspace root, if it exists and if it's distinct from the current // project. If it is the current project, it is added as such in the next step. if let Some(project) = &workspace_pyproject_toml.project { - let pyproject_path = workspace_root.join("pyproject.toml"); - let contents = fs_err::read_to_string(&pyproject_path)?; - let pyproject_toml = PyProjectToml::from_string(contents, &pyproject_path) - .map_err(|err| WorkspaceError::Toml(pyproject_path.clone(), Box::new(err)))?; - debug!( "Adding root workspace member: `{}`", workspace_root.simplified_display() @@ -967,7 +1018,7 @@ impl Workspace { WorkspaceMember { root: workspace_root.clone(), project: project.clone(), - pyproject_toml, + pyproject_toml: workspace_pyproject_toml.clone(), }, ); } @@ -1281,6 +1332,27 @@ pub struct ProjectWorkspace { } impl ProjectWorkspace { + fn from_cache( + project_root: &Path, + options: &DiscoveryOptions, + cache: &WorkspaceCache, + ) -> Option { + if options.members != MemberDiscovery::All { + return None; + } + let workspace = cache.get(project_root)?; + let (project_name, _member) = workspace + .packages + .iter() + .find(|(_project_name, member)| member.root() == project_root)?; + + Some(Self { + project_root: project_root.to_path_buf(), + project_name: project_name.clone(), + workspace, + }) + } + /// Find the current project and workspace, given the current directory. /// /// `stop_discovery_at` must be either `None` or an ancestor of the current directory. If set, @@ -1290,6 +1362,10 @@ impl ProjectWorkspace { options: &DiscoveryOptions, cache: &WorkspaceCache, ) -> Result { + assert!( + path.is_absolute(), + "project workspace discovery with relative path" + ); let project_root = path .ancestors() .take_while(|path| { @@ -1318,8 +1394,13 @@ impl ProjectWorkspace { options: &DiscoveryOptions, cache: &WorkspaceCache, ) -> Result { + if let Some(project) = Self::from_cache(project_root, options, cache) { + return Ok(project); + } + // Read the current `pyproject.toml`. let pyproject_path = project_root.join("pyproject.toml"); + let contents = fs_err::tokio::read_to_string(&pyproject_path).await?; let pyproject_toml = PyProjectToml::from_string(contents, &pyproject_path) .map_err(|err| WorkspaceError::Toml(pyproject_path.clone(), Box::new(err)))?; @@ -1336,12 +1417,16 @@ impl ProjectWorkspace { /// 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( - install_path: &Path, + project_root: &Path, options: &DiscoveryOptions, cache: &WorkspaceCache, ) -> Result, WorkspaceError> { + if let Some(project) = Self::from_cache(project_root, options, cache) { + return Ok(Some(project)); + } + // Read the `pyproject.toml`. - let pyproject_path = install_path.join("pyproject.toml"); + let pyproject_path = project_root.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); @@ -1355,7 +1440,7 @@ impl ProjectWorkspace { return Ok(None); }; - match Self::from_project(install_path, &project, &pyproject_toml, options, cache).await { + match Self::from_project(project_root, &project, &pyproject_toml, options, cache).await { Ok(workspace) => Ok(Some(workspace)), Err(WorkspaceError::NonWorkspace(_)) => Ok(None), Err(err) => Err(err), @@ -1420,6 +1505,10 @@ impl ProjectWorkspace { return Err(WorkspaceError::NonWorkspace(project_path.to_path_buf())); } + if let Some(project) = Self::from_cache(&project_path, options, cache) { + return Ok(project); + } + // Check if the current project is also an explicit workspace root. let mut workspace = project_pyproject_toml .tool @@ -1473,27 +1562,49 @@ impl ProjectWorkspace { indexes: Vec::default(), pyproject_toml: project_pyproject_toml.clone(), }; + let workspace = Arc::new(workspace); + cache.insert(workspace.clone()); return Ok(Self { project_root: project_path.to_path_buf(), project_name: project.name.clone(), - workspace: Arc::new(workspace), + workspace, }); }; + if options.members == MemberDiscovery::All { + // Ensure that workspace discovery runs only once for any given workspace root. + if let Some(workspace) = cache.register_or_wait(&workspace_root).await { + return Ok(Self { + project_root: project_path.to_path_buf(), + project_name: project.name.clone(), + workspace, + }); + } + } + debug!( "Found workspace root: `{}`", workspace_root.simplified_display() ); - let workspace = Workspace::collect_members( - workspace_root, + let workspace = match Workspace::build( + workspace_root.clone(), workspace_definition, workspace_pyproject_toml, Some(current_project), options, cache, ) - .await?; + .await + { + Ok(workspace) => workspace, + Err(error) => { + if options.members == MemberDiscovery::All { + cache.insert_none(workspace_root); + } + return Err(error); + } + }; Ok(Self { project_root: project_path.to_path_buf(), @@ -1727,6 +1838,26 @@ impl VirtualProject { project_root.simplified_display() ); + // Fast path. + if options.members == MemberDiscovery::All + && let Some(workspace) = cache.get(project_root) + { + let virtual_project = if let Some((project_name, _member)) = workspace + .packages + .iter() + .find(|(_package_name, member)| member.root == project_root) + { + Self::Project(ProjectWorkspace { + project_root: project_root.to_path_buf(), + project_name: project_name.clone(), + workspace, + }) + } else { + Self::NonProject(workspace.clone()) + }; + return Ok(virtual_project); + } + // Read the current `pyproject.toml`. let pyproject_path = project_root.join("pyproject.toml"); let contents = fs_err::tokio::read_to_string(&pyproject_path).await?; @@ -1756,8 +1887,8 @@ impl VirtualProject { .map_err(WorkspaceError::Normalize)? .clone(); - let workspace = Workspace::collect_members( - project_path, + let workspace = Workspace::build( + project_path.clone(), workspace.clone(), pyproject_toml, None, @@ -1765,7 +1896,6 @@ impl VirtualProject { cache, ) .await?; - Ok(Self::NonProject(workspace)) } else { // Otherwise it's a pyproject.toml that maybe contains dependency-groups @@ -1774,13 +1904,15 @@ impl VirtualProject { .map_err(WorkspaceError::Normalize)? .clone(); - let workspace = Workspace::collect_members( + let workspace = Workspace::build( project_path, ToolUvWorkspace::default(), pyproject_toml, None, options, - cache, + // Avoid populating the shared cache with a synthetic workspace that + // `Workspace::discover` would reject as missing a `[project]` table. + &WorkspaceCache::default(), ) .await?; @@ -1810,10 +1942,15 @@ impl VirtualProject { /// Update the `pyproject.toml` for the current project. /// /// Assumes that the project name is unchanged in the updated [`PyProjectToml`]. + /// + /// The [`WorkspaceCache`] is passed to ensure the caller doesn't forget to clear it. pub fn update_member( self, pyproject_toml: PyProjectToml, + workspace_cache: &WorkspaceCache, ) -> Result, WorkspaceError> { + // Our modifying operations run on a single workspace, clear that workspace. + workspace_cache.remove(&self.workspace().install_path); Ok(match self { Self::Project(project) => { let Some(project) = project.update_member(pyproject_toml)? else { @@ -1822,16 +1959,41 @@ impl VirtualProject { Some(Self::Project(project)) } Self::NonProject(workspace) => { + let workspace = Arc::try_unwrap(workspace).unwrap_or_else(|workspace| { + if cfg!(debug_assertions) { + panic!( + "Cannot modify workspace still in use with {} references", + Arc::strong_count(&workspace) + ); + } else { + (*workspace).clone() + } + }); // If this is a non-project workspace root, then by definition the root isn't a // member, so we can just update the top-level `pyproject.toml`. - Some(Self::NonProject(Arc::new(Workspace { + let workspace = Workspace { pyproject_toml, - ..(*workspace).clone() - }))) + ..workspace + }; + Some(Self::NonProject(Arc::new(workspace))) } }) } + /// Clone while detaching from the original workspace `Arc`, freeing the original state for + /// modification. + #[must_use] + pub fn clone_detach(&self) -> Self { + match self { + Self::Project(project) => Self::Project(ProjectWorkspace { + project_root: project.project_root.clone(), + project_name: project.project_name.clone(), + workspace: Arc::new((*project.workspace).clone()), + }), + Self::NonProject(workspace) => Self::NonProject(Arc::new((**workspace).clone())), + } + } + /// Return the root of the project. pub fn root(&self) -> &Path { match self { @@ -1876,6 +2038,8 @@ mod tests { use std::env; use std::path::Path; use std::str::FromStr; + use std::sync::Arc; + use std::time::Duration; use anyhow::Result; use assert_fs::fixture::ChildPath; @@ -1886,7 +2050,7 @@ mod tests { use uv_pypi_types::DependencyGroupSpecifier; use crate::pyproject::PyProjectToml; - use crate::workspace::{DiscoveryOptions, ProjectWorkspace}; + use crate::workspace::{DiscoveryOptions, ProjectWorkspace, Workspace}; use crate::{WorkspaceCache, WorkspaceError}; async fn workspace_test(folder: &str) -> (ProjectWorkspace, String) { @@ -2260,6 +2424,147 @@ mod tests { }); } + #[tokio::test] + async fn workspace_cache_reuses_workspace_for_member() -> Result<()> { + let root = tempfile::TempDir::new()?; + let root = ChildPath::new(root.path()); + + root.child("pyproject.toml").write_str( + r#" + [project] + name = "albatross" + version = "0.1.0" + requires-python = ">=3.12" + + [tool.uv.workspace] + members = ["packages/*"] + "#, + )?; + + root.child("packages") + .child("seeds") + .child("pyproject.toml") + .write_str( + r#" + [project] + name = "seeds" + version = "1.0.0" + requires-python = ">=3.12" + "#, + )?; + + let cache = WorkspaceCache::default(); + let root_workspace = + Workspace::discover(root.as_ref(), &DiscoveryOptions::default(), &cache).await?; + let member_workspace = Workspace::discover( + root.child("packages").child("seeds").as_ref(), + &DiscoveryOptions::default(), + &cache, + ) + .await?; + + assert!(Arc::ptr_eq(&root_workspace, &member_workspace)); + + root.child("pyproject.toml") + .write_str("not valid toml >.<")?; + let member_project = ProjectWorkspace::from_maybe_project_root( + root.child("packages").child("seeds").as_ref(), + &DiscoveryOptions::default(), + &cache, + ) + .await? + .expect("cached workspace member ignores invalid change in the meantime"); + + assert!(Arc::ptr_eq(&root_workspace, &member_project.workspace)); + + Ok(()) + } + + #[tokio::test] + async fn workspace_cache_recovers_after_error() -> Result<()> { + fn is_child1_editable_conflict(error: &WorkspaceError) -> bool { + match error { + WorkspaceError::EditableConflict(package) => package.as_str() == "child1", + _ => false, + } + } + + let root = tempfile::TempDir::new()?; + let root = ChildPath::new(root.path()); + + root.child("pyproject.toml").write_str( + r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["child1"] + + [tool.uv.workspace] + members = ["child1", "child2"] + + [tool.uv.sources] + child1 = { workspace = true, editable = false } + "#, + )?; + + root.child("child1").child("pyproject.toml").write_str( + r#" + [project] + name = "child1" + version = "0.1.0" + requires-python = ">=3.12" + "#, + )?; + + let child2 = root.child("child2").child("pyproject.toml"); + child2.write_str( + r#" + [project] + name = "child2" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["child1"] + + [tool.uv.sources] + child1 = { workspace = true, editable = true } + "#, + )?; + + let cache = WorkspaceCache::default(); + let first = Workspace::discover(root.as_ref(), &DiscoveryOptions::default(), &cache).await; + assert!(matches!(first, Err(ref error) if is_child1_editable_conflict(error))); + + let second = tokio::time::timeout( + Duration::from_secs(1), + Workspace::discover(root.as_ref(), &DiscoveryOptions::default(), &cache), + ) + .await; + assert!(matches!(second, Ok(Err(ref error)) if is_child1_editable_conflict(error))); + + child2.write_str( + r#" + [project] + name = "child2" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["child1"] + + [tool.uv.sources] + child1 = { workspace = true, editable = false } + "#, + )?; + + let recovered = tokio::time::timeout( + Duration::from_secs(1), + Workspace::discover(root.as_ref(), &DiscoveryOptions::default(), &cache), + ) + .await; + assert!(matches!(recovered, Ok(Ok(_)))); + + Ok(()) + } + #[tokio::test] async fn albatross_just_project() { let (project, root_escaped) = workspace_test("albatross-just-project").await; diff --git a/crates/uv/src/commands/project/add.rs b/crates/uv/src/commands/project/add.rs index b39108fccb025..0ee55c20b46f2 100644 --- a/crates/uv/src/commands/project/add.rs +++ b/crates/uv/src/commands/project/add.rs @@ -712,7 +712,7 @@ pub(crate) async fn add( }; // Update the `pypackage.toml` in-memory. - let target = target.update(&content)?; + let target = target.update(&content, &WorkspaceCache::default())?; // Set the Ctrl-C handler to revert changes on exit. let _ = ctrlc::set_handler({ @@ -1169,7 +1169,7 @@ async fn lock_and_sync( target.write(&content)?; // Update the `pypackage.toml` in-memory. - target = target.update(&content)?; + target = target.update(&content, &WorkspaceCache::default())?; // Invalidate the project metadata. if let AddTarget::Project(VirtualProject::Project(ref project), _) = target { @@ -1396,7 +1396,7 @@ impl AddTarget { } /// Update the target in-memory to incorporate the new content. - fn update(self, content: &str) -> Result { + fn update(self, content: &str, workspace_cache: &WorkspaceCache) -> Result { match self { Self::Script(mut script, interpreter) => { script.metadata = Pep723Metadata::from_str(content) @@ -1409,6 +1409,7 @@ impl AddTarget { .update_member( PyProjectToml::from_string(content.to_string(), &pyproject_path) .map_err(ProjectError::PyprojectTomlParse)?, + workspace_cache, )? .ok_or(ProjectError::PyprojectTomlUpdate)?; Ok(Self::Project(project, venv)) @@ -1425,10 +1426,14 @@ impl AddTarget { }; let lock = target.read_bytes().await?; - // Clone the target. + // Obtain a detached a copy of the old structure so we can revert to it without + // breaking the assumption that the workspace cache is only used by the modifying code + // when changing it. match self { Self::Script(script, _) => Ok(AddTargetSnapshot::Script(script.clone(), lock)), - Self::Project(project, _) => Ok(AddTargetSnapshot::Project(project.clone(), lock)), + Self::Project(project, _) => { + Ok(AddTargetSnapshot::Project(project.clone_detach(), lock)) + } } } } diff --git a/crates/uv/src/commands/project/remove.rs b/crates/uv/src/commands/project/remove.rs index ab78d656466ba..99c0582aedd69 100644 --- a/crates/uv/src/commands/project/remove.rs +++ b/crates/uv/src/commands/project/remove.rs @@ -208,7 +208,7 @@ pub(crate) async fn remove( } // Update the `pypackage.toml` in-memory. - let target = target.update(&content)?; + let target = target.update(&content, &WorkspaceCache::default())?; // Determine enabled groups and extras let default_groups = match &target { @@ -441,7 +441,7 @@ impl RemoveTarget { } /// Update the target in-memory to incorporate the new content. - fn update(self, content: &str) -> Result { + fn update(self, content: &str, workspace_cache: &WorkspaceCache) -> Result { match self { Self::Script(mut script) => { script.metadata = Pep723Metadata::from_str(content) @@ -454,6 +454,7 @@ impl RemoveTarget { .update_member( PyProjectToml::from_string(content.to_string(), &pyproject_path) .map_err(ProjectError::PyprojectTomlParse)?, + workspace_cache, )? .ok_or(ProjectError::PyprojectTomlUpdate)?; Ok(Self::Project(project)) diff --git a/crates/uv/src/commands/project/version.rs b/crates/uv/src/commands/project/version.rs index 1e1d2f1fd4882..0f70374e46479 100644 --- a/crates/uv/src/commands/project/version.rs +++ b/crates/uv/src/commands/project/version.rs @@ -337,7 +337,13 @@ pub(crate) async fn project_version( let status = if dry_run { ExitStatus::Success } else if let Some(new_version) = &new_version { - let project = update_project(project, new_version, &mut toml, &pyproject_path)?; + let project = update_project( + project, + new_version, + &mut toml, + &pyproject_path, + workspace_cache, + )?; Box::pin(lock_and_sync( project, project_dir, @@ -444,6 +450,7 @@ fn update_project( new_version: &Version, toml: &mut PyProjectTomlMut, pyproject_path: &Path, + workspace_cache: &WorkspaceCache, ) -> Result { // Save to disk toml.set_version(new_version)?; @@ -455,6 +462,7 @@ fn update_project( .update_member( PyProjectToml::from_string(content, pyproject_path) .map_err(ProjectError::PyprojectTomlParse)?, + workspace_cache, )? .ok_or(ProjectError::PyprojectTomlUpdate)?; diff --git a/crates/uv/tests/it/lock.rs b/crates/uv/tests/it/lock.rs index c63431ed9055f..e3d2c14a1d4e6 100644 --- a/crates/uv/tests/it/lock.rs +++ b/crates/uv/tests/it/lock.rs @@ -20379,19 +20379,16 @@ fn lock_explicit_default_index() -> Result<()> { DEBUG Searching for user configuration in: `[UV_USER_CONFIG_DIR]/uv.toml` DEBUG uv [VERSION] ([COMMIT] DATE) DEBUG Found project root: `[TEMP_DIR]/` - DEBUG No workspace root found, using project root DEBUG No Python version file found in workspace: [TEMP_DIR]/ DEBUG Using Python request `>=3.12` from `requires-python` metadata DEBUG Checking for Python environment at: `.venv` DEBUG The project environment's Python version satisfies the request: `Python >=3.12` DEBUG Using request connect timeout of [TIME] and read timeout of [TIME] DEBUG Found static `requires-dist` for: [TEMP_DIR]/ - DEBUG No workspace root found, using project root DEBUG Resolving despite existing lockfile due to mismatched requirements for: `project==0.1.0` Requested: {Requirement { name: PackageName("anyio"), extras: [], groups: [], marker: true, source: Registry { specifier: VersionSpecifiers([]), index: None, conflict: None }, origin: None }} Existing: {Requirement { name: PackageName("iniconfig"), extras: [], groups: [], marker: true, source: Registry { specifier: VersionSpecifiers([VersionSpecifier { operator: Equal, version: "2.0.0" }]), index: Some(IndexMetadata { url: Url(VerbatimUrl { url: DisplaySafeUrl { scheme: "https", cannot_be_a_base: false, username: "", password: None, host: Some(Domain("test.pypi.org")), port: None, path: "/simple", query: None, fragment: None }, given: None, expanded: false }), format: Simple }), conflict: None }, origin: None }} DEBUG Found static `pyproject.toml` for: project @ file://[TEMP_DIR]/ - DEBUG No workspace root found, using project root DEBUG Solving with installed Python version: 3.12.[X] DEBUG Solving with target Python version: >=3.12 DEBUG Solving with exclude-newer: global: 2024-03-25T00:00:00Z From 3fe0deaa6a17256699b022e7d480b73974b9629b Mon Sep 17 00:00:00 2001 From: konstin Date: Wed, 27 May 2026 10:35:43 +0200 Subject: [PATCH 04/20] Minor fixes --- crates/uv-workspace/src/workspace.rs | 103 ++++----------------------- 1 file changed, 15 insertions(+), 88 deletions(-) diff --git a/crates/uv-workspace/src/workspace.rs b/crates/uv-workspace/src/workspace.rs index 032b6461bcd63..6ac0e13e7e2d6 100644 --- a/crates/uv-workspace/src/workspace.rs +++ b/crates/uv-workspace/src/workspace.rs @@ -36,7 +36,7 @@ type CachedWorkspace = Option>; /// Avoid re-reading the `pyproject.toml` files in a workspace for each member by caching the /// workspace members by their workspace root. /// -/// The cache contains references to the workspace root from each workspace member. +/// The cache is indexed both by the workspace root and by the path of each workspace member. /// /// The cache makes assumptions about [`DiscoveryOptions`]: /// * For a given discovery path, `stop_discovery_at` either always or never sits between a project @@ -57,7 +57,7 @@ impl WorkspaceCache { .done(workspace.install_path.clone(), Some(workspace)); } - // Handle an error without leaving the map dangling. + /// Handle an error without leaving the map dangling. fn insert_none(&self, install_path: PathBuf) { self.workspaces.done(install_path, None); } @@ -222,6 +222,11 @@ impl Workspace { .ok_or(WorkspaceError::MissingPyprojectToml)? .to_path_buf(); + // Fast path: The workspace was already fully discovered. + // It's possible that there are two separate discoveries for the same workspace going on + // at the same time from different roots, both failing this check. These cases are fine, we + // synchronize them after finding the workspace root and allow only one of them to perform + // the full discovery. if options.members == MemberDiscovery::All && let Some(workspace) = cache.get(&path) { @@ -283,6 +288,10 @@ impl Workspace { if options.members == MemberDiscovery::All { // Ensure that workspace discovery runs only once for any given workspace root. + // If two threads start at different packages at the same time, they only read their + // package `pyproject.toml` and the workspace root `pyproject.toml` before arriving + // here. At this point, only one thread can continue and the other waits, then uses the + // cached workspace. if let Some(workspace) = cache.register_or_wait(&workspace_root).await { return Ok(workspace); } @@ -1838,7 +1847,7 @@ impl VirtualProject { project_root.simplified_display() ); - // Fast path. + // Fast path: The workspace is already cached. if options.members == MemberDiscovery::All && let Some(workspace) = cache.get(project_root) { @@ -1951,6 +1960,9 @@ impl VirtualProject { ) -> Result, WorkspaceError> { // Our modifying operations run on a single workspace, clear that workspace. workspace_cache.remove(&self.workspace().install_path); + for member in self.workspace().packages.values() { + workspace_cache.remove(member.root()); + } Ok(match self { Self::Project(project) => { let Some(project) = project.update_member(pyproject_toml)? else { @@ -2480,91 +2492,6 @@ mod tests { Ok(()) } - #[tokio::test] - async fn workspace_cache_recovers_after_error() -> Result<()> { - fn is_child1_editable_conflict(error: &WorkspaceError) -> bool { - match error { - WorkspaceError::EditableConflict(package) => package.as_str() == "child1", - _ => false, - } - } - - let root = tempfile::TempDir::new()?; - let root = ChildPath::new(root.path()); - - root.child("pyproject.toml").write_str( - r#" - [project] - name = "project" - version = "0.1.0" - requires-python = ">=3.12" - dependencies = ["child1"] - - [tool.uv.workspace] - members = ["child1", "child2"] - - [tool.uv.sources] - child1 = { workspace = true, editable = false } - "#, - )?; - - root.child("child1").child("pyproject.toml").write_str( - r#" - [project] - name = "child1" - version = "0.1.0" - requires-python = ">=3.12" - "#, - )?; - - let child2 = root.child("child2").child("pyproject.toml"); - child2.write_str( - r#" - [project] - name = "child2" - version = "0.1.0" - requires-python = ">=3.12" - dependencies = ["child1"] - - [tool.uv.sources] - child1 = { workspace = true, editable = true } - "#, - )?; - - let cache = WorkspaceCache::default(); - let first = Workspace::discover(root.as_ref(), &DiscoveryOptions::default(), &cache).await; - assert!(matches!(first, Err(ref error) if is_child1_editable_conflict(error))); - - let second = tokio::time::timeout( - Duration::from_secs(1), - Workspace::discover(root.as_ref(), &DiscoveryOptions::default(), &cache), - ) - .await; - assert!(matches!(second, Ok(Err(ref error)) if is_child1_editable_conflict(error))); - - child2.write_str( - r#" - [project] - name = "child2" - version = "0.1.0" - requires-python = ">=3.12" - dependencies = ["child1"] - - [tool.uv.sources] - child1 = { workspace = true, editable = false } - "#, - )?; - - let recovered = tokio::time::timeout( - Duration::from_secs(1), - Workspace::discover(root.as_ref(), &DiscoveryOptions::default(), &cache), - ) - .await; - assert!(matches!(recovered, Ok(Ok(_)))); - - Ok(()) - } - #[tokio::test] async fn albatross_just_project() { let (project, root_escaped) = workspace_test("albatross-just-project").await; From 15650da7dc21546479ffbcf3dbf6272ca88d0a4a Mon Sep 17 00:00:00 2001 From: konstin Date: Mon, 1 Jun 2026 09:51:55 +0200 Subject: [PATCH 05/20] Review --- crates/uv-workspace/src/workspace.rs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/crates/uv-workspace/src/workspace.rs b/crates/uv-workspace/src/workspace.rs index 6ac0e13e7e2d6..a82a7e3df0b66 100644 --- a/crates/uv-workspace/src/workspace.rs +++ b/crates/uv-workspace/src/workspace.rs @@ -77,6 +77,9 @@ impl WorkspaceCache { /// Remove all cached workspace entries for the given workspace root. Used before modifying the /// workspace. + /// + /// Contract: There are no parallel workspace operations, this is the only thread operating on + /// workspaces. pub fn remove(&self, install_path: &Path) { if let Some(Some(workspace)) = self.workspaces.remove(install_path) { for member in workspace.packages.values() { @@ -228,7 +231,7 @@ impl Workspace { // synchronize them after finding the workspace root and allow only one of them to perform // the full discovery. if options.members == MemberDiscovery::All - && let Some(workspace) = cache.get(&path) + && let Some(workspace) = cache.get(&project_path) { return Ok(workspace); } @@ -1952,6 +1955,9 @@ impl VirtualProject { /// /// Assumes that the project name is unchanged in the updated [`PyProjectToml`]. /// + /// Contract: There are no parallel workspace operations, this is the only thread operating on + /// workspaces. + /// /// The [`WorkspaceCache`] is passed to ensure the caller doesn't forget to clear it. pub fn update_member( self, From b1132de9644ec9aebf20fcdedc8f78ca59734890 Mon Sep 17 00:00:00 2001 From: konstin Date: Mon, 1 Jun 2026 12:25:09 +0200 Subject: [PATCH 06/20] clippy --- crates/uv-workspace/src/workspace.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/crates/uv-workspace/src/workspace.rs b/crates/uv-workspace/src/workspace.rs index a82a7e3df0b66..7c745e62b5adb 100644 --- a/crates/uv-workspace/src/workspace.rs +++ b/crates/uv-workspace/src/workspace.rs @@ -2057,7 +2057,6 @@ mod tests { use std::path::Path; use std::str::FromStr; use std::sync::Arc; - use std::time::Duration; use anyhow::Result; use assert_fs::fixture::ChildPath; From 8f2858652fc9bb9899186c860a7d87ef1a555700 Mon Sep 17 00:00:00 2001 From: konstin Date: Tue, 2 Jun 2026 11:39:11 +0200 Subject: [PATCH 07/20] Review --- crates/uv-workspace/src/workspace.rs | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/crates/uv-workspace/src/workspace.rs b/crates/uv-workspace/src/workspace.rs index 7c745e62b5adb..2a1507923ad8a 100644 --- a/crates/uv-workspace/src/workspace.rs +++ b/crates/uv-workspace/src/workspace.rs @@ -80,8 +80,8 @@ impl WorkspaceCache { /// /// Contract: There are no parallel workspace operations, this is the only thread operating on /// workspaces. - pub fn remove(&self, install_path: &Path) { - if let Some(Some(workspace)) = self.workspaces.remove(install_path) { + pub fn invalidate_workspace(&self, workspace: &Workspace) { + if let Some(Some(workspace)) = self.workspaces.remove(workspace.install_path()) { for member in workspace.packages.values() { self.workspaces.remove(&member.root); } @@ -1965,10 +1965,7 @@ impl VirtualProject { workspace_cache: &WorkspaceCache, ) -> Result, WorkspaceError> { // Our modifying operations run on a single workspace, clear that workspace. - workspace_cache.remove(&self.workspace().install_path); - for member in self.workspace().packages.values() { - workspace_cache.remove(member.root()); - } + workspace_cache.invalidate_workspace(self.workspace()); Ok(match self { Self::Project(project) => { let Some(project) = project.update_member(pyproject_toml)? else { From 1249795bfe907b59cd38ffbcca85376c5cb8f93a Mon Sep 17 00:00:00 2001 From: konstin Date: Tue, 2 Jun 2026 12:26:47 +0200 Subject: [PATCH 08/20] Review --- crates/uv-workspace/src/workspace.rs | 69 ++++++++++++++--------- crates/uv/src/commands/project/version.rs | 1 - 2 files changed, 41 insertions(+), 29 deletions(-) diff --git a/crates/uv-workspace/src/workspace.rs b/crates/uv-workspace/src/workspace.rs index 2a1507923ad8a..0783c87ac1578 100644 --- a/crates/uv-workspace/src/workspace.rs +++ b/crates/uv-workspace/src/workspace.rs @@ -326,7 +326,12 @@ impl Workspace { ) .await { - Ok(workspace) => Ok(workspace), + Ok(workspace) => { + if options.members == MemberDiscovery::All { + cache.insert(workspace.clone()); + } + Ok(workspace) + } Err(error) => { if options.members == MemberDiscovery::All { cache.insert_none(workspace_root); @@ -361,16 +366,13 @@ impl Workspace { package_name: &PackageName, pyproject_toml: PyProjectToml, ) -> Result>, WorkspaceError> { - let slf = Arc::try_unwrap(self).unwrap_or_else(|slf| { - if cfg!(debug_assertions) { - panic!( - "Cannot modify workspace still in use with {} references", - Arc::strong_count(&slf) - ); - } else { - (*slf).clone() - } - }); + debug_assert_eq!( + Arc::strong_count(&self), + 1, + "cannot modify workspace still in use", + ); + + let slf = Arc::unwrap_or_clone(self); let mut packages = slf.packages; let Some(member) = Arc::make_mut(&mut packages).get_mut(package_name) else { @@ -999,11 +1001,7 @@ impl Workspace { indexes: workspace_indexes, pyproject_toml: workspace_pyproject_toml, }; - let workspace = Arc::new(workspace); - if options.members == MemberDiscovery::All { - cache.insert(workspace.clone()); - } - Ok(workspace) + Ok(Arc::new(workspace)) } async fn collect_members_only( @@ -1575,7 +1573,9 @@ impl ProjectWorkspace { pyproject_toml: project_pyproject_toml.clone(), }; let workspace = Arc::new(workspace); - cache.insert(workspace.clone()); + if options.members == MemberDiscovery::All { + cache.insert(workspace.clone()); + } return Ok(Self { project_root: project_path.to_path_buf(), project_name: project.name.clone(), @@ -1609,7 +1609,12 @@ impl ProjectWorkspace { ) .await { - Ok(workspace) => workspace, + Ok(workspace) => { + if options.members == MemberDiscovery::All { + cache.insert(workspace.clone()); + } + workspace + } Err(error) => { if options.members == MemberDiscovery::All { cache.insert_none(workspace_root); @@ -1908,6 +1913,11 @@ impl VirtualProject { cache, ) .await?; + + if options.members == MemberDiscovery::All { + cache.insert(workspace.clone()); + } + Ok(Self::NonProject(workspace)) } else { // Otherwise it's a pyproject.toml that maybe contains dependency-groups @@ -1928,6 +1938,10 @@ impl VirtualProject { ) .await?; + if options.members == MemberDiscovery::All { + cache.insert(workspace.clone()); + } + Ok(Self::NonProject(workspace)) } } @@ -1974,16 +1988,13 @@ impl VirtualProject { Some(Self::Project(project)) } Self::NonProject(workspace) => { - let workspace = Arc::try_unwrap(workspace).unwrap_or_else(|workspace| { - if cfg!(debug_assertions) { - panic!( - "Cannot modify workspace still in use with {} references", - Arc::strong_count(&workspace) - ); - } else { - (*workspace).clone() - } - }); + debug_assert_eq!( + Arc::strong_count(&workspace), + 1, + "cannot modify workspace still in use", + ); + + let workspace = Arc::unwrap_or_clone(workspace); // If this is a non-project workspace root, then by definition the root isn't a // member, so we can just update the top-level `pyproject.toml`. let workspace = Workspace { @@ -1997,6 +2008,8 @@ impl VirtualProject { /// Clone while detaching from the original workspace `Arc`, freeing the original state for /// modification. + /// + /// This is intended for rollbacks only. #[must_use] pub fn clone_detach(&self) -> Self { match self { diff --git a/crates/uv/src/commands/project/version.rs b/crates/uv/src/commands/project/version.rs index 0f70374e46479..57ae33d3058a5 100644 --- a/crates/uv/src/commands/project/version.rs +++ b/crates/uv/src/commands/project/version.rs @@ -415,7 +415,6 @@ async fn find_target( workspace_cache: &WorkspaceCache, ) -> Result { // Find the project in the workspace. - // No workspace caching since `uv version` changes the workspace definition. let project = if let Some(package) = package { VirtualProject::discover_with_package( project_dir, From ad8f3bec63d86acdb4067094e67fdd263efb292b Mon Sep 17 00:00:00 2001 From: konstin Date: Tue, 2 Jun 2026 13:46:00 +0200 Subject: [PATCH 09/20] review --- crates/uv-workspace/src/workspace.rs | 7 ------- 1 file changed, 7 deletions(-) diff --git a/crates/uv-workspace/src/workspace.rs b/crates/uv-workspace/src/workspace.rs index 0783c87ac1578..807d0c38b9283 100644 --- a/crates/uv-workspace/src/workspace.rs +++ b/crates/uv-workspace/src/workspace.rs @@ -322,7 +322,6 @@ impl Workspace { workspace_pyproject_toml, current_project, options, - cache, ) .await { @@ -921,7 +920,6 @@ impl Workspace { workspace_pyproject_toml: PyProjectToml, current_project: Option, options: &DiscoveryOptions, - cache: &WorkspaceCache, ) -> Result, WorkspaceError> { trace!( "Discovering workspace members for: `{}`", @@ -1605,7 +1603,6 @@ impl ProjectWorkspace { workspace_pyproject_toml, Some(current_project), options, - cache, ) .await { @@ -1910,7 +1907,6 @@ impl VirtualProject { pyproject_toml, None, options, - cache, ) .await?; @@ -1932,9 +1928,6 @@ impl VirtualProject { pyproject_toml, None, options, - // Avoid populating the shared cache with a synthetic workspace that - // `Workspace::discover` would reject as missing a `[project]` table. - &WorkspaceCache::default(), ) .await?; From 55e78724e5df920148f3061662df777bfb1a9234 Mon Sep 17 00:00:00 2001 From: konstin Date: Tue, 2 Jun 2026 17:14:24 +0200 Subject: [PATCH 10/20] Cache errors too --- .../src/metadata/dependency_groups.rs | 5 +- crates/uv-workspace/src/lib.rs | 2 +- crates/uv-workspace/src/workspace.rs | 268 ++++++++++-------- crates/uv/src/commands/project/format.rs | 17 +- crates/uv/src/commands/project/init.rs | 41 +-- crates/uv/src/commands/project/run.rs | 28 +- crates/uv/src/commands/project/version.rs | 4 +- crates/uv/src/commands/python/find.rs | 15 +- crates/uv/src/commands/venv.rs | 27 +- 9 files changed, 227 insertions(+), 180 deletions(-) diff --git a/crates/uv-distribution/src/metadata/dependency_groups.rs b/crates/uv-distribution/src/metadata/dependency_groups.rs index 081f6885a61e0..492041b11c0c4 100644 --- a/crates/uv-distribution/src/metadata/dependency_groups.rs +++ b/crates/uv-distribution/src/metadata/dependency_groups.rs @@ -9,6 +9,7 @@ use uv_workspace::dependency_groups::FlatDependencyGroups; use uv_workspace::pyproject::{Sources, ToolUvSources}; use uv_workspace::{ DiscoveryOptions, MemberDiscovery, VirtualProject, WorkspaceCache, WorkspaceError, + WorkspaceErrorKind, }; use crate::metadata::{GitWorkspaceMember, LoweredRequirement, MetadataError}; @@ -84,8 +85,8 @@ impl SourcedDependencyGroups { // The subsequent API takes an absolute path to the dir the pyproject is in let empty = PathBuf::new(); - let absolute_pyproject_path = - std::path::absolute(pyproject_path).map_err(WorkspaceError::Normalize)?; + let absolute_pyproject_path = std::path::absolute(pyproject_path) + .map_err(|err| WorkspaceError::from(WorkspaceErrorKind::Normalize(err)))?; let project_dir = absolute_pyproject_path.parent().unwrap_or(&empty); let project = VirtualProject::discover(project_dir, &discovery, cache).await?; diff --git a/crates/uv-workspace/src/lib.rs b/crates/uv-workspace/src/lib.rs index c15a8b0075814..1f4ddc658fd26 100644 --- a/crates/uv-workspace/src/lib.rs +++ b/crates/uv-workspace/src/lib.rs @@ -1,6 +1,6 @@ pub use workspace::{ DiscoveryOptions, Editability, MemberDiscovery, ProjectWorkspace, RequiresPythonSources, - VirtualProject, Workspace, WorkspaceCache, WorkspaceError, WorkspaceMember, + VirtualProject, Workspace, WorkspaceCache, WorkspaceError, WorkspaceErrorKind, WorkspaceMember, }; pub mod dependency_groups; diff --git a/crates/uv-workspace/src/workspace.rs b/crates/uv-workspace/src/workspace.rs index 807d0c38b9283..d4da7b5cb3fcb 100644 --- a/crates/uv-workspace/src/workspace.rs +++ b/crates/uv-workspace/src/workspace.rs @@ -1,6 +1,9 @@ //! Resolve the current [`ProjectWorkspace`] or [`Workspace`]. use std::collections::{BTreeMap, BTreeSet}; +use std::error::Error; +use std::fmt; +use std::fmt::Display; use std::hash::BuildHasherDefault; use std::path::{Path, PathBuf}; use std::sync::Arc; @@ -28,8 +31,7 @@ use crate::pyproject::{ type WorkspaceMembers = Arc>; type FxOnceMap = OnceMap>; -/// `None` means there was an error during discovery. -type CachedWorkspace = Option>; +type CachedWorkspaceResult = Result, WorkspaceError>; /// Cache for workspace discovery. /// @@ -44,35 +46,38 @@ type CachedWorkspace = Option>; /// * TODO(konsti): Support caching for [`MemberDiscovery`] modes that aren't `All`. #[derive(Debug, Default, Clone)] pub struct WorkspaceCache { - workspaces: Arc>, + workspaces: Arc>, } impl WorkspaceCache { - fn insert(&self, workspace: Arc) { - for package in workspace.packages.values() { - self.workspaces - .done(package.root.clone(), Some(workspace.clone())); + /// Insert a workspace discovery into the cache that may have succeeded or failed. + /// + /// Once an error is inserted, it will be returned to all future callers that query the failed + /// workspace root. + fn insert(&self, result: CachedWorkspaceResult, install_path: &Path) { + match result { + Ok(workspace) => { + for package in workspace.packages.values() { + self.workspaces + .done(package.root.clone(), Ok(workspace.clone())); + } + self.workspaces + .done(workspace.install_path.clone(), Ok(workspace)); + } + Err(err) => { + self.workspaces.done(install_path.to_path_buf(), Err(err)); + } } - self.workspaces - .done(workspace.install_path.clone(), Some(workspace)); - } - - /// Handle an error without leaving the map dangling. - fn insert_none(&self, install_path: PathBuf) { - self.workspaces.done(install_path, None); } /// Register workspace discovery for a root, or wait for an in-flight discovery. - async fn register_or_wait(&self, workspace_root: &PathBuf) -> Option> { - self.workspaces - .register_or_wait(workspace_root) - .await - .flatten() + async fn register_or_wait(&self, workspace_root: &PathBuf) -> Option { + self.workspaces.register_or_wait(workspace_root).await } /// Get the cached workspace, if any, from the path to the workspace root or to a member root. - fn get(&self, path: &Path) -> Option> { - self.workspaces.get(path).flatten() + fn get(&self, path: &Path) -> Option { + self.workspaces.get(path) } /// Remove all cached workspace entries for the given workspace root. Used before modifying the @@ -81,7 +86,7 @@ impl WorkspaceCache { /// Contract: There are no parallel workspace operations, this is the only thread operating on /// workspaces. pub fn invalidate_workspace(&self, workspace: &Workspace) { - if let Some(Some(workspace)) = self.workspaces.remove(workspace.install_path()) { + if let Some(Ok(workspace)) = self.workspaces.remove(workspace.install_path()) { for member in workspace.packages.values() { self.workspaces.remove(&member.root); } @@ -89,8 +94,38 @@ impl WorkspaceCache { } } +#[derive(Debug, Clone)] +pub struct WorkspaceError(Arc); + +impl AsRef for WorkspaceError { + fn as_ref(&self) -> &WorkspaceErrorKind { + &self.0 + } +} + +impl From for WorkspaceError +where + T: Into, +{ + fn from(error: T) -> Self { + Self(Arc::new(error.into())) + } +} + +impl Display for WorkspaceError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + Display::fmt(&self.0, f) + } +} + +impl Error for WorkspaceError { + fn source(&self) -> Option<&(dyn Error + 'static)> { + self.0.source() + } +} + #[derive(thiserror::Error, Debug)] -pub enum WorkspaceError { +pub enum WorkspaceErrorKind { // Workspace structure errors. #[error("No `pyproject.toml` found in current directory or any parent directory")] MissingPyprojectToml, @@ -215,14 +250,14 @@ impl Workspace { cache: &WorkspaceCache, ) -> Result, WorkspaceError> { let path = std::path::absolute(path) - .map_err(WorkspaceError::Normalize)? + .map_err(WorkspaceErrorKind::Normalize)? .clone(); let path = normalize_path(&path); let project_path = path .ancestors() .find(|path| path.join("pyproject.toml").is_file()) - .ok_or(WorkspaceError::MissingPyprojectToml)? + .ok_or(WorkspaceErrorKind::MissingPyprojectToml)? .to_path_buf(); // Fast path: The workspace was already fully discovered. @@ -233,13 +268,13 @@ impl Workspace { if options.members == MemberDiscovery::All && let Some(workspace) = cache.get(&project_path) { - return Ok(workspace); + return workspace; } 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, &pyproject_path) - .map_err(|err| WorkspaceError::Toml(pyproject_path.clone(), Box::new(err)))?; + .map_err(|err| WorkspaceErrorKind::Toml(pyproject_path.clone(), Box::new(err)))?; // Check if the project is explicitly marked as unmanaged. if pyproject_toml @@ -253,7 +288,9 @@ impl Workspace { "Project `{}` is marked as unmanaged", project_path.simplified_display() ); - return Err(WorkspaceError::NonWorkspace(project_path)); + return Err(WorkspaceError::from(WorkspaceErrorKind::NonWorkspace( + project_path, + ))); } // Check if the current project is also an explicit workspace root. @@ -276,7 +313,9 @@ impl Workspace { workspace } else if pyproject_toml.project.is_none() { // Without a project, it can't be an implicit root - return Err(WorkspaceError::MissingProject(pyproject_path)); + return Err(WorkspaceError::from(WorkspaceErrorKind::MissingProject( + pyproject_path, + ))); } else if let Some(workspace) = find_workspace(&project_path, options).await? { // We have found an explicit root above. workspace @@ -296,7 +335,7 @@ impl Workspace { // here. At this point, only one thread can continue and the other waits, then uses the // cached workspace. if let Some(workspace) = cache.register_or_wait(&workspace_root).await { - return Ok(workspace); + return workspace; } } @@ -316,28 +355,16 @@ impl Workspace { pyproject_toml, }); - match Self::build( + let result = Self::build( workspace_root.clone(), workspace_definition, workspace_pyproject_toml, current_project, options, ) - .await - { - Ok(workspace) => { - if options.members == MemberDiscovery::All { - cache.insert(workspace.clone()); - } - Ok(workspace) - } - Err(error) => { - if options.members == MemberDiscovery::All { - cache.insert_none(workspace_root); - } - Err(error) - } - } + .await; + cache.insert(result.clone(), &workspace_root); + result } /// Set the current project to the given workspace member. @@ -528,7 +555,9 @@ impl Workspace { if let Some(editable) = editable { // If there are conflicting `editable` values, raise an error. if existing != *editable { - return Err(WorkspaceError::EditableConflict(package.clone())); + return Err(WorkspaceError::from( + WorkspaceErrorKind::EditableConflict(package.clone()), + )); } } } @@ -651,7 +680,7 @@ impl Workspace { // // Arguably we could check groups.prod() to disable this, since, the requires-python // of the project is *technically* not relevant if you're doing `--only-group`, but, - // that would be a big surprising change so let's *not* do that until someone asks! + // that would be a big surprising change, so let's *not* do that until someone asks! let top_requires = member .pyproject_toml() .project @@ -1042,15 +1071,15 @@ impl Workspace { .to_string_lossy() .to_string(); for member_root in glob(&absolute_glob) - .map_err(|err| WorkspaceError::Pattern(absolute_glob.clone(), err))? + .map_err(|err| WorkspaceErrorKind::Pattern(absolute_glob.clone(), err))? { let member_root = member_root - .map_err(|err| WorkspaceError::GlobWalk(absolute_glob.clone(), err))?; + .map_err(|err| WorkspaceErrorKind::GlobWalk(absolute_glob.clone(), err))?; if !seen.insert(member_root.clone()) { continue; } let member_root = std::path::absolute(&member_root) - .map_err(WorkspaceError::Normalize)? + .map_err(WorkspaceErrorKind::Normalize)? .clone(); // If the directory is explicitly ignored, skip it. @@ -1141,9 +1170,11 @@ impl Workspace { continue; } - return Err(WorkspaceError::MissingPyprojectTomlMember( - member_root, - member_glob.to_string(), + return Err(WorkspaceError::from( + WorkspaceErrorKind::MissingPyprojectTomlMember( + member_root, + member_glob.to_string(), + ), )); } @@ -1151,7 +1182,9 @@ impl Workspace { } }; let pyproject_toml = PyProjectToml::from_string(contents, &pyproject_path) - .map_err(|err| WorkspaceError::Toml(pyproject_path.clone(), Box::new(err)))?; + .map_err(|err| { + WorkspaceErrorKind::Toml(pyproject_path.clone(), Box::new(err)) + })?; // Check if the current project is explicitly marked as unmanaged. if pyproject_toml @@ -1177,7 +1210,9 @@ impl Workspace { // Extract the package name. let Some(project) = pyproject_toml.project.clone() else { - return Err(WorkspaceError::MissingProject(pyproject_path)); + return Err(WorkspaceError::from(WorkspaceErrorKind::MissingProject( + pyproject_path, + ))); }; debug!( @@ -1193,11 +1228,11 @@ impl Workspace { pyproject_toml, }, ) { - return Err(WorkspaceError::DuplicatePackage { + return Err(WorkspaceError::from(WorkspaceErrorKind::DuplicatePackage { name: existing.project.name, first: existing.root.clone(), second: member_root, - }); + })); } } } @@ -1213,7 +1248,9 @@ impl Workspace { .and_then(|uv| uv.workspace.as_ref()) .is_some() { - return Err(WorkspaceError::NestedWorkspace(member.root.clone())); + return Err(WorkspaceError::from(WorkspaceErrorKind::NestedWorkspace( + member.root.clone(), + ))); } } Ok(workspace_members) @@ -1344,21 +1381,28 @@ impl ProjectWorkspace { project_root: &Path, options: &DiscoveryOptions, cache: &WorkspaceCache, - ) -> Option { + ) -> Result, WorkspaceError> { if options.members != MemberDiscovery::All { - return None; + return Ok(None); } - let workspace = cache.get(project_root)?; - let (project_name, _member) = workspace + let workspace = match cache.get(project_root) { + Some(Ok(workspace)) => workspace, + Some(Err(error)) => return Err(error), + None => return Ok(None), + }; + let Some((project_name, _member)) = workspace .packages .iter() - .find(|(_project_name, member)| member.root() == project_root)?; + .find(|(_project_name, member)| member.root() == project_root) + else { + return Ok(None); + }; - Some(Self { + Ok(Some(Self { project_root: project_root.to_path_buf(), project_name: project_name.clone(), workspace, - }) + })) } /// Find the current project and workspace, given the current directory. @@ -1386,7 +1430,7 @@ impl ProjectWorkspace { .unwrap_or(true) }) .find(|path| path.join("pyproject.toml").is_file()) - .ok_or(WorkspaceError::MissingPyprojectToml)?; + .ok_or_else(|| WorkspaceErrorKind::MissingPyprojectToml)?; debug!( "Found project root: `{}`", @@ -1402,7 +1446,7 @@ impl ProjectWorkspace { options: &DiscoveryOptions, cache: &WorkspaceCache, ) -> Result { - if let Some(project) = Self::from_cache(project_root, options, cache) { + if let Some(project) = Self::from_cache(project_root, options, cache)? { return Ok(project); } @@ -1411,13 +1455,13 @@ impl ProjectWorkspace { let contents = fs_err::tokio::read_to_string(&pyproject_path).await?; let pyproject_toml = PyProjectToml::from_string(contents, &pyproject_path) - .map_err(|err| WorkspaceError::Toml(pyproject_path.clone(), Box::new(err)))?; + .map_err(|err| WorkspaceErrorKind::Toml(pyproject_path.clone(), Box::new(err)))?; // It must have a `[project]` table. let project = pyproject_toml .project .clone() - .ok_or(WorkspaceError::MissingProject(pyproject_path))?; + .ok_or_else(|| WorkspaceErrorKind::MissingProject(pyproject_path))?; Self::from_project(project_root, &project, &pyproject_toml, options, cache).await } @@ -1429,7 +1473,7 @@ impl ProjectWorkspace { options: &DiscoveryOptions, cache: &WorkspaceCache, ) -> Result, WorkspaceError> { - if let Some(project) = Self::from_cache(project_root, options, cache) { + if let Some(project) = Self::from_cache(project_root, options, cache)? { return Ok(Some(project)); } @@ -1440,7 +1484,7 @@ impl ProjectWorkspace { return Ok(None); }; let pyproject_toml = PyProjectToml::from_string(contents, &pyproject_path) - .map_err(|err| WorkspaceError::Toml(pyproject_path.clone(), Box::new(err)))?; + .map_err(|err| WorkspaceErrorKind::Toml(pyproject_path.clone(), Box::new(err)))?; // Extract the `[project]` metadata. let Some(project) = pyproject_toml.project.clone() else { @@ -1450,7 +1494,7 @@ impl ProjectWorkspace { match Self::from_project(project_root, &project, &pyproject_toml, options, cache).await { Ok(workspace) => Ok(Some(workspace)), - Err(WorkspaceError::NonWorkspace(_)) => Ok(None), + Err(error) if matches!(error.as_ref(), WorkspaceErrorKind::NonWorkspace(_)) => Ok(None), Err(err) => Err(err), } } @@ -1497,7 +1541,7 @@ impl ProjectWorkspace { cache: &WorkspaceCache, ) -> Result { let project_path = std::path::absolute(install_path) - .map_err(WorkspaceError::Normalize)? + .map_err(WorkspaceErrorKind::Normalize)? .clone(); let project_path = normalize_path(&project_path); @@ -1510,10 +1554,12 @@ impl ProjectWorkspace { == Some(false) { debug!("Project `{}` is marked as unmanaged", project.name); - return Err(WorkspaceError::NonWorkspace(project_path.to_path_buf())); + return Err(WorkspaceError::from(WorkspaceErrorKind::NonWorkspace( + project_path.to_path_buf(), + ))); } - if let Some(project) = Self::from_cache(&project_path, options, cache) { + if let Some(project) = Self::from_cache(&project_path, options, cache)? { return Ok(project); } @@ -1572,7 +1618,7 @@ impl ProjectWorkspace { }; let workspace = Arc::new(workspace); if options.members == MemberDiscovery::All { - cache.insert(workspace.clone()); + cache.insert(Ok(workspace.clone()), &project_path); } return Ok(Self { project_root: project_path.to_path_buf(), @@ -1584,7 +1630,7 @@ impl ProjectWorkspace { if options.members == MemberDiscovery::All { // Ensure that workspace discovery runs only once for any given workspace root. if let Some(workspace) = cache.register_or_wait(&workspace_root).await { - return Ok(Self { + return workspace.map(|workspace| Self { project_root: project_path.to_path_buf(), project_name: project.name.clone(), workspace, @@ -1597,33 +1643,20 @@ impl ProjectWorkspace { workspace_root.simplified_display() ); - let workspace = match Workspace::build( + let result = Workspace::build( workspace_root.clone(), workspace_definition, workspace_pyproject_toml, Some(current_project), options, ) - .await - { - Ok(workspace) => { - if options.members == MemberDiscovery::All { - cache.insert(workspace.clone()); - } - workspace - } - Err(error) => { - if options.members == MemberDiscovery::All { - cache.insert_none(workspace_root); - } - return Err(error); - } - }; + .await; + cache.insert(result.clone(), &workspace_root); Ok(Self { project_root: project_path.to_path_buf(), project_name: project.name.clone(), - workspace, + workspace: result?, }) } } @@ -1659,7 +1692,7 @@ async fn find_workspace( // Read the `pyproject.toml`. let contents = fs_err::tokio::read_to_string(&pyproject_path).await?; let pyproject_toml = PyProjectToml::from_string(contents, &pyproject_path) - .map_err(|err| WorkspaceError::Toml(pyproject_path.clone(), Box::new(err)))?; + .map_err(|err| WorkspaceErrorKind::Toml(pyproject_path.clone(), Box::new(err)))?; return if let Some(workspace) = pyproject_toml .tool @@ -1773,7 +1806,7 @@ fn is_excluded_from_workspace( .join(normalized_glob.as_ref()); let absolute_glob = absolute_glob.to_string_lossy(); let exclude_pattern = glob::Pattern::new(&absolute_glob) - .map_err(|err| WorkspaceError::Pattern(absolute_glob.to_string(), err))?; + .map_err(|err| WorkspaceErrorKind::Pattern(absolute_glob.to_string(), err))?; if exclude_pattern.matches_path(project_path) { return Ok(true); } @@ -1796,7 +1829,7 @@ fn is_included_in_workspace( .join(normalized_glob); let absolute_glob = absolute_glob.to_string_lossy(); let include_pattern = glob::Pattern::new(&absolute_glob) - .map_err(|err| WorkspaceError::Pattern(absolute_glob.to_string(), err))?; + .map_err(|err| WorkspaceErrorKind::Pattern(absolute_glob.to_string(), err))?; if include_pattern.matches_path(project_path) { return Ok(true); } @@ -1845,7 +1878,7 @@ impl VirtualProject { .unwrap_or(true) }) .find(|path| path.join("pyproject.toml").is_file()) - .ok_or(WorkspaceError::MissingPyprojectToml)?; + .ok_or(WorkspaceErrorKind::MissingPyprojectToml)?; debug!( "Found project root: `{}`", @@ -1856,6 +1889,7 @@ impl VirtualProject { if options.members == MemberDiscovery::All && let Some(workspace) = cache.get(project_root) { + let workspace = workspace?; let virtual_project = if let Some((project_name, _member)) = workspace .packages .iter() @@ -1876,7 +1910,7 @@ impl VirtualProject { let pyproject_path = project_root.join("pyproject.toml"); let contents = fs_err::tokio::read_to_string(&pyproject_path).await?; let pyproject_toml = PyProjectToml::from_string(contents, &pyproject_path) - .map_err(|err| WorkspaceError::Toml(pyproject_path.clone(), Box::new(err)))?; + .map_err(|err| WorkspaceErrorKind::Toml(pyproject_path.clone(), Box::new(err)))?; if let Some(project) = pyproject_toml.project.as_ref() { // If the `pyproject.toml` contains a `[project]` table, it's a project. @@ -1898,44 +1932,36 @@ impl VirtualProject { // Otherwise, if it contains a `tool.uv.workspace` table, it's a non-project workspace // root. let project_path = std::path::absolute(project_root) - .map_err(WorkspaceError::Normalize)? + .map_err(WorkspaceErrorKind::Normalize)? .clone(); - let workspace = Workspace::build( + let result = Workspace::build( project_path.clone(), workspace.clone(), pyproject_toml, None, options, ) - .await?; - - if options.members == MemberDiscovery::All { - cache.insert(workspace.clone()); - } - - Ok(Self::NonProject(workspace)) + .await; + cache.insert(result.clone(), &project_path); + Ok(Self::NonProject(result?)) } else { // Otherwise it's a pyproject.toml that maybe contains dependency-groups // that we want to treat like a project/workspace to handle those uniformly let project_path = std::path::absolute(project_root) - .map_err(WorkspaceError::Normalize)? + .map_err(WorkspaceErrorKind::Normalize)? .clone(); - let workspace = Workspace::build( - project_path, + let result = Workspace::build( + project_path.clone(), ToolUvWorkspace::default(), pyproject_toml, None, options, ) - .await?; - - if options.members == MemberDiscovery::All { - cache.insert(workspace.clone()); - } - - Ok(Self::NonProject(workspace)) + .await; + cache.insert(result.clone(), &project_path); + Ok(Self::NonProject(result?)) } } @@ -1950,10 +1976,10 @@ impl VirtualProject { let Some(project_workspace) = Workspace::with_current_project(workspace.clone(), package.clone()) else { - return Err(WorkspaceError::NoSuchMember( + return Err(WorkspaceError::from(WorkspaceErrorKind::NoSuchMember( package, workspace.install_path.clone(), - )); + ))); }; Ok(Self::Project(project_workspace)) } diff --git a/crates/uv/src/commands/project/format.rs b/crates/uv/src/commands/project/format.rs index 4f901894b0edd..e4624d2779ead 100644 --- a/crates/uv/src/commands/project/format.rs +++ b/crates/uv/src/commands/project/format.rs @@ -11,7 +11,7 @@ use uv_cache::Cache; use uv_client::BaseClientBuilder; use uv_preview::{Preview, PreviewFeature}; use uv_warnings::warn_user; -use uv_workspace::{DiscoveryOptions, VirtualProject, WorkspaceCache, WorkspaceError}; +use uv_workspace::{DiscoveryOptions, VirtualProject, WorkspaceCache, WorkspaceErrorKind}; use crate::child::run_to_completion; use crate::commands::ExitStatus; @@ -55,11 +55,16 @@ pub(crate) async fn format( Ok(proj) => proj.root().to_owned(), // If there is a problem finding a project, we just use the provided directory, // e.g., for unmanaged projects - Err( - WorkspaceError::MissingPyprojectToml - | WorkspaceError::MissingProject(_) - | WorkspaceError::NonWorkspace(_), - ) => project_dir.to_owned(), + Err(err) + if matches!( + err.as_ref(), + WorkspaceErrorKind::MissingPyprojectToml + | WorkspaceErrorKind::MissingProject(_) + | WorkspaceErrorKind::NonWorkspace(_) + ) => + { + project_dir.to_owned() + } Err(err) => return Err(err.into()), } }; diff --git a/crates/uv/src/commands/project/init.rs b/crates/uv/src/commands/project/init.rs index d79ffa4dd3f40..58711f8ce9126 100644 --- a/crates/uv/src/commands/project/init.rs +++ b/crates/uv/src/commands/project/init.rs @@ -30,7 +30,9 @@ use uv_settings::PythonInstallMirrors; use uv_static::EnvVars; use uv_warnings::warn_user_once; use uv_workspace::pyproject_mut::{DependencyTarget, PyProjectTomlMut}; -use uv_workspace::{DiscoveryOptions, MemberDiscovery, Workspace, WorkspaceCache, WorkspaceError}; +use uv_workspace::{ + DiscoveryOptions, MemberDiscovery, Workspace, WorkspaceCache, WorkspaceErrorKind, +}; use crate::commands::ExitStatus; use crate::commands::project::{find_requires_python, init_script_python_requirement}; @@ -327,7 +329,7 @@ async fn init_project( .await { Ok(workspace) => { - // Ignore the current workspace, if `--no-workspace` was provided. + // Ignore the current workspace if `--no-workspace` was provided. if no_workspace { debug!("Ignoring discovered workspace due to `--no-workspace`"); None @@ -335,25 +337,28 @@ async fn init_project( Some(workspace) } } - Err(WorkspaceError::MissingPyprojectToml | WorkspaceError::NonWorkspace(_)) => { - // If the user runs with `--no-workspace` and we can't find a workspace, warn. - if no_workspace { - warn!("`--no-workspace` was provided, but no workspace was found"); - } - None - } Err(err) => { - // If the user runs with `--no-workspace`, ignore the error. - if no_workspace { - warn!("Ignoring workspace discovery error due to `--no-workspace`: {err}"); + if matches!( + err.as_ref(), + WorkspaceErrorKind::MissingPyprojectToml | WorkspaceErrorKind::NonWorkspace(_) + ) { + if no_workspace { + warn!("`--no-workspace` was provided, but no workspace was found"); + } None } else { - return Err(err).with_context(|| { - format!( - "Failed to discover parent workspace; use `{}` to ignore", - "uv init --no-workspace".green() - ) - }); + // If the user runs with `--no-workspace`, ignore the error. + if no_workspace { + warn!("Ignoring workspace discovery error due to `--no-workspace`: {err}"); + None + } else { + return Err(err).with_context(|| { + format!( + "Failed to discover parent workspace; use `{}` to ignore", + "uv init --no-workspace".green() + ) + }); + } } } } diff --git a/crates/uv/src/commands/project/run.rs b/crates/uv/src/commands/project/run.rs index 7e99fe6f78ac1..374a7eac80cc0 100644 --- a/crates/uv/src/commands/project/run.rs +++ b/crates/uv/src/commands/project/run.rs @@ -45,7 +45,7 @@ use uv_shell::WindowsRunnable; use uv_static::EnvVars; use uv_types::SourceTreeEditablePolicy; use uv_warnings::warn_user; -use uv_workspace::{DiscoveryOptions, VirtualProject, WorkspaceCache, WorkspaceError}; +use uv_workspace::{DiscoveryOptions, VirtualProject, WorkspaceCache, WorkspaceErrorKind}; use crate::child::run_to_completion; @@ -561,20 +561,24 @@ pub(crate) async fn run( Some(project) } } - Err(WorkspaceError::MissingPyprojectToml | WorkspaceError::NonWorkspace(_)) => { - // If the user runs with `--no-project` and we can't find a project, warn. - if no_project { - warn!("`--no-project` was provided, but no project was found"); - } - None - } Err(err) => { - // If the user runs with `--no-project`, ignore the error. - if no_project { - warn!("Ignoring project discovery error due to `--no-project`: {err}"); + if matches!( + err.as_ref(), + WorkspaceErrorKind::MissingPyprojectToml + | WorkspaceErrorKind::NonWorkspace(_) + ) { + if no_project { + warn!("`--no-project` was provided, but no project was found"); + } None } else { - return Err(err.into()); + // If the user runs with `--no-project`, ignore the error. + if no_project { + warn!("Ignoring project discovery error due to `--no-project`: {err}"); + None + } else { + return Err(err.into()); + } } } } diff --git a/crates/uv/src/commands/project/version.rs b/crates/uv/src/commands/project/version.rs index 57ae33d3058a5..73118c810fb25 100644 --- a/crates/uv/src/commands/project/version.rs +++ b/crates/uv/src/commands/project/version.rs @@ -26,7 +26,7 @@ use uv_workspace::VirtualProject; use uv_workspace::pyproject::PyProjectToml; use uv_workspace::pyproject_mut::Error; use uv_workspace::{ - DiscoveryOptions, WorkspaceCache, WorkspaceError, + DiscoveryOptions, WorkspaceCache, WorkspaceError, WorkspaceErrorKind, pyproject_mut::{DependencyTarget, PyProjectTomlMut}, }; @@ -398,7 +398,7 @@ impl uv_errors::Hint for MissingProjectVersionError { /// Add hint to use `uv self version` when workspace discovery fails due to missing pyproject.toml /// and --project was not explicitly passed fn hint_uv_self_version(err: WorkspaceError, explicit_project: bool) -> anyhow::Error { - if matches!(err, WorkspaceError::MissingPyprojectToml) && !explicit_project { + if matches!(err.as_ref(), WorkspaceErrorKind::MissingPyprojectToml) && !explicit_project { MissingProjectVersionError { err }.into() } else { err.into() diff --git a/crates/uv/src/commands/python/find.rs b/crates/uv/src/commands/python/find.rs index 36f44248c59b0..04611176b14ee 100644 --- a/crates/uv/src/commands/python/find.rs +++ b/crates/uv/src/commands/python/find.rs @@ -13,7 +13,7 @@ use uv_python::{ use uv_scripts::Pep723ItemRef; use uv_settings::PythonInstallMirrors; use uv_warnings::{warn_user, warn_user_once}; -use uv_workspace::{DiscoveryOptions, VirtualProject, WorkspaceCache, WorkspaceError}; +use uv_workspace::{DiscoveryOptions, VirtualProject, WorkspaceCache, WorkspaceErrorKind}; use crate::commands::{ ExitStatus, @@ -51,11 +51,16 @@ pub(crate) async fn find( .await { Ok(project) => Some(project), - Err(WorkspaceError::MissingProject(_)) => None, - Err(WorkspaceError::MissingPyprojectToml) => None, - Err(WorkspaceError::NonWorkspace(_)) => None, Err(err) => { - warn_user_once!("{err}"); + // Ignore missing or unmanaged workspaces in Python discovery. + if !matches!( + err.as_ref(), + WorkspaceErrorKind::MissingProject(_) + | WorkspaceErrorKind::MissingPyprojectToml + | WorkspaceErrorKind::NonWorkspace(_) + ) { + warn_user_once!("{err}"); + } None } } diff --git a/crates/uv/src/commands/venv.rs b/crates/uv/src/commands/venv.rs index d4d031b1cb4f5..dfd4b7dd7e224 100644 --- a/crates/uv/src/commands/venv.rs +++ b/crates/uv/src/commands/venv.rs @@ -33,7 +33,7 @@ use uv_types::{ }; use uv_virtualenv::OnExisting; use uv_warnings::warn_user; -use uv_workspace::{DiscoveryOptions, VirtualProject, WorkspaceCache, WorkspaceError}; +use uv_workspace::{DiscoveryOptions, VirtualProject, WorkspaceCache, WorkspaceErrorKind}; use crate::commands::ExitStatus; use crate::commands::pip::loggers::{DefaultInstallLogger, InstallLogger}; @@ -95,19 +95,20 @@ pub(crate) async fn venv( .await { Ok(project) => Some(project), - Err(WorkspaceError::MissingProject(_)) => None, - Err(WorkspaceError::MissingPyprojectToml) => None, - Err(WorkspaceError::NonWorkspace(_)) => None, - Err(WorkspaceError::Toml(path, err)) => { - warn_user!( - "Failed to parse `{}` during environment creation:\n{}", - path.user_display().cyan(), - textwrap::indent(&err.to_string(), " ") - ); - None - } Err(err) => { - warn_user!("{err}"); + match err.as_ref() { + WorkspaceErrorKind::MissingProject(_) + | WorkspaceErrorKind::MissingPyprojectToml + | WorkspaceErrorKind::NonWorkspace(_) => {} + WorkspaceErrorKind::Toml(path, err) => { + warn_user!( + "Failed to parse `{}` during environment creation:\n{}", + path.user_display().cyan(), + textwrap::indent(&err.to_string(), " ") + ); + } + _ => warn_user!("{err}"), + } None } } From 2178de6cff303b50ad437774d34a166df86e825e Mon Sep 17 00:00:00 2001 From: konstin Date: Tue, 2 Jun 2026 17:56:48 +0200 Subject: [PATCH 11/20] Fix missing guard --- crates/uv-workspace/src/workspace.rs | 74 +++++++++++++++++++++++++--- 1 file changed, 68 insertions(+), 6 deletions(-) diff --git a/crates/uv-workspace/src/workspace.rs b/crates/uv-workspace/src/workspace.rs index d4da7b5cb3fcb..512839cf10225 100644 --- a/crates/uv-workspace/src/workspace.rs +++ b/crates/uv-workspace/src/workspace.rs @@ -363,7 +363,9 @@ impl Workspace { options, ) .await; - cache.insert(result.clone(), &workspace_root); + if options.members == MemberDiscovery::All { + cache.insert(result.clone(), &workspace_root); + } result } @@ -1651,7 +1653,9 @@ impl ProjectWorkspace { options, ) .await; - cache.insert(result.clone(), &workspace_root); + if options.members == MemberDiscovery::All { + cache.insert(result.clone(), &workspace_root); + } Ok(Self { project_root: project_path.to_path_buf(), @@ -1943,7 +1947,9 @@ impl VirtualProject { options, ) .await; - cache.insert(result.clone(), &project_path); + if options.members == MemberDiscovery::All { + cache.insert(result.clone(), &project_path); + } Ok(Self::NonProject(result?)) } else { // Otherwise it's a pyproject.toml that maybe contains dependency-groups @@ -1960,7 +1966,9 @@ impl VirtualProject { options, ) .await; - cache.insert(result.clone(), &project_path); + if options.members == MemberDiscovery::All { + cache.insert(result.clone(), &project_path); + } Ok(Self::NonProject(result?)) } } @@ -2092,11 +2100,11 @@ mod tests { use assert_fs::prelude::*; use insta::{assert_json_snapshot, assert_snapshot}; - use uv_normalize::GroupName; + use uv_normalize::{GroupName, PackageName}; use uv_pypi_types::DependencyGroupSpecifier; use crate::pyproject::PyProjectToml; - use crate::workspace::{DiscoveryOptions, ProjectWorkspace, Workspace}; + use crate::workspace::{DiscoveryOptions, MemberDiscovery, ProjectWorkspace, Workspace}; use crate::{WorkspaceCache, WorkspaceError}; async fn workspace_test(folder: &str) -> (ProjectWorkspace, String) { @@ -2526,6 +2534,60 @@ mod tests { Ok(()) } + #[tokio::test] + async fn workspace_cache_does_not_store_partial_discovery() -> Result<()> { + let root = tempfile::TempDir::new()?; + let root = ChildPath::new(root.path()); + + root.child("pyproject.toml").write_str( + r#" + [project] + name = "albatross" + version = "0.1.0" + requires-python = ">=3.12" + + [tool.uv.workspace] + members = ["packages/*"] + "#, + )?; + + root.child("packages") + .child("seeds") + .child("pyproject.toml") + .write_str( + r#" + [project] + name = "seeds" + version = "1.0.0" + requires-python = ">=3.12" + "#, + )?; + + let cache = WorkspaceCache::default(); + let partial_options = DiscoveryOptions { + members: MemberDiscovery::None, + ..DiscoveryOptions::default() + }; + let partial_project = + ProjectWorkspace::discover(root.as_ref(), &partial_options, &cache).await?; + + assert_eq!(partial_project.workspace().packages().len(), 1); + + let member_project = ProjectWorkspace::discover( + root.child("packages").child("seeds").as_ref(), + &DiscoveryOptions::default(), + &cache, + ) + .await?; + let seeds = PackageName::from_str("seeds")?; + + assert_eq!(member_project.project_name(), &seeds); + assert_eq!(member_project.workspace().packages().len(), 2); + assert!(member_project.workspace().packages().contains_key(&seeds)); + + Ok(()) + } + #[tokio::test] async fn albatross_just_project() { let (project, root_escaped) = workspace_test("albatross-just-project").await; From 7decaad43421b0efd0fdfe0fba31801cbe6f8e81 Mon Sep 17 00:00:00 2001 From: konstin Date: Tue, 2 Jun 2026 18:08:18 +0200 Subject: [PATCH 12/20] Rebase fixes --- crates/uv/src/commands/project/check.rs | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/crates/uv/src/commands/project/check.rs b/crates/uv/src/commands/project/check.rs index 66b770e1fc388..fa0451ccdf549 100644 --- a/crates/uv/src/commands/project/check.rs +++ b/crates/uv/src/commands/project/check.rs @@ -16,7 +16,9 @@ use uv_python::{ }; use uv_settings::{MalwareCheckSettings, PythonInstallMirrors}; use uv_warnings::warn_user; -use uv_workspace::{DiscoveryOptions, VirtualProject, WorkspaceCache, WorkspaceError}; +use uv_workspace::{ + DiscoveryOptions, VirtualProject, WorkspaceCache, WorkspaceError, WorkspaceErrorKind, +}; use crate::commands::pip::loggers::{SummaryInstallLogger, SummaryResolveLogger}; use crate::commands::pip::operations::Modifications; @@ -75,12 +77,18 @@ pub(crate) async fn check( .await { Ok(project) => Some(project), - Err( - WorkspaceError::MissingPyprojectToml - | WorkspaceError::MissingProject(_) - | WorkspaceError::NonWorkspace(_), - ) => None, - Err(err) => return Err(err.into()), + Err(err) => { + if matches!( + err.as_ref(), + WorkspaceErrorKind::MissingPyprojectToml + | WorkspaceErrorKind::MissingProject(_) + | WorkspaceErrorKind::NonWorkspace(_), + ) { + None + } else { + return Err(err.into()); + } + } } }; From bd6958edd88368f152b8c78456b46a4b4aed490e Mon Sep 17 00:00:00 2001 From: konstin Date: Tue, 2 Jun 2026 18:08:48 +0200 Subject: [PATCH 13/20] Clippy --- crates/uv/src/commands/project/check.rs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/crates/uv/src/commands/project/check.rs b/crates/uv/src/commands/project/check.rs index fa0451ccdf549..67b5172210a49 100644 --- a/crates/uv/src/commands/project/check.rs +++ b/crates/uv/src/commands/project/check.rs @@ -16,9 +16,7 @@ use uv_python::{ }; use uv_settings::{MalwareCheckSettings, PythonInstallMirrors}; use uv_warnings::warn_user; -use uv_workspace::{ - DiscoveryOptions, VirtualProject, WorkspaceCache, WorkspaceError, WorkspaceErrorKind, -}; +use uv_workspace::{DiscoveryOptions, VirtualProject, WorkspaceCache, WorkspaceErrorKind}; use crate::commands::pip::loggers::{SummaryInstallLogger, SummaryResolveLogger}; use crate::commands::pip::operations::Modifications; From 7c3a26872d781061e4a9aaddd7e648b85c1e6e37 Mon Sep 17 00:00:00 2001 From: konstin Date: Tue, 2 Jun 2026 18:30:02 +0200 Subject: [PATCH 14/20] Fix edge case --- crates/uv/src/commands/project/version.rs | 32 +++++++++++------------ crates/uv/tests/it/version.rs | 27 +++++++++++++++++++ 2 files changed, 42 insertions(+), 17 deletions(-) diff --git a/crates/uv/src/commands/project/version.rs b/crates/uv/src/commands/project/version.rs index 73118c810fb25..260ef675a72bb 100644 --- a/crates/uv/src/commands/project/version.rs +++ b/crates/uv/src/commands/project/version.rs @@ -2,7 +2,7 @@ use std::fmt::Write; use std::path::Path; use std::str::FromStr; -use anyhow::{Result, anyhow, bail}; +use anyhow::{Result, anyhow}; use owo_colors::OwoColorize; use thiserror::Error; @@ -22,11 +22,11 @@ use uv_pep440::{BumpCommand, PrereleaseKind, Version}; use uv_preview::Preview; use uv_python::{PythonDownloads, PythonPreference, PythonRequest}; use uv_settings::{MalwareCheckSettings, PythonInstallMirrors}; -use uv_workspace::VirtualProject; use uv_workspace::pyproject::PyProjectToml; use uv_workspace::pyproject_mut::Error; use uv_workspace::{ - DiscoveryOptions, WorkspaceCache, WorkspaceError, WorkspaceErrorKind, + DiscoveryOptions, ProjectWorkspace, VirtualProject, WorkspaceCache, WorkspaceError, + WorkspaceErrorKind, pyproject_mut::{DependencyTarget, PyProjectTomlMut}, }; @@ -425,20 +425,18 @@ async fn find_target( .await .map_err(|err| hint_uv_self_version(err, explicit_project))? } else { - let project = - VirtualProject::discover(project_dir, &DiscoveryOptions::default(), workspace_cache) - .await - .map_err(|err| hint_uv_self_version(err, explicit_project))?; - match &project { - VirtualProject::Project(_) => project, - VirtualProject::NonProject(workspace) => bail!( - "No `project` table found in: {}", - workspace - .install_path() - .join("pyproject.toml") - .simplified_display() - ), - } + // Configuration discovery may have cached errors from virtual workspace member discovery. + // `uv version` requires a project, so reject non-project roots before consulting that cache. + let project_workspace_cache = WorkspaceCache::default(); + VirtualProject::Project( + ProjectWorkspace::discover( + project_dir, + &DiscoveryOptions::default(), + &project_workspace_cache, + ) + .await + .map_err(|err| hint_uv_self_version(err, explicit_project))?, + ) }; Ok(project) } diff --git a/crates/uv/tests/it/version.rs b/crates/uv/tests/it/version.rs index f2349229832ac..fc7da5b54c012 100644 --- a/crates/uv/tests/it/version.rs +++ b/crates/uv/tests/it/version.rs @@ -2544,6 +2544,33 @@ fn version_get_workspace() -> Result<()> { Ok(()) } +/// Ensure that virtual workspace roots are rejected before discovering their members. +#[test] +fn version_virtual_workspace_root_rejects_before_members() -> Result<()> { + let context = uv_test::test_context!("3.12"); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str(indoc! {r#" + [tool.uv.workspace] + members = ["workspace-member"] + "#})?; + + let workspace_member = context.temp_dir.child("workspace-member"); + workspace_member.create_dir_all()?; + workspace_member.child("README.md").touch()?; + + uv_snapshot!(context.filters(), context.version(), @" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: No `project` table found in: [TEMP_DIR]/pyproject.toml + "); + + Ok(()) +} + /// Edit the version of a workspace member /// /// Also check that --locked/--frozen/--no-sync do what they say From 1c53ba9b16f130b2a139409afc76a103200e8272 Mon Sep 17 00:00:00 2001 From: konstin Date: Wed, 3 Jun 2026 13:57:58 +0200 Subject: [PATCH 15/20] Prevent workspace discovery from walking into the cache --- Cargo.lock | 2 + crates/uv-build-frontend/Cargo.toml | 1 + crates/uv-build-frontend/src/lib.rs | 5 + .../src/metadata/build_requires.rs | 13 +- .../src/metadata/dependency_groups.rs | 7 +- crates/uv-distribution/src/metadata/mod.rs | 5 +- .../src/metadata/requires_dist.rs | 16 ++- crates/uv-distribution/src/source/mod.rs | 9 ++ crates/uv-workspace/Cargo.toml | 1 + crates/uv-workspace/src/workspace.rs | 124 +++++++++++++----- crates/uv/src/commands/build_frontend.rs | 1 + crates/uv/src/commands/pip/operations.rs | 1 + crates/uv/src/commands/project/add.rs | 3 + crates/uv/src/commands/project/audit.rs | 10 +- crates/uv/src/commands/project/check.rs | 9 +- crates/uv/src/commands/project/export.rs | 3 + crates/uv/src/commands/project/format.rs | 9 +- crates/uv/src/commands/project/init.rs | 1 + crates/uv/src/commands/project/lock.rs | 10 +- crates/uv/src/commands/project/remove.rs | 2 + crates/uv/src/commands/project/run.rs | 2 + crates/uv/src/commands/project/sync.rs | 3 + crates/uv/src/commands/project/tree.rs | 10 +- crates/uv/src/commands/project/version.rs | 4 + crates/uv/src/commands/python/find.rs | 9 +- crates/uv/src/commands/python/pin.rs | 9 +- crates/uv/src/commands/venv.rs | 9 +- crates/uv/src/commands/workspace/dir.rs | 11 +- crates/uv/src/commands/workspace/list.rs | 11 +- crates/uv/src/commands/workspace/metadata.rs | 10 +- crates/uv/src/lib.rs | 27 +++- crates/uv/tests/it/workspace.rs | 73 +++++++++++ 32 files changed, 341 insertions(+), 69 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index deecdfe27c8cd..79b8570c65541 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6005,6 +6005,7 @@ dependencies = [ "toml_edit", "tracing", "uv-auth", + "uv-cache", "uv-cache-key", "uv-configuration", "uv-distribution", @@ -7458,6 +7459,7 @@ dependencies = [ "toml_edit", "tracing", "uv-build-backend", + "uv-cache", "uv-cache-key", "uv-configuration", "uv-distribution-types", diff --git a/crates/uv-build-frontend/Cargo.toml b/crates/uv-build-frontend/Cargo.toml index e09d087f71dc5..512493dbbce5f 100644 --- a/crates/uv-build-frontend/Cargo.toml +++ b/crates/uv-build-frontend/Cargo.toml @@ -17,6 +17,7 @@ workspace = true [dependencies] uv-auth = { workspace = true } +uv-cache = { workspace = true } uv-cache-key = { workspace = true } uv-configuration = { workspace = true } uv-distribution = { workspace = true } diff --git a/crates/uv-build-frontend/src/lib.rs b/crates/uv-build-frontend/src/lib.rs index b80bf4121ceb7..9690c88222910 100644 --- a/crates/uv-build-frontend/src/lib.rs +++ b/crates/uv-build-frontend/src/lib.rs @@ -28,6 +28,7 @@ use tokio::process::Command; use tokio::sync::{Mutex, Semaphore}; use tracing::{Instrument, debug, info_span, instrument, warn}; use uv_auth::CredentialsCache; +use uv_cache::Cache; use uv_cache_key::cache_digest; use uv_configuration::{BuildKind, BuildOutput, NoSources}; use uv_distribution::BuildRequires; @@ -321,6 +322,7 @@ impl SourceBuild { fallback_package_name, locations, &no_sources, + build_context.cache(), workspace_cache, credentials_cache, ) @@ -572,6 +574,7 @@ impl SourceBuild { package_name: Option<&PackageName>, locations: &IndexLocations, no_sources: &NoSources, + cache: &Cache, workspace_cache: &WorkspaceCache, credentials_cache: &CredentialsCache, ) -> Result<(Pep517Backend, Option), Box> { @@ -652,6 +655,7 @@ impl SourceBuild { locations, no_sources, true, + cache, workspace_cache, credentials_cache, ) @@ -1100,6 +1104,7 @@ async fn create_pep517_build_environment( build_context .source_tree_editable_policy() .workspace_member_editable(None), + build_context.cache(), workspace_cache, credentials_cache, ) diff --git a/crates/uv-distribution/src/metadata/build_requires.rs b/crates/uv-distribution/src/metadata/build_requires.rs index d758207c803d6..de0efd1410e00 100644 --- a/crates/uv-distribution/src/metadata/build_requires.rs +++ b/crates/uv-distribution/src/metadata/build_requires.rs @@ -2,6 +2,7 @@ use std::collections::BTreeMap; use std::path::Path; use uv_auth::CredentialsCache; +use uv_cache::Cache; use uv_configuration::NoSources; use uv_distribution_types::{ ExtraBuildRequirement, ExtraBuildRequires, IndexLocations, Requirement, @@ -43,7 +44,8 @@ impl BuildRequires { locations: &IndexLocations, sources: &NoSources, editable: bool, - cache: &WorkspaceCache, + cache: &Cache, + workspace_cache: &WorkspaceCache, credentials_cache: &CredentialsCache, ) -> Result { let discovery = if sources.all() { @@ -54,8 +56,13 @@ impl BuildRequires { } else { DiscoveryOptions::default() }; - let Some(project_workspace) = - ProjectWorkspace::from_maybe_project_root(install_path, &discovery, cache).await? + let Some(project_workspace) = ProjectWorkspace::from_maybe_project_root( + install_path, + &discovery, + cache, + workspace_cache, + ) + .await? else { return Ok(Self::from_metadata23(metadata)); }; diff --git a/crates/uv-distribution/src/metadata/dependency_groups.rs b/crates/uv-distribution/src/metadata/dependency_groups.rs index 492041b11c0c4..dcb134fe9a62f 100644 --- a/crates/uv-distribution/src/metadata/dependency_groups.rs +++ b/crates/uv-distribution/src/metadata/dependency_groups.rs @@ -2,6 +2,7 @@ use std::collections::BTreeMap; use std::path::{Path, PathBuf}; use uv_auth::CredentialsCache; +use uv_cache::Cache; use uv_configuration::NoSources; use uv_distribution_types::{IndexLocations, Requirement}; use uv_normalize::{GroupName, PackageName}; @@ -58,7 +59,8 @@ impl SourcedDependencyGroups { git_member: Option<&GitWorkspaceMember<'_>>, locations: &IndexLocations, no_sources: NoSources, - cache: &WorkspaceCache, + cache: &Cache, + workspace_cache: &WorkspaceCache, credentials_cache: &CredentialsCache, ) -> Result { // If the `pyproject.toml` doesn't exist, fail early. @@ -88,7 +90,8 @@ impl SourcedDependencyGroups { let absolute_pyproject_path = std::path::absolute(pyproject_path) .map_err(|err| WorkspaceError::from(WorkspaceErrorKind::Normalize(err)))?; let project_dir = absolute_pyproject_path.parent().unwrap_or(&empty); - let project = VirtualProject::discover(project_dir, &discovery, cache).await?; + let project = + VirtualProject::discover(project_dir, &discovery, cache, workspace_cache).await?; // Collect the dependency groups. let dependency_groups = diff --git a/crates/uv-distribution/src/metadata/mod.rs b/crates/uv-distribution/src/metadata/mod.rs index 54a9faaeb8ae4..e1ce898c32dba 100644 --- a/crates/uv-distribution/src/metadata/mod.rs +++ b/crates/uv-distribution/src/metadata/mod.rs @@ -4,6 +4,7 @@ use std::path::{Path, PathBuf}; use thiserror::Error; use uv_auth::CredentialsCache; +use uv_cache::Cache; use uv_configuration::NoSources; use uv_distribution_types::{GitDirectorySourceUrl, IndexLocations, Requirement}; use uv_normalize::{ExtraName, GroupName, PackageName}; @@ -101,7 +102,8 @@ impl Metadata { locations: &IndexLocations, sources: NoSources, editable: bool, - cache: &WorkspaceCache, + cache: &Cache, + workspace_cache: &WorkspaceCache, credentials_cache: &CredentialsCache, ) -> Result { // Lower the requirements. @@ -125,6 +127,7 @@ impl Metadata { sources, editable, cache, + workspace_cache, credentials_cache, ) .await?; diff --git a/crates/uv-distribution/src/metadata/requires_dist.rs b/crates/uv-distribution/src/metadata/requires_dist.rs index 0566d48148c3f..529891032ff15 100644 --- a/crates/uv-distribution/src/metadata/requires_dist.rs +++ b/crates/uv-distribution/src/metadata/requires_dist.rs @@ -5,6 +5,7 @@ use std::slice; use rustc_hash::FxHashSet; use uv_auth::CredentialsCache; +use uv_cache::Cache; use uv_configuration::NoSources; use uv_distribution_types::{IndexLocations, Requirement}; use uv_normalize::{ExtraName, GroupName, PackageName}; @@ -35,7 +36,8 @@ impl RequiresDist { locations: &IndexLocations, sources: NoSources, editable: bool, - cache: &WorkspaceCache, + cache: &Cache, + workspace_cache: &WorkspaceCache, credentials_cache: &CredentialsCache, ) -> Result { let discovery = DiscoveryOptions { @@ -52,8 +54,13 @@ impl RequiresDist { MemberDiscovery::None }, }; - let Some(project_workspace) = - ProjectWorkspace::from_maybe_project_root(install_path, &discovery, cache).await? + let Some(project_workspace) = ProjectWorkspace::from_maybe_project_root( + install_path, + &discovery, + cache, + workspace_cache, + ) + .await? else { return Self::from_metadata23_with_source_context(metadata, git_member); }; @@ -453,6 +460,7 @@ mod test { use tempfile::TempDir; use uv_auth::CredentialsCache; + use uv_cache::Cache; use uv_configuration::NoSources; use uv_distribution_types::IndexLocations; use uv_normalize::PackageName; @@ -467,12 +475,14 @@ mod test { contents: &str, ) -> anyhow::Result { fs_err::write(temp_dir.join("pyproject.toml"), contents)?; + let cache = Cache::from_path(temp_dir.join(".uv_cache")); let project_workspace = ProjectWorkspace::discover( temp_dir, &DiscoveryOptions { stop_discovery_at: Some(temp_dir.to_path_buf()), ..DiscoveryOptions::default() }, + &cache, &WorkspaceCache::default(), ) .await?; diff --git a/crates/uv-distribution/src/source/mod.rs b/crates/uv-distribution/src/source/mod.rs index 532d6f7b01c55..fba1032f1c658 100644 --- a/crates/uv-distribution/src/source/mod.rs +++ b/crates/uv-distribution/src/source/mod.rs @@ -1479,6 +1479,7 @@ impl<'a, T: BuildContext> SourceDistributionBuilder<'a, T> { self.build_context.locations(), self.build_context.sources().clone(), editable, + self.build_context.cache(), self.build_context.workspace_cache(), credentials_cache, ) @@ -1534,6 +1535,7 @@ impl<'a, T: BuildContext> SourceDistributionBuilder<'a, T> { self.build_context.locations(), self.build_context.sources().clone(), editable, + self.build_context.cache(), self.build_context.workspace_cache(), credentials_cache, ) @@ -1585,6 +1587,7 @@ impl<'a, T: BuildContext> SourceDistributionBuilder<'a, T> { self.build_context.locations(), self.build_context.sources().clone(), editable, + self.build_context.cache(), self.build_context.workspace_cache(), credentials_cache, ) @@ -1648,6 +1651,7 @@ impl<'a, T: BuildContext> SourceDistributionBuilder<'a, T> { self.build_context.locations(), self.build_context.sources().clone(), editable, + self.build_context.cache(), self.build_context.workspace_cache(), credentials_cache, ) @@ -1727,6 +1731,7 @@ impl<'a, T: BuildContext> SourceDistributionBuilder<'a, T> { self.build_context .source_tree_editable_policy() .workspace_member_editable(None), + self.build_context.cache(), self.build_context.workspace_cache(), credentials_cache, ) @@ -2322,6 +2327,7 @@ impl<'a, T: BuildContext> SourceDistributionBuilder<'a, T> { self.build_context .source_tree_editable_policy() .workspace_member_editable(None), + self.build_context.cache(), self.build_context.workspace_cache(), credentials_cache, ) @@ -2359,6 +2365,7 @@ impl<'a, T: BuildContext> SourceDistributionBuilder<'a, T> { self.build_context .source_tree_editable_policy() .workspace_member_editable(None), + self.build_context.cache(), self.build_context.workspace_cache(), credentials_cache, ) @@ -2415,6 +2422,7 @@ impl<'a, T: BuildContext> SourceDistributionBuilder<'a, T> { self.build_context .source_tree_editable_policy() .workspace_member_editable(None), + self.build_context.cache(), self.build_context.workspace_cache(), credentials_cache, ) @@ -2480,6 +2488,7 @@ impl<'a, T: BuildContext> SourceDistributionBuilder<'a, T> { self.build_context .source_tree_editable_policy() .workspace_member_editable(None), + self.build_context.cache(), self.build_context.workspace_cache(), credentials_cache, ) diff --git a/crates/uv-workspace/Cargo.toml b/crates/uv-workspace/Cargo.toml index 91331f628a737..c628f24196469 100644 --- a/crates/uv-workspace/Cargo.toml +++ b/crates/uv-workspace/Cargo.toml @@ -17,6 +17,7 @@ workspace = true [dependencies] uv-build-backend = { workspace = true, features = ["schemars"] } +uv-cache = { workspace = true } uv-cache-key = { workspace = true } uv-configuration = { workspace = true } uv-distribution-types = { workspace = true } diff --git a/crates/uv-workspace/src/workspace.rs b/crates/uv-workspace/src/workspace.rs index 512839cf10225..bee8c13b5080d 100644 --- a/crates/uv-workspace/src/workspace.rs +++ b/crates/uv-workspace/src/workspace.rs @@ -13,6 +13,7 @@ use itertools::Itertools; use rustc_hash::{FxHashSet, FxHasher}; use tracing::{debug, trace, warn}; +use uv_cache::Cache; use uv_configuration::DependencyGroupsWithDefaults; use uv_distribution_types::{Index, Requirement, RequirementSource}; use uv_fs::{CWD, Simplified, normalize_path}; @@ -41,8 +42,8 @@ type CachedWorkspaceResult = Result, WorkspaceError>; /// The cache is indexed both by the workspace root and by the path of each workspace member. /// /// The cache makes assumptions about [`DiscoveryOptions`]: -/// * For a given discovery path, `stop_discovery_at` either always or never sits between a project -/// and a workspace root. This means that we don't need to key discovery on `stop_discovery_at`. +/// * `stop_discovery_at` is only used for isolation workspaces in the cache. We avoid traversing +/// into the cache if `cache` is accidentally included in the workspace member glob. /// * TODO(konsti): Support caching for [`MemberDiscovery`] modes that aren't `All`. #[derive(Debug, Default, Clone)] pub struct WorkspaceCache { @@ -247,7 +248,8 @@ impl Workspace { pub async fn discover( path: &Path, options: &DiscoveryOptions, - cache: &WorkspaceCache, + cache: &Cache, + workspace_cache: &WorkspaceCache, ) -> Result, WorkspaceError> { let path = std::path::absolute(path) .map_err(WorkspaceErrorKind::Normalize)? @@ -266,7 +268,7 @@ impl Workspace { // synchronize them after finding the workspace root and allow only one of them to perform // the full discovery. if options.members == MemberDiscovery::All - && let Some(workspace) = cache.get(&project_path) + && let Some(workspace) = workspace_cache.get(&project_path) { return workspace; } @@ -334,7 +336,7 @@ impl Workspace { // package `pyproject.toml` and the workspace root `pyproject.toml` before arriving // here. At this point, only one thread can continue and the other waits, then uses the // cached workspace. - if let Some(workspace) = cache.register_or_wait(&workspace_root).await { + if let Some(workspace) = workspace_cache.register_or_wait(&workspace_root).await { return workspace; } } @@ -361,10 +363,11 @@ impl Workspace { workspace_pyproject_toml, current_project, options, + cache, ) .await; if options.members == MemberDiscovery::All { - cache.insert(result.clone(), &workspace_root); + workspace_cache.insert(result.clone(), &workspace_root); } result } @@ -951,6 +954,7 @@ impl Workspace { workspace_pyproject_toml: PyProjectToml, current_project: Option, options: &DiscoveryOptions, + cache: &Cache, ) -> Result, WorkspaceError> { trace!( "Discovering workspace members for: `{}`", @@ -961,6 +965,7 @@ impl Workspace { &workspace_definition, &workspace_pyproject_toml, options, + cache, ) .await?; let mut workspace_members = Arc::new(workspace_members); @@ -1038,11 +1043,20 @@ impl Workspace { workspace_definition: &ToolUvWorkspace, workspace_pyproject_toml: &PyProjectToml, options: &DiscoveryOptions, + cache: &Cache, ) -> Result, WorkspaceError> { let mut workspace_members = BTreeMap::new(); // Avoid reading a `pyproject.toml` more than once. let mut seen = FxHashSet::default(); + // We may receive an uninitialized cache with a relative cache root. + let cache_root = if cache.root().is_absolute() { + cache.root().to_path_buf() + } else { + CWD.join(cache.root()) + }; + let cache_root = normalize_path(&cache_root).into_owned(); + // Add the project at the workspace root, if it exists and if it's distinct from the current // project. If it is the current project, it is added as such in the next step. if let Some(project) = &workspace_pyproject_toml.project { @@ -1077,6 +1091,13 @@ impl Workspace { { let member_root = member_root .map_err(|err| WorkspaceErrorKind::GlobWalk(absolute_glob.clone(), err))?; + if member_root.starts_with(&cache_root) { + debug!( + "Ignoring cache directory while discovering workspace members: `{}`", + member_root.simplified_display() + ); + continue; + } if !seen.insert(member_root.clone()) { continue; } @@ -1414,7 +1435,8 @@ impl ProjectWorkspace { pub async fn discover( path: &Path, options: &DiscoveryOptions, - cache: &WorkspaceCache, + cache: &Cache, + workspace_cache: &WorkspaceCache, ) -> Result { assert!( path.is_absolute(), @@ -1439,16 +1461,17 @@ impl ProjectWorkspace { project_root.simplified_display() ); - Self::from_project_root(project_root, options, cache).await + Self::from_project_root(project_root, options, cache, workspace_cache).await } /// Discover the workspace starting from the directory containing the `pyproject.toml`. async fn from_project_root( project_root: &Path, options: &DiscoveryOptions, - cache: &WorkspaceCache, + cache: &Cache, + workspace_cache: &WorkspaceCache, ) -> Result { - if let Some(project) = Self::from_cache(project_root, options, cache)? { + if let Some(project) = Self::from_cache(project_root, options, workspace_cache)? { return Ok(project); } @@ -1465,7 +1488,15 @@ impl ProjectWorkspace { .clone() .ok_or_else(|| WorkspaceErrorKind::MissingProject(pyproject_path))?; - Self::from_project(project_root, &project, &pyproject_toml, options, cache).await + Self::from_project( + project_root, + &project, + &pyproject_toml, + options, + cache, + workspace_cache, + ) + .await } /// If the current directory contains a `pyproject.toml` with a `project` table, discover the @@ -1473,9 +1504,10 @@ impl ProjectWorkspace { pub async fn from_maybe_project_root( project_root: &Path, options: &DiscoveryOptions, - cache: &WorkspaceCache, + cache: &Cache, + workspace_cache: &WorkspaceCache, ) -> Result, WorkspaceError> { - if let Some(project) = Self::from_cache(project_root, options, cache)? { + if let Some(project) = Self::from_cache(project_root, options, workspace_cache)? { return Ok(Some(project)); } @@ -1494,7 +1526,16 @@ impl ProjectWorkspace { return Ok(None); }; - match Self::from_project(project_root, &project, &pyproject_toml, options, cache).await { + match Self::from_project( + project_root, + &project, + &pyproject_toml, + options, + cache, + workspace_cache, + ) + .await + { Ok(workspace) => Ok(Some(workspace)), Err(error) if matches!(error.as_ref(), WorkspaceErrorKind::NonWorkspace(_)) => Ok(None), Err(err) => Err(err), @@ -1540,7 +1581,8 @@ impl ProjectWorkspace { project: &Project, project_pyproject_toml: &PyProjectToml, options: &DiscoveryOptions, - cache: &WorkspaceCache, + cache: &Cache, + workspace_cache: &WorkspaceCache, ) -> Result { let project_path = std::path::absolute(install_path) .map_err(WorkspaceErrorKind::Normalize)? @@ -1561,7 +1603,7 @@ impl ProjectWorkspace { ))); } - if let Some(project) = Self::from_cache(&project_path, options, cache)? { + if let Some(project) = Self::from_cache(&project_path, options, workspace_cache)? { return Ok(project); } @@ -1620,7 +1662,7 @@ impl ProjectWorkspace { }; let workspace = Arc::new(workspace); if options.members == MemberDiscovery::All { - cache.insert(Ok(workspace.clone()), &project_path); + workspace_cache.insert(Ok(workspace.clone()), &project_path); } return Ok(Self { project_root: project_path.to_path_buf(), @@ -1631,7 +1673,7 @@ impl ProjectWorkspace { if options.members == MemberDiscovery::All { // Ensure that workspace discovery runs only once for any given workspace root. - if let Some(workspace) = cache.register_or_wait(&workspace_root).await { + if let Some(workspace) = workspace_cache.register_or_wait(&workspace_root).await { return workspace.map(|workspace| Self { project_root: project_path.to_path_buf(), project_name: project.name.clone(), @@ -1651,10 +1693,11 @@ impl ProjectWorkspace { workspace_pyproject_toml, Some(current_project), options, + cache, ) .await; if options.members == MemberDiscovery::All { - cache.insert(result.clone(), &workspace_root); + workspace_cache.insert(result.clone(), &workspace_root); } Ok(Self { @@ -1864,7 +1907,8 @@ impl VirtualProject { pub async fn discover( path: &Path, options: &DiscoveryOptions, - cache: &WorkspaceCache, + cache: &Cache, + workspace_cache: &WorkspaceCache, ) -> Result { assert!( path.is_absolute(), @@ -1891,7 +1935,7 @@ impl VirtualProject { // Fast path: The workspace is already cached. if options.members == MemberDiscovery::All - && let Some(workspace) = cache.get(project_root) + && let Some(workspace) = workspace_cache.get(project_root) { let workspace = workspace?; let virtual_project = if let Some((project_name, _member)) = workspace @@ -1924,6 +1968,7 @@ impl VirtualProject { &pyproject_toml, options, cache, + workspace_cache, ) .await?; Ok(Self::Project(project)) @@ -1945,10 +1990,11 @@ impl VirtualProject { pyproject_toml, None, options, + cache, ) .await; if options.members == MemberDiscovery::All { - cache.insert(result.clone(), &project_path); + workspace_cache.insert(result.clone(), &project_path); } Ok(Self::NonProject(result?)) } else { @@ -1964,10 +2010,11 @@ impl VirtualProject { pyproject_toml, None, options, + cache, ) .await; if options.members == MemberDiscovery::All { - cache.insert(result.clone(), &project_path); + workspace_cache.insert(result.clone(), &project_path); } Ok(Self::NonProject(result?)) } @@ -1977,10 +2024,11 @@ impl VirtualProject { pub async fn discover_with_package( path: &Path, options: &DiscoveryOptions, - cache: &WorkspaceCache, + cache: &Cache, + workspace_cache: &WorkspaceCache, package: PackageName, ) -> Result { - let workspace = Workspace::discover(path, options, cache).await?; + let workspace = Workspace::discover(path, options, cache, workspace_cache).await?; let Some(project_workspace) = Workspace::with_current_project(workspace.clone(), package.clone()) else { @@ -2100,6 +2148,7 @@ mod tests { use assert_fs::prelude::*; use insta::{assert_json_snapshot, assert_snapshot}; + use uv_cache::Cache; use uv_normalize::{GroupName, PackageName}; use uv_pypi_types::DependencyGroupSpecifier; @@ -2116,9 +2165,11 @@ mod tests { .unwrap() .join("test") .join("workspaces"); + let cache = Cache::from_path(root_dir.join(".uv_cache")); let project = ProjectWorkspace::discover( &root_dir.join(folder), &DiscoveryOptions::default(), + &cache, &WorkspaceCache::default(), ) .await @@ -2131,9 +2182,11 @@ mod tests { folder: &Path, ) -> Result<(ProjectWorkspace, String), (WorkspaceError, String)> { let root_escaped = regex::escape(folder.to_string_lossy().as_ref()); + let cache = Cache::from_path(env::temp_dir().join("uv-workspace-cache")); let project = ProjectWorkspace::discover( folder, &DiscoveryOptions::default(), + &cache, &WorkspaceCache::default(), ) .await @@ -2507,13 +2560,20 @@ mod tests { "#, )?; - let cache = WorkspaceCache::default(); - let root_workspace = - Workspace::discover(root.as_ref(), &DiscoveryOptions::default(), &cache).await?; + let cache = Cache::from_path(env::temp_dir().join("uv-workspace-cache")); + let workspace_cache = WorkspaceCache::default(); + let root_workspace = Workspace::discover( + root.as_ref(), + &DiscoveryOptions::default(), + &cache, + &workspace_cache, + ) + .await?; let member_workspace = Workspace::discover( root.child("packages").child("seeds").as_ref(), &DiscoveryOptions::default(), &cache, + &workspace_cache, ) .await?; @@ -2525,6 +2585,7 @@ mod tests { root.child("packages").child("seeds").as_ref(), &DiscoveryOptions::default(), &cache, + &workspace_cache, ) .await? .expect("cached workspace member ignores invalid change in the meantime"); @@ -2563,13 +2624,15 @@ mod tests { "#, )?; - let cache = WorkspaceCache::default(); + let cache = Cache::from_path(env::temp_dir().join("uv-workspace-cache")); + let workspace_cache = WorkspaceCache::default(); let partial_options = DiscoveryOptions { members: MemberDiscovery::None, ..DiscoveryOptions::default() }; let partial_project = - ProjectWorkspace::discover(root.as_ref(), &partial_options, &cache).await?; + ProjectWorkspace::discover(root.as_ref(), &partial_options, &cache, &workspace_cache) + .await?; assert_eq!(partial_project.workspace().packages().len(), 1); @@ -2577,6 +2640,7 @@ mod tests { root.child("packages").child("seeds").as_ref(), &DiscoveryOptions::default(), &cache, + &workspace_cache, ) .await?; let seeds = PackageName::from_str("seeds")?; diff --git a/crates/uv/src/commands/build_frontend.rs b/crates/uv/src/commands/build_frontend.rs index b1f128b55d4da..530c0e8f643c3 100644 --- a/crates/uv/src/commands/build_frontend.rs +++ b/crates/uv/src/commands/build_frontend.rs @@ -270,6 +270,7 @@ async fn build_impl( let workspace = Workspace::discover( src.directory(), &DiscoveryOptions::default(), + cache, workspace_cache, ) .await; diff --git a/crates/uv/src/commands/pip/operations.rs b/crates/uv/src/commands/pip/operations.rs index 84103bf975b8e..814d80eda364a 100644 --- a/crates/uv/src/commands/pip/operations.rs +++ b/crates/uv/src/commands/pip/operations.rs @@ -219,6 +219,7 @@ pub(crate) async fn resolve( None, build_dispatch.locations(), build_dispatch.sources().clone(), + build_dispatch.cache(), build_dispatch.workspace_cache(), client.credentials_cache(), ) diff --git a/crates/uv/src/commands/project/add.rs b/crates/uv/src/commands/project/add.rs index 0ee55c20b46f2..1ebcb879d84d3 100644 --- a/crates/uv/src/commands/project/add.rs +++ b/crates/uv/src/commands/project/add.rs @@ -236,6 +236,7 @@ pub(crate) async fn add( VirtualProject::discover_with_package( project_dir, &DiscoveryOptions::default(), + cache, &WorkspaceCache::default(), package, ) @@ -244,6 +245,7 @@ pub(crate) async fn add( VirtualProject::discover( project_dir, &DiscoveryOptions::default(), + cache, &WorkspaceCache::default(), ) .await? @@ -610,6 +612,7 @@ pub(crate) async fn add( VirtualProject::discover( project.root(), &DiscoveryOptions::default(), + cache, &WorkspaceCache::default(), ) .await?, diff --git a/crates/uv/src/commands/project/audit.rs b/crates/uv/src/commands/project/audit.rs index 90dcd9e03cca9..9a1f57a2317d3 100644 --- a/crates/uv/src/commands/project/audit.rs +++ b/crates/uv/src/commands/project/audit.rs @@ -84,9 +84,13 @@ pub(crate) async fn audit( let target = if let Some(script) = script.as_ref() { LockTarget::Script(script) } else { - workspace = - Workspace::discover(project_dir, &DiscoveryOptions::default(), &workspace_cache) - .await?; + workspace = Workspace::discover( + project_dir, + &DiscoveryOptions::default(), + &cache, + &workspace_cache, + ) + .await?; LockTarget::Workspace(&workspace) }; diff --git a/crates/uv/src/commands/project/check.rs b/crates/uv/src/commands/project/check.rs index 67b5172210a49..a0dc03f68013c 100644 --- a/crates/uv/src/commands/project/check.rs +++ b/crates/uv/src/commands/project/check.rs @@ -71,8 +71,13 @@ pub(crate) async fn check( let project = if no_project { None } else { - match VirtualProject::discover(project_dir, &DiscoveryOptions::default(), workspace_cache) - .await + match VirtualProject::discover( + project_dir, + &DiscoveryOptions::default(), + cache, + workspace_cache, + ) + .await { Ok(project) => Some(project), Err(err) => { diff --git a/crates/uv/src/commands/project/export.rs b/crates/uv/src/commands/project/export.rs index 2d083b86eac05..c2c22ff5183ea 100644 --- a/crates/uv/src/commands/project/export.rs +++ b/crates/uv/src/commands/project/export.rs @@ -97,6 +97,7 @@ pub(crate) async fn export( members: MemberDiscovery::None, ..DiscoveryOptions::default() }, + cache, &workspace_cache, ) .await? @@ -104,6 +105,7 @@ pub(crate) async fn export( VirtualProject::discover_with_package( project_dir, &DiscoveryOptions::default(), + cache, &workspace_cache, name.clone(), ) @@ -112,6 +114,7 @@ pub(crate) async fn export( let project = VirtualProject::discover( project_dir, &DiscoveryOptions::default(), + cache, &workspace_cache, ) .await?; diff --git a/crates/uv/src/commands/project/format.rs b/crates/uv/src/commands/project/format.rs index e4624d2779ead..65a3fc10e9887 100644 --- a/crates/uv/src/commands/project/format.rs +++ b/crates/uv/src/commands/project/format.rs @@ -48,8 +48,13 @@ pub(crate) async fn format( let target_dir = if no_project { project_dir.to_owned() } else { - match VirtualProject::discover(project_dir, &DiscoveryOptions::default(), &workspace_cache) - .await + match VirtualProject::discover( + project_dir, + &DiscoveryOptions::default(), + &cache, + &workspace_cache, + ) + .await { // If we found a project, we use the project root Ok(proj) => proj.root().to_owned(), diff --git a/crates/uv/src/commands/project/init.rs b/crates/uv/src/commands/project/init.rs index 58711f8ce9126..cc3a5f0b6c057 100644 --- a/crates/uv/src/commands/project/init.rs +++ b/crates/uv/src/commands/project/init.rs @@ -324,6 +324,7 @@ async fn init_project( members: MemberDiscovery::Ignore(std::iter::once(path.to_path_buf()).collect()), ..DiscoveryOptions::default() }, + cache, &workspace_cache, ) .await diff --git a/crates/uv/src/commands/project/lock.rs b/crates/uv/src/commands/project/lock.rs index 6565bdcce5bec..fb9306bbb04ce 100644 --- a/crates/uv/src/commands/project/lock.rs +++ b/crates/uv/src/commands/project/lock.rs @@ -129,9 +129,13 @@ pub(crate) async fn lock( let target = if let Some(script) = script.as_ref() { LockTarget::Script(script) } else { - workspace = - VirtualProject::discover(project_dir, &DiscoveryOptions::default(), workspace_cache) - .await?; + workspace = VirtualProject::discover( + project_dir, + &DiscoveryOptions::default(), + cache, + workspace_cache, + ) + .await?; LockTarget::Workspace(workspace.workspace()) }; diff --git a/crates/uv/src/commands/project/remove.rs b/crates/uv/src/commands/project/remove.rs index 99c0582aedd69..6d828c27c7607 100644 --- a/crates/uv/src/commands/project/remove.rs +++ b/crates/uv/src/commands/project/remove.rs @@ -93,6 +93,7 @@ pub(crate) async fn remove( VirtualProject::discover_with_package( project_dir, &DiscoveryOptions::default(), + cache, &WorkspaceCache::default(), package.clone(), ) @@ -101,6 +102,7 @@ pub(crate) async fn remove( VirtualProject::discover( project_dir, &DiscoveryOptions::default(), + cache, &WorkspaceCache::default(), ) .await? diff --git a/crates/uv/src/commands/project/run.rs b/crates/uv/src/commands/project/run.rs index 374a7eac80cc0..2a178e7a5eb18 100644 --- a/crates/uv/src/commands/project/run.rs +++ b/crates/uv/src/commands/project/run.rs @@ -540,6 +540,7 @@ pub(crate) async fn run( let project = VirtualProject::discover_with_package( project_dir, &DiscoveryOptions::default(), + &cache, workspace_cache, package.clone(), ) @@ -549,6 +550,7 @@ pub(crate) async fn run( match VirtualProject::discover( project_dir, &DiscoveryOptions::default(), + &cache, workspace_cache, ) .await diff --git a/crates/uv/src/commands/project/sync.rs b/crates/uv/src/commands/project/sync.rs index 13084475b2a8d..ef4c25f5d3d14 100644 --- a/crates/uv/src/commands/project/sync.rs +++ b/crates/uv/src/commands/project/sync.rs @@ -108,6 +108,7 @@ pub(crate) async fn sync( members: MemberDiscovery::Existing, ..DiscoveryOptions::default() }, + cache, workspace_cache, ) .await? @@ -115,6 +116,7 @@ pub(crate) async fn sync( VirtualProject::discover_with_package( project_dir, &DiscoveryOptions::default(), + cache, workspace_cache, name.clone(), ) @@ -123,6 +125,7 @@ pub(crate) async fn sync( let project = VirtualProject::discover( project_dir, &DiscoveryOptions::default(), + cache, workspace_cache, ) .await?; diff --git a/crates/uv/src/commands/project/tree.rs b/crates/uv/src/commands/project/tree.rs index 8c6a9eee89e1b..1acbd7cf02c4a 100644 --- a/crates/uv/src/commands/project/tree.rs +++ b/crates/uv/src/commands/project/tree.rs @@ -69,9 +69,13 @@ pub(crate) async fn tree( let target = if let Some(script) = script.as_ref() { LockTarget::Script(script) } else { - workspace = - Workspace::discover(project_dir, &DiscoveryOptions::default(), &workspace_cache) - .await?; + workspace = Workspace::discover( + project_dir, + &DiscoveryOptions::default(), + cache, + &workspace_cache, + ) + .await?; LockTarget::Workspace(&workspace) }; diff --git a/crates/uv/src/commands/project/version.rs b/crates/uv/src/commands/project/version.rs index 260ef675a72bb..483c5be5189e7 100644 --- a/crates/uv/src/commands/project/version.rs +++ b/crates/uv/src/commands/project/version.rs @@ -102,6 +102,7 @@ pub(crate) async fn project_version( project_dir, package.as_ref(), explicit_project, + cache, workspace_cache, ) .await?; @@ -412,6 +413,7 @@ async fn find_target( project_dir: &Path, package: Option<&PackageName>, explicit_project: bool, + cache: &Cache, workspace_cache: &WorkspaceCache, ) -> Result { // Find the project in the workspace. @@ -419,6 +421,7 @@ async fn find_target( VirtualProject::discover_with_package( project_dir, &DiscoveryOptions::default(), + cache, workspace_cache, package.clone(), ) @@ -432,6 +435,7 @@ async fn find_target( ProjectWorkspace::discover( project_dir, &DiscoveryOptions::default(), + cache, &project_workspace_cache, ) .await diff --git a/crates/uv/src/commands/python/find.rs b/crates/uv/src/commands/python/find.rs index 04611176b14ee..018fceaf34f38 100644 --- a/crates/uv/src/commands/python/find.rs +++ b/crates/uv/src/commands/python/find.rs @@ -47,8 +47,13 @@ pub(crate) async fn find( let project = if no_project { None } else { - match VirtualProject::discover(project_dir, &DiscoveryOptions::default(), workspace_cache) - .await + match VirtualProject::discover( + project_dir, + &DiscoveryOptions::default(), + cache, + workspace_cache, + ) + .await { Ok(project) => Some(project), Err(err) => { diff --git a/crates/uv/src/commands/python/pin.rs b/crates/uv/src/commands/python/pin.rs index 65d3f819c9459..9d119aa8c5c4f 100644 --- a/crates/uv/src/commands/python/pin.rs +++ b/crates/uv/src/commands/python/pin.rs @@ -43,8 +43,13 @@ pub(crate) async fn pin( let virtual_project = if no_project { None } else { - match VirtualProject::discover(project_dir, &DiscoveryOptions::default(), workspace_cache) - .await + match VirtualProject::discover( + project_dir, + &DiscoveryOptions::default(), + cache, + workspace_cache, + ) + .await { Ok(virtual_project) => Some(virtual_project), Err(err) => { diff --git a/crates/uv/src/commands/venv.rs b/crates/uv/src/commands/venv.rs index dfd4b7dd7e224..40407a5255d32 100644 --- a/crates/uv/src/commands/venv.rs +++ b/crates/uv/src/commands/venv.rs @@ -91,8 +91,13 @@ pub(crate) async fn venv( let project = if no_project { None } else { - match VirtualProject::discover(project_dir, &DiscoveryOptions::default(), workspace_cache) - .await + match VirtualProject::discover( + project_dir, + &DiscoveryOptions::default(), + cache, + workspace_cache, + ) + .await { Ok(project) => Some(project), Err(err) => { diff --git a/crates/uv/src/commands/workspace/dir.rs b/crates/uv/src/commands/workspace/dir.rs index 4f8be63ba6377..bf4de7593bf06 100644 --- a/crates/uv/src/commands/workspace/dir.rs +++ b/crates/uv/src/commands/workspace/dir.rs @@ -4,6 +4,7 @@ use std::path::Path; use anyhow::{Result, bail}; use owo_colors::OwoColorize; +use uv_cache::Cache; use uv_fs::Simplified; use uv_normalize::PackageName; use uv_workspace::{DiscoveryOptions, Workspace, WorkspaceCache}; @@ -15,11 +16,17 @@ use crate::printer::Printer; pub(crate) async fn dir( package_name: Option, project_dir: &Path, + cache: &Cache, workspace_cache: &WorkspaceCache, printer: Printer, ) -> Result { - let workspace = - Workspace::discover(project_dir, &DiscoveryOptions::default(), workspace_cache).await?; + let workspace = Workspace::discover( + project_dir, + &DiscoveryOptions::default(), + cache, + workspace_cache, + ) + .await?; let dir = match package_name { None => workspace.install_path(), diff --git a/crates/uv/src/commands/workspace/list.rs b/crates/uv/src/commands/workspace/list.rs index 9b103bfbf8435..7713e7ebea922 100644 --- a/crates/uv/src/commands/workspace/list.rs +++ b/crates/uv/src/commands/workspace/list.rs @@ -4,6 +4,7 @@ use std::path::Path; use anyhow::Result; use owo_colors::OwoColorize; +use uv_cache::Cache; use uv_fs::Simplified; use uv_workspace::{DiscoveryOptions, Workspace, WorkspaceCache}; @@ -14,11 +15,17 @@ use crate::printer::Printer; pub(crate) async fn list( project_dir: &Path, paths: bool, + cache: &Cache, workspace_cache: &WorkspaceCache, printer: Printer, ) -> Result { - let workspace = - Workspace::discover(project_dir, &DiscoveryOptions::default(), workspace_cache).await?; + let workspace = Workspace::discover( + project_dir, + &DiscoveryOptions::default(), + cache, + workspace_cache, + ) + .await?; for (name, member) in workspace.packages() { if paths { diff --git a/crates/uv/src/commands/workspace/metadata.rs b/crates/uv/src/commands/workspace/metadata.rs index e9433ea71c1cf..faaa8ee5a9bcc 100644 --- a/crates/uv/src/commands/workspace/metadata.rs +++ b/crates/uv/src/commands/workspace/metadata.rs @@ -55,9 +55,13 @@ pub(crate) async fn metadata( ); } - let virtual_project = - VirtualProject::discover(project_dir, &DiscoveryOptions::default(), workspace_cache) - .await?; + let virtual_project = VirtualProject::discover( + project_dir, + &DiscoveryOptions::default(), + cache, + workspace_cache, + ) + .await?; let target = LockTarget::Workspace(virtual_project.workspace()); // Don't enable any groups' requires-python for interpreter discovery. diff --git a/crates/uv/src/lib.rs b/crates/uv/src/lib.rs index 178d50967f069..0850ead146d21 100644 --- a/crates/uv/src/lib.rs +++ b/crates/uv/src/lib.rs @@ -270,6 +270,12 @@ async fn run(cli: Cli) -> Result { // If found, this file is combined with the user configuration file. // 3. The nearest configuration file (`uv.toml` or `pyproject.toml`) in the directory tree, // starting from the current directory. + + // Pass the (possibly non-existent) cache dir path to the initial workspace discovery. + let discovery_cache = Cache::from_settings( + cli.top_level.cache_args.no_cache, + cli.top_level.cache_args.cache_dir.clone(), + )?; let workspace_cache = WorkspaceCache::default(); let filesystem = if let Some(config_file) = cli.top_level.config_file.as_ref() { if config_file @@ -288,8 +294,13 @@ async fn run(cli: Cli) -> Result { FilesystemOptions::user() .map_err(map_settings_error)? .combine(FilesystemOptions::system().map_err(map_settings_error)?) - } else if let Ok(workspace) = - Workspace::discover(&project_dir, &DiscoveryOptions::default(), &workspace_cache).await + } else if let Ok(workspace) = Workspace::discover( + &project_dir, + &DiscoveryOptions::default(), + &discovery_cache, + &workspace_cache, + ) + .await { let project = FilesystemOptions::find(workspace.install_path()).map_err(map_settings_error)?; @@ -527,6 +538,7 @@ async fn run(cli: Cli) -> Result { debug!("Disabling the uv cache due to `--no-cache`"); } let cache = Cache::from_settings(cache_settings.no_cache, cache_settings.cache_dir)?; + let workspace_cache = WorkspaceCache::default(); // Configure the global network settings. let client_builder = BaseClientBuilder::new( @@ -1979,10 +1991,17 @@ async fn run(cli: Cli) -> Result { .await } WorkspaceCommand::Dir(args) => { - commands::dir(args.package, &project_dir, &workspace_cache, printer).await + commands::dir( + args.package, + &project_dir, + &cache, + &workspace_cache, + printer, + ) + .await } WorkspaceCommand::List(args) => { - commands::list(&project_dir, args.paths, &workspace_cache, printer).await + commands::list(&project_dir, args.paths, &cache, &workspace_cache, printer).await } }, Commands::BuildBackend { command } => spawn_blocking(move || match command { diff --git a/crates/uv/tests/it/workspace.rs b/crates/uv/tests/it/workspace.rs index 29a54f13713b8..e882f06ad15aa 100644 --- a/crates/uv/tests/it/workspace.rs +++ b/crates/uv/tests/it/workspace.rs @@ -2193,6 +2193,79 @@ fn transitive_dep_in_git_workspace_with_root() -> Result<()> { Ok(()) } +/// Ignore uv's cache directory when expanding member globs for an outer workspace. +/// +/// If `workspace-member-in-subdir` is treated as a member of the outer workspace, its workspace +/// source resolves to the outer workspace's `uv-git-workspace-in-root` instead of the Git +/// checkout's root package. +/// +/// See: +#[test] +#[cfg(feature = "test-git")] +fn transitive_dep_in_git_workspace_with_cache_inside_workspace() -> Result<()> { + let context = uv_test::test_context!("3.12"); + + context.temp_dir.child("pyproject.toml").write_str(indoc! {r#" + [project] + name = "prime-cache" + version = "0" + requires-python = ">=3.12" + dependencies = ["workspace-member-in-subdir"] + + [tool.uv.sources] + workspace-member-in-subdir = { git = "https://github.com/astral-sh/workspace-in-root-test", subdirectory = "workspace-member-in-subdir", rev = "d3ab48d2338296d47e28dbb2fb327c5e2ac4ac68" } + "#})?; + context.lock().assert().success(); + fs_err::remove_file(context.temp_dir.child("uv.lock"))?; + + context.root.child("pyproject.toml").write_str(indoc! {r#" + [project] + name = "outer" + version = "0" + requires-python = ">=3.12" + + [tool.uv.workspace] + members = [ + "packages/uv-git-workspace-in-root", + "**/workspace-member-in-subdir", + ] + "#})?; + context + .root + .child("packages") + .child("uv-git-workspace-in-root") + .child("pyproject.toml") + .write_str(indoc! {r#" + [project] + name = "uv-git-workspace-in-root" + version = "0" + requires-python = ">=3.12" + "#})?; + + context.temp_dir.child("pyproject.toml").write_str(indoc! {r#" + [project] + name = "consumer" + version = "0" + requires-python = ">=3.12" + dependencies = ["outer", "workspace-member-in-subdir"] + + [tool.uv.sources] + outer = { path = ".." } + workspace-member-in-subdir = { git = "https://github.com/astral-sh/workspace-in-root-test", subdirectory = "workspace-member-in-subdir", rev = "d3ab48d2338296d47e28dbb2fb327c5e2ac4ac68" } + "#})?; + + uv_snapshot!(context.filters(), context.lock().arg("--offline"), @" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 4 packages in [TIME] + "); + + Ok(()) +} + #[test] fn workspace_members_with_leading_dot_slash() -> Result<()> { let context = uv_test::test_context!("3.12"); From b0a22b74bb9ea9206861722257fbed4dd039cde6 Mon Sep 17 00:00:00 2001 From: konstin Date: Wed, 3 Jun 2026 14:16:09 +0200 Subject: [PATCH 16/20] Handle directories in the cache too --- crates/uv-workspace/src/workspace.rs | 34 ++++++++++++++++++++-------- 1 file changed, 24 insertions(+), 10 deletions(-) diff --git a/crates/uv-workspace/src/workspace.rs b/crates/uv-workspace/src/workspace.rs index bee8c13b5080d..6c31bbb930ba5 100644 --- a/crates/uv-workspace/src/workspace.rs +++ b/crates/uv-workspace/src/workspace.rs @@ -42,8 +42,9 @@ type CachedWorkspaceResult = Result, WorkspaceError>; /// The cache is indexed both by the workspace root and by the path of each workspace member. /// /// The cache makes assumptions about [`DiscoveryOptions`]: -/// * `stop_discovery_at` is only used for isolation workspaces in the cache. We avoid traversing -/// into the cache if `cache` is accidentally included in the workspace member glob. +/// * `stop_discovery_at` is only used for isolation workspaces in the cache. Otherwise, we avoid +/// traversing into an external cache if `cache` is accidentally included in the workspace member +/// glob. /// * TODO(konsti): Support caching for [`MemberDiscovery`] modes that aren't `All`. #[derive(Debug, Default, Clone)] pub struct WorkspaceCache { @@ -188,6 +189,10 @@ pub enum MemberDiscovery { #[derive(Debug, Default, Clone, Hash, PartialEq, Eq)] pub struct DiscoveryOptions { /// The path to stop discovery at. + /// + /// Assumption: This is only used for directories in the cache to avoid them escaping the cache. + /// If you want to use it for other cases too, you need to also update the cache handling in + /// the workspace discovery glob walking. pub stop_discovery_at: Option, /// The strategy to use when discovering workspace members. pub members: MemberDiscovery, @@ -1049,13 +1054,19 @@ impl Workspace { // Avoid reading a `pyproject.toml` more than once. let mut seen = FxHashSet::default(); - // We may receive an uninitialized cache with a relative cache root. - let cache_root = if cache.root().is_absolute() { - cache.root().to_path_buf() - } else { - CWD.join(cache.root()) - }; - let cache_root = normalize_path(&cache_root).into_owned(); + let external_cache_root = options + .stop_discovery_at + .is_none() + .then(|| { + // We may receive an uninitialized cache with a relative cache root. + let cache_root = if cache.root().is_absolute() { + cache.root().to_path_buf() + } else { + CWD.join(cache.root()) + }; + normalize_path(&cache_root).into_owned() + }) + .filter(|cache_root| !workspace_root.starts_with(cache_root)); // Add the project at the workspace root, if it exists and if it's distinct from the current // project. If it is the current project, it is added as such in the next step. @@ -1091,7 +1102,10 @@ impl Workspace { { let member_root = member_root .map_err(|err| WorkspaceErrorKind::GlobWalk(absolute_glob.clone(), err))?; - if member_root.starts_with(&cache_root) { + if external_cache_root + .as_ref() + .is_some_and(|cache_root| member_root.starts_with(cache_root)) + { debug!( "Ignoring cache directory while discovering workspace members: `{}`", member_root.simplified_display() From f95b82b4eaeedda86653a24c5368a0d60d2ec211 Mon Sep 17 00:00:00 2001 From: konstin Date: Wed, 3 Jun 2026 14:51:32 +0200 Subject: [PATCH 17/20] log snapshot --- crates/uv/tests/it/lock.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/uv/tests/it/lock.rs b/crates/uv/tests/it/lock.rs index e3d2c14a1d4e6..a4e51c45f3801 100644 --- a/crates/uv/tests/it/lock.rs +++ b/crates/uv/tests/it/lock.rs @@ -20295,7 +20295,7 @@ fn lock_explicit_default_index() -> Result<()> { dependencies = ["iniconfig==2.0.0"] [tool.uv.sources] - iniconfig = { index = "test" } + iniconfig = { index = "test" }∆∆ [[tool.uv.index]] name = "test" @@ -20379,6 +20379,7 @@ fn lock_explicit_default_index() -> Result<()> { DEBUG Searching for user configuration in: `[UV_USER_CONFIG_DIR]/uv.toml` DEBUG uv [VERSION] ([COMMIT] DATE) DEBUG Found project root: `[TEMP_DIR]/` + DEBUG No workspace root found, using project root DEBUG No Python version file found in workspace: [TEMP_DIR]/ DEBUG Using Python request `>=3.12` from `requires-python` metadata DEBUG Checking for Python environment at: `.venv` From dcd4bdb3bbbfc9670226984fc19b1ddb396bf085 Mon Sep 17 00:00:00 2001 From: konstin Date: Wed, 3 Jun 2026 14:51:50 +0200 Subject: [PATCH 18/20] . --- crates/uv/tests/it/lock.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/uv/tests/it/lock.rs b/crates/uv/tests/it/lock.rs index a4e51c45f3801..3cbbf92df9dcc 100644 --- a/crates/uv/tests/it/lock.rs +++ b/crates/uv/tests/it/lock.rs @@ -20295,7 +20295,7 @@ fn lock_explicit_default_index() -> Result<()> { dependencies = ["iniconfig==2.0.0"] [tool.uv.sources] - iniconfig = { index = "test" }∆∆ + iniconfig = { index = "test" } [[tool.uv.index]] name = "test" From 9851bbd44ea77a0dfabc30104259f778bf113066 Mon Sep 17 00:00:00 2001 From: konstin Date: Wed, 3 Jun 2026 15:10:06 +0200 Subject: [PATCH 19/20] Avoid panic when uv is running inside the cache dir --- crates/uv-workspace/src/workspace.rs | 27 +++++++++++++++++-- crates/uv/tests/it/workspace_dir.rs | 40 +++++++++++++++++++++++++++- 2 files changed, 64 insertions(+), 3 deletions(-) diff --git a/crates/uv-workspace/src/workspace.rs b/crates/uv-workspace/src/workspace.rs index 6c31bbb930ba5..8841852da5149 100644 --- a/crates/uv-workspace/src/workspace.rs +++ b/crates/uv-workspace/src/workspace.rs @@ -323,7 +323,7 @@ impl Workspace { return Err(WorkspaceError::from(WorkspaceErrorKind::MissingProject( pyproject_path, ))); - } else if let Some(workspace) = find_workspace(&project_path, options).await? { + } else if let Some(workspace) = find_workspace(&project_path, options, cache).await? { // We have found an explicit root above. workspace } else { @@ -1638,7 +1638,7 @@ impl ProjectWorkspace { if workspace.is_none() { // The project isn't an explicit workspace root, check if we're a regular workspace // member by looking for an explicit workspace root above. - workspace = find_workspace(&project_path, options).await?; + workspace = find_workspace(&project_path, options, cache).await?; } let current_project = WorkspaceMember { @@ -1726,7 +1726,30 @@ impl ProjectWorkspace { async fn find_workspace( project_root: &Path, options: &DiscoveryOptions, + cache: &Cache, ) -> Result, WorkspaceError> { + let external_cache_root = if options.stop_discovery_at.is_none() { + // We may receive an uninitialized cache with a relative cache root. + let cache_root = if cache.root().is_absolute() { + cache.root().to_path_buf() + } else { + CWD.join(cache.root()) + }; + Some(normalize_path(&cache_root).into_owned()) + } else { + None + }; + // Avoid panicking in the odd (unsupported) case that uv is running inside the cache dir. + if let Some(cache_root) = external_cache_root + && project_root.starts_with(cache_root) + { + debug!( + "Project is contained in cache directory: `{}`", + project_root.simplified_display() + ); + return Ok(None); + } + // Skip 1 to ignore the current project itself. for workspace_root in project_root .ancestors() diff --git a/crates/uv/tests/it/workspace_dir.rs b/crates/uv/tests/it/workspace_dir.rs index 426124fe609b1..fd5ef7433b6b0 100644 --- a/crates/uv/tests/it/workspace_dir.rs +++ b/crates/uv/tests/it/workspace_dir.rs @@ -1,6 +1,6 @@ use anyhow::Result; use assert_cmd::assert::OutputAssertExt; -use assert_fs::fixture::PathChild; +use assert_fs::fixture::{FileWriteStr, PathChild}; use uv_test::{copy_dir_ignore, uv_snapshot}; @@ -83,6 +83,44 @@ fn workspace_metadata_from_member() -> Result<()> { Ok(()) } +/// Test that a cached project matched by an outer workspace glob remains isolated. +#[test] +fn workspace_dir_cached_project_ignores_outer_workspace() -> Result<()> { + let mut context = uv_test::test_context!("3.12"); + let workspace = context.temp_dir.child("workspace"); + let cache_dir = workspace.child("cache"); + let cached_project = cache_dir.child("cached-project"); + + fs_err::create_dir_all(&cached_project)?; + workspace.child("pyproject.toml").write_str( + r#" + [tool.uv.workspace] + members = ["cache/cached-project"] + "#, + )?; + cached_project.child("pyproject.toml").write_str( + r#" + [project] + name = "cached-project" + version = "0.1.0" + "#, + )?; + + context.cache_dir = cache_dir; + + uv_snapshot!(context.filters(), context.workspace_dir().current_dir(&cached_project), @" + success: true + exit_code: 0 + ----- stdout ----- + [TEMP_DIR]/workspace/cache/cached-project + + ----- stderr ----- + " + ); + + Ok(()) +} + /// Test workspace dir error output for a non-existent package. #[test] fn workspace_dir_package_doesnt_exist() { From e5ede0a84fbb0b9c7f2b78136a0fc9453ad67237 Mon Sep 17 00:00:00 2001 From: konstin Date: Fri, 5 Jun 2026 12:26:30 +0200 Subject: [PATCH 20/20] tiny style fixes --- crates/uv-workspace/src/workspace.rs | 5 ++- crates/uv/tests/it/workspace.rs | 46 ++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/crates/uv-workspace/src/workspace.rs b/crates/uv-workspace/src/workspace.rs index 8841852da5149..4d0ef4045b386 100644 --- a/crates/uv-workspace/src/workspace.rs +++ b/crates/uv-workspace/src/workspace.rs @@ -55,7 +55,7 @@ impl WorkspaceCache { /// Insert a workspace discovery into the cache that may have succeeded or failed. /// /// Once an error is inserted, it will be returned to all future callers that query the failed - /// workspace root. + /// query path. fn insert(&self, result: CachedWorkspaceResult, install_path: &Path) { match result { Ok(workspace) => { @@ -73,6 +73,9 @@ impl WorkspaceCache { } /// Register workspace discovery for a root, or wait for an in-flight discovery. + /// + /// Calling this function ensures that - given a workspace root - the discovery is only done by + /// one thread. async fn register_or_wait(&self, workspace_root: &PathBuf) -> Option { self.workspaces.register_or_wait(workspace_root).await } diff --git a/crates/uv/tests/it/workspace.rs b/crates/uv/tests/it/workspace.rs index e882f06ad15aa..4f75e21edbe6f 100644 --- a/crates/uv/tests/it/workspace.rs +++ b/crates/uv/tests/it/workspace.rs @@ -2263,6 +2263,52 @@ fn transitive_dep_in_git_workspace_with_cache_inside_workspace() -> Result<()> { Resolved 4 packages in [TIME] "); + insta::with_settings!({ + filters => context.filters(), + }, { + assert_snapshot!(context.read("uv.lock"), @r#" + version = 1 + revision = 3 + requires-python = ">=3.12" + + [options] + exclude-newer = "2024-03-25T00:00:00Z" + + [[package]] + name = "consumer" + version = "0" + source = { virtual = "." } + dependencies = [ + { name = "outer" }, + { name = "workspace-member-in-subdir" }, + ] + + [package.metadata] + requires-dist = [ + { name = "outer", directory = "../" }, + { name = "workspace-member-in-subdir", git = "https://github.com/astral-sh/workspace-in-root-test?subdirectory=workspace-member-in-subdir&rev=d3ab48d2338296d47e28dbb2fb327c5e2ac4ac68" }, + ] + + [[package]] + name = "outer" + version = "0" + source = { directory = "../" } + + [[package]] + name = "uv-git-workspace-in-root" + version = "0.1.0" + source = { git = "https://github.com/astral-sh/workspace-in-root-test?rev=d3ab48d2338296d47e28dbb2fb327c5e2ac4ac68#d3ab48d2338296d47e28dbb2fb327c5e2ac4ac68" } + + [[package]] + name = "workspace-member-in-subdir" + version = "0.1.0" + source = { git = "https://github.com/astral-sh/workspace-in-root-test?subdirectory=workspace-member-in-subdir&rev=d3ab48d2338296d47e28dbb2fb327c5e2ac4ac68#d3ab48d2338296d47e28dbb2fb327c5e2ac4ac68" } + dependencies = [ + { name = "uv-git-workspace-in-root" }, + ] + "#); + }); + Ok(()) }