Skip to content
Draft
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
242 changes: 218 additions & 24 deletions crates/uv-workspace/src/workspace.rs
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ impl ProjectEnvironmentSelection {
type WorkspaceMembers = Arc<BTreeMap<PackageName, WorkspaceMember>>;
type FxOnceMap<K, V> = OnceMap<K, V, BuildHasherDefault<FxHasher>>;
type CachedWorkspaceResult = Result<Arc<Workspace>, WorkspaceError>;
type CachedWorkspaceEntry = Result<(Arc<Workspace>, Option<PackageName>), WorkspaceError>;

/// Cache for workspace discovery.
///
Expand All @@ -76,7 +77,7 @@ type CachedWorkspaceResult = Result<Arc<Workspace>, WorkspaceError>;
/// [`MemberDiscovery::Existing`], which discovers the same members when none are missing.
#[derive(Debug, Default, Clone)]
pub struct WorkspaceCache {
workspaces: Arc<FxOnceMap<PathBuf, CachedWorkspaceResult>>,
workspaces: Arc<FxOnceMap<PathBuf, CachedWorkspaceEntry>>,
}

impl WorkspaceCache {
Expand All @@ -87,19 +88,29 @@ impl WorkspaceCache {
fn insert(&self, result: CachedWorkspaceResult, install_path: &Path) {
match result {
Ok(workspace) => {
for package in workspace.packages.values() {
let mut root_project_name = None;
// `OnceMap::done` replaces an existing entry, so insert in reverse workspace
// order to preserve the first project name when member roots overlap.
for (project_name, package) in workspace.packages.iter().rev() {
// Historically, upward workspace discovery stopped at an intermediate
// `pyproject.toml`, so don't map this member to the outer workspace in that
// case.
// See: <https://github.com/astral-sh/uv/issues/19916>
if has_intermediate_pyproject(&workspace.install_path, &package.root) {
continue;
}
self.workspaces
.done(package.root.clone(), Ok(workspace.clone()));
if package.root == workspace.install_path {
root_project_name = Some(project_name.clone());
}
self.workspaces.done(
package.root.clone(),
Ok((workspace.clone(), Some(project_name.clone()))),
);
}
self.workspaces
.done(workspace.install_path.clone(), Ok(workspace));
self.workspaces.done(
workspace.install_path.clone(),
Ok((workspace, root_project_name)),
);
}
Err(err) => {
self.workspaces.done(install_path.to_path_buf(), Err(err));
Expand All @@ -112,7 +123,10 @@ impl WorkspaceCache {
/// 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<CachedWorkspaceResult> {
self.workspaces.register_or_wait(workspace_root).await
self.workspaces
.register_or_wait(workspace_root)
.await
.map(|result| result.map(|(workspace, _project_name)| workspace))
}

/// Get the cached workspace, if any, from the path to the workspace root or to a member root.
Expand All @@ -124,6 +138,16 @@ impl WorkspaceCache {
path: &Path,
member_discovery: &MemberDiscovery,
) -> Option<CachedWorkspaceResult> {
self.get_project(path, member_discovery)
.map(|result| result.map(|(workspace, _project_name)| workspace))
}

/// Get the cached workspace and the project name at the requested path, if any.
fn get_project(
&self,
path: &Path,
member_discovery: &MemberDiscovery,
) -> Option<CachedWorkspaceEntry> {
match member_discovery {
MemberDiscovery::All => self.workspaces.get(path),
MemberDiscovery::Existing => match self.workspaces.get(path) {
Expand All @@ -140,7 +164,9 @@ impl WorkspaceCache {
/// Contract: There are no parallel workspace operations, this is the only thread operating on
/// workspaces.
fn invalidate_workspace(&self, workspace: &Workspace) {
if let Some(Ok(workspace)) = self.workspaces.remove(workspace.install_path()) {
if let Some(Ok((workspace, _project_name))) =
self.workspaces.remove(workspace.install_path())
{
for member in workspace.packages.values() {
self.workspaces.remove(&member.root);
}
Expand Down Expand Up @@ -1489,22 +1515,18 @@ impl ProjectWorkspace {
options: &DiscoveryOptions,
cache: &WorkspaceCache,
) -> Result<Option<Self>, WorkspaceError> {
let workspace = match cache.get(project_root, &options.members) {
let (workspace, project_name) = match cache.get_project(project_root, &options.members) {
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)
else {
let Some(project_name) = project_name else {
return Ok(None);
};

Ok(Some(Self {
project_root: project_root.to_path_buf(),
project_name: project_name.clone(),
project_name,
workspace,
}))
}
Expand Down Expand Up @@ -2035,16 +2057,12 @@ impl VirtualProject {
);

// Fast path: The workspace is already cached.
if let Some(workspace) = workspace_cache.get(project_root, &options.members) {
let workspace = workspace?;
let virtual_project = if let Some((project_name, _member)) = workspace
.packages
.iter()
.find(|(_package_name, member)| member.root == project_root)
{
if let Some(workspace) = workspace_cache.get_project(project_root, &options.members) {
let (workspace, project_name) = workspace?;
let virtual_project = if let Some(project_name) = project_name {
Self::Project(ProjectWorkspace {
project_root: project_root.to_path_buf(),
project_name: project_name.clone(),
project_name,
workspace,
})
} else {
Expand Down Expand Up @@ -2253,7 +2271,9 @@ mod tests {
use uv_pypi_types::DependencyGroupSpecifier;

use crate::pyproject::PyProjectToml;
use crate::workspace::{DiscoveryOptions, MemberDiscovery, ProjectWorkspace, Workspace};
use crate::workspace::{
DiscoveryOptions, MemberDiscovery, ProjectWorkspace, VirtualProject, Workspace,
};
use crate::{WorkspaceCache, WorkspaceError};

async fn workspace_test(folder: &str) -> (ProjectWorkspace, String) {
Expand Down Expand Up @@ -2691,6 +2711,180 @@ mod tests {
.expect("cached workspace member ignores invalid change in the meantime");

assert!(Arc::ptr_eq(&root_workspace, &member_project.workspace));
assert_eq!(
member_project.project_name(),
&PackageName::from_str("seeds")?
);

let virtual_project = VirtualProject::discover(
root.child("packages").child("seeds").as_ref(),
&DiscoveryOptions::default(),
&cache,
&workspace_cache,
)
.await?;
let VirtualProject::Project(virtual_project) = virtual_project else {
anyhow::bail!("cached workspace member was treated as a non-project root");
};
assert_eq!(
virtual_project.project_name(),
&PackageName::from_str("seeds")?
);

Ok(())
}

#[tokio::test]
async fn workspace_cache_reuses_project_names_for_many_members() -> Result<()> {
const MEMBER_COUNT: usize = 64;

let root = tempfile::TempDir::new()?;
let root = ChildPath::new(root.path());
root.child("pyproject.toml").write_str(
r#"
[project]
name = "workspace-root"
version = "0.1.0"

[tool.uv.workspace]
members = ["packages/*"]
"#,
)?;

let mut members = Vec::with_capacity(MEMBER_COUNT);
for index in 0..MEMBER_COUNT {
let member_root = root
.child("packages")
.child(format!("member-path-{:03}", MEMBER_COUNT - index));
let project_name = PackageName::from_str(&format!("member-name-{index:03}"))?;
member_root.child("pyproject.toml").write_str(&format!(
r#"
[project]
name = "{project_name}"
version = "0.1.0"
"#
))?;
members.push((project_name, member_root));
}

let cache = Cache::from_path(env::temp_dir().join("uv-workspace-cache"));
let workspace_cache = WorkspaceCache::default();
let workspace = Workspace::discover(
root.as_ref(),
&DiscoveryOptions::default(),
&cache,
&workspace_cache,
)
.await?;
assert_eq!(workspace.packages().len(), MEMBER_COUNT + 1);

root.child("pyproject.toml")
.write_str("not valid toml >.<")?;
for (_project_name, member_root) in &members {
member_root
.child("pyproject.toml")
.write_str("not valid toml >.<")?;
}

let options = DiscoveryOptions {
members: MemberDiscovery::Existing,
..DiscoveryOptions::default()
};
for (project_name, member_root) in members.iter().rev() {
let project = ProjectWorkspace::from_maybe_project_root(
member_root.as_ref(),
&options,
&cache,
&workspace_cache,
)
.await?
.expect("cached workspace member is a project");
assert_eq!(project.project_name(), project_name);
assert!(Arc::ptr_eq(&project.workspace, &workspace));

let VirtualProject::Project(project) =
VirtualProject::discover(member_root.as_ref(), &options, &cache, &workspace_cache)
.await?
else {
anyhow::bail!("cached workspace member was treated as a non-project root");
};
assert_eq!(project.project_name(), project_name);
assert!(Arc::ptr_eq(&project.workspace, &workspace));
}

let root_project = ProjectWorkspace::from_maybe_project_root(
root.as_ref(),
&options,
&cache,
&workspace_cache,
)
.await?
.expect("cached workspace root is a project");
assert_eq!(
root_project.project_name(),
&PackageName::from_str("workspace-root")?
);

Ok(())
}

#[tokio::test]
async fn workspace_cache_reuses_non_project_workspace_root() -> Result<()> {
let root = tempfile::TempDir::new()?;
let root = ChildPath::new(root.path());
root.child("pyproject.toml").write_str(
r#"
[tool.uv.workspace]
members = ["packages/*"]
"#,
)?;
let member_root = root.child("packages").child("member");
member_root.child("pyproject.toml").write_str(
r#"
[project]
name = "workspace-member"
version = "0.1.0"
"#,
)?;

let cache = Cache::from_path(env::temp_dir().join("uv-workspace-cache"));
let workspace_cache = WorkspaceCache::default();
let workspace = Workspace::discover(
root.as_ref(),
&DiscoveryOptions::default(),
&cache,
&workspace_cache,
)
.await?;

root.child("pyproject.toml")
.write_str("not valid toml >.<")?;
member_root
.child("pyproject.toml")
.write_str("not valid toml >.<")?;

let options = DiscoveryOptions {
members: MemberDiscovery::Existing,
..DiscoveryOptions::default()
};
let VirtualProject::NonProject(cached_workspace) =
VirtualProject::discover(root.as_ref(), &options, &cache, &workspace_cache).await?
else {
anyhow::bail!("cached non-project workspace root was treated as a project");
};
assert!(Arc::ptr_eq(&workspace, &cached_workspace));

let VirtualProject::Project(project) =
VirtualProject::discover(member_root.as_ref(), &options, &cache, &workspace_cache)
.await?
else {
anyhow::bail!("cached workspace member was treated as a non-project root");
};
assert_eq!(
project.project_name(),
&PackageName::from_str("workspace-member")?
);
assert!(Arc::ptr_eq(&project.workspace, &workspace));

Ok(())
}
Expand Down
Loading