uv/crates/uv-workspace/src/workspace.rs

1503 lines
54 KiB
Rust

//! Resolve the current [`ProjectWorkspace`] or [`Workspace`].
use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};
use glob::{glob, GlobError, PatternError};
use rustc_hash::FxHashSet;
use tracing::{debug, trace, warn};
use uv_distribution_types::Index;
use uv_fs::{Simplified, CWD};
use uv_normalize::{GroupName, PackageName, DEV_DEPENDENCIES};
use uv_pep508::{MarkerTree, RequirementOrigin, VerbatimUrl};
use uv_pypi_types::{Conflicts, Requirement, RequirementSource, SupportedEnvironments};
use uv_static::EnvVars;
use uv_warnings::{warn_user, warn_user_once};
use crate::dependency_groups::{DependencyGroupError, FlatDependencyGroups};
use crate::pyproject::{
DependencyGroups, Project, PyProjectToml, PyprojectTomlError, Sources, ToolUvSources,
ToolUvWorkspace,
};
#[derive(thiserror::Error, Debug)]
pub enum WorkspaceError {
// Workspace structure errors.
#[error("No `pyproject.toml` found in current directory or any parent directory")]
MissingPyprojectToml,
#[error("Workspace member `{}` is missing a `pyproject.toml` (matches: `{1}`)", _0.simplified_display())]
MissingPyprojectTomlMember(PathBuf, String),
#[error("No `project` table found in: `{}`", _0.simplified_display())]
MissingProject(PathBuf),
#[error("No workspace found for: `{}`", _0.simplified_display())]
MissingWorkspace(PathBuf),
#[error("The project is marked as unmanaged: `{}`", _0.simplified_display())]
NonWorkspace(PathBuf),
#[error("pyproject.toml section is declared as dynamic, but must be static: `{0}`")]
DynamicNotAllowed(&'static str),
#[error("Failed to find directories for glob: `{0}`")]
Pattern(String, #[source] PatternError),
// Syntax and other errors.
#[error("Invalid glob in `tool.uv.workspace.members`: `{0}`")]
Glob(String, #[source] GlobError),
#[error(transparent)]
Io(#[from] std::io::Error),
#[error("Failed to parse: `{}`", _0.user_display())]
Toml(PathBuf, #[source] Box<PyprojectTomlError>),
#[error("Failed to normalize workspace member path")]
Normalize(#[source] std::io::Error),
}
#[derive(Debug, Default, Clone)]
pub enum MemberDiscovery<'a> {
/// Discover all workspace members.
#[default]
All,
/// Don't discover any workspace members.
None,
/// Discover workspace members, but ignore the given paths.
Ignore(FxHashSet<&'a Path>),
}
#[derive(Debug, Default, Clone)]
pub struct DiscoveryOptions<'a> {
/// The path to stop discovery at.
pub stop_discovery_at: Option<&'a Path>,
/// The strategy to use when discovering workspace members.
pub members: MemberDiscovery<'a>,
}
/// A workspace, consisting of a root directory and members. See [`ProjectWorkspace`].
#[derive(Debug, Clone)]
#[cfg_attr(test, derive(serde::Serialize))]
pub struct Workspace {
/// The path to the workspace root.
///
/// The workspace root is the directory containing the top level `pyproject.toml` with
/// the `uv.tool.workspace`, or the `pyproject.toml` in an implicit single workspace project.
install_path: PathBuf,
/// The members of the workspace.
packages: BTreeMap<PackageName, WorkspaceMember>,
/// The sources table from the workspace `pyproject.toml`.
///
/// This table is overridden by the project sources.
sources: BTreeMap<PackageName, Sources>,
/// The index table from the workspace `pyproject.toml`.
///
/// This table is overridden by the project indexes.
indexes: Vec<Index>,
/// The `pyproject.toml` of the workspace root.
pyproject_toml: PyProjectToml,
}
impl Workspace {
/// Find the workspace containing the given path.
///
/// Unlike the [`ProjectWorkspace`] discovery, this does not require a current project. It also
/// always uses absolute path, i.e., this method only supports discovering the main workspace.
///
/// Steps of workspace discovery: Start by looking at the closest `pyproject.toml`:
/// * If it's an explicit workspace root: Collect workspace from this root, we're done.
/// * If it's also not a project: Error, must be either a workspace root or a project.
/// * Otherwise, try to find an explicit workspace root above:
/// * If an explicit workspace root exists: Collect workspace from this root, we're done.
/// * If there is no explicit workspace: We have a single project workspace, we're done.
///
/// Note that there are two kinds of workspace roots: projects, and (legacy) non-project roots.
/// The non-project roots lack a `[project]` table, and so are not themselves projects, as in:
/// ```toml
/// [tool.uv.workspace]
/// members = ["packages/*"]
///
/// [tool.uv]
/// dev-dependencies = ["ruff"]
/// ```
pub async fn discover(
path: &Path,
options: &DiscoveryOptions<'_>,
) -> Result<Workspace, WorkspaceError> {
let path = std::path::absolute(path)
.map_err(WorkspaceError::Normalize)?
.clone();
let project_path = path
.ancestors()
.find(|path| path.join("pyproject.toml").is_file())
.ok_or(WorkspaceError::MissingPyprojectToml)?
.to_path_buf();
let pyproject_path = project_path.join("pyproject.toml");
let contents = fs_err::tokio::read_to_string(&pyproject_path).await?;
let pyproject_toml = PyProjectToml::from_string(contents)
.map_err(|err| WorkspaceError::Toml(pyproject_path.clone(), Box::new(err)))?;
// Check if the project is explicitly marked as unmanaged.
if pyproject_toml
.tool
.as_ref()
.and_then(|tool| tool.uv.as_ref())
.and_then(|uv| uv.managed)
== Some(false)
{
debug!(
"Project `{}` is marked as unmanaged",
project_path.simplified_display()
);
return Err(WorkspaceError::NonWorkspace(project_path));
}
// Check if the current project is also an explicit workspace root.
let explicit_root = pyproject_toml
.tool
.as_ref()
.and_then(|tool| tool.uv.as_ref())
.and_then(|uv| uv.workspace.as_ref())
.map(|workspace| {
(
project_path.clone(),
workspace.clone(),
pyproject_toml.clone(),
)
});
let (workspace_root, workspace_definition, workspace_pyproject_toml) =
if let Some(workspace) = explicit_root {
// We have found the explicit root immediately.
workspace
} else if pyproject_toml.project.is_none() {
// Without a project, it can't be an implicit root
return Err(WorkspaceError::MissingProject(pyproject_path));
} else if let Some(workspace) = find_workspace(&project_path, options).await? {
// We have found an explicit root above.
workspace
} else {
// Support implicit single project workspaces.
(
project_path.clone(),
ToolUvWorkspace::default(),
pyproject_toml.clone(),
)
};
debug!(
"Found workspace root: `{}`",
workspace_root.simplified_display()
);
check_nested_workspaces(&workspace_root, options);
// Unlike in `ProjectWorkspace` discovery, we might be in a legacy non-project root without
// being in any specific project.
let current_project = pyproject_toml
.project
.clone()
.map(|project| WorkspaceMember {
root: project_path,
project,
pyproject_toml,
});
Self::collect_members(
workspace_root.clone(),
workspace_definition,
workspace_pyproject_toml,
current_project,
options,
)
.await
}
/// Set the current project to the given workspace member.
///
/// Returns `None` if the package is not part of the workspace.
pub fn with_current_project(self, package_name: PackageName) -> Option<ProjectWorkspace> {
let member = self.packages.get(&package_name)?;
Some(ProjectWorkspace {
project_root: member.root().clone(),
project_name: package_name,
workspace: self,
})
}
/// Set the [`ProjectWorkspace`] for a given workspace member.
///
/// Assumes that the project name is unchanged in the updated [`PyProjectToml`].
#[must_use]
pub fn with_pyproject_toml(
self,
package_name: &PackageName,
pyproject_toml: PyProjectToml,
) -> Option<Self> {
let mut packages = self.packages;
let member = packages.get_mut(package_name)?;
if member.root == self.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();
// Refresh the workspace sources.
let workspace_sources = workspace_pyproject_toml
.tool
.clone()
.and_then(|tool| tool.uv)
.and_then(|uv| uv.sources)
.map(ToolUvSources::into_inner)
.unwrap_or_default();
// Set the `pyproject.toml` for the member.
member.pyproject_toml = pyproject_toml;
Some(Self {
pyproject_toml: workspace_pyproject_toml,
sources: workspace_sources,
packages,
..self
})
} else {
// Set the `pyproject.toml` for the member.
member.pyproject_toml = pyproject_toml;
Some(Self { packages, ..self })
}
}
/// Returns `true` if the workspace has a (legacy) non-project root.
pub fn is_non_project(&self) -> bool {
!self
.packages
.values()
.any(|member| *member.root() == self.install_path)
}
/// Returns the set of requirements that include all packages in the workspace.
pub fn members_requirements(&self) -> impl Iterator<Item = Requirement> + '_ {
self.packages.values().filter_map(|member| {
let url = VerbatimUrl::from_absolute_path(&member.root)
.expect("path is valid URL")
.with_given(member.root.to_string_lossy());
Some(Requirement {
name: member.pyproject_toml.project.as_ref()?.name.clone(),
extras: vec![],
marker: MarkerTree::TRUE,
source: if member.pyproject_toml.is_package() {
RequirementSource::Directory {
install_path: member.root.clone(),
editable: true,
r#virtual: false,
url,
}
} else {
RequirementSource::Directory {
install_path: member.root.clone(),
editable: false,
r#virtual: true,
url,
}
},
origin: None,
})
})
}
/// Returns any requirements that are exclusive to the workspace root, i.e., not included in
/// any of the workspace members.
///
/// For workspaces with non-project roots, returns the dev dependencies in the corresponding
/// `pyproject.toml`.
///
/// Otherwise, returns an empty list.
pub fn non_project_requirements(&self) -> Result<Vec<Requirement>, DependencyGroupError> {
if self
.packages
.values()
.any(|member| *member.root() == self.install_path)
{
// If the workspace has an explicit root, the root is a member, so we don't need to
// include any root-only requirements.
Ok(Vec::new())
} else {
// Otherwise, return the dependency groups in the non-project workspace root.
// First, collect `tool.uv.dev_dependencies`
let dev_dependencies = self
.pyproject_toml
.tool
.as_ref()
.and_then(|tool| tool.uv.as_ref())
.and_then(|uv| uv.dev_dependencies.as_ref());
// Then, collect `dependency-groups`
let dependency_groups = self
.pyproject_toml
.dependency_groups
.iter()
.flatten()
.collect::<BTreeMap<_, _>>();
// Resolve any `include-group` entries in `dependency-groups`.
let dependency_groups =
FlatDependencyGroups::from_dependency_groups(&dependency_groups)?;
// Concatenate the two sets of requirements.
let dev_dependencies = dependency_groups
.into_iter()
.flat_map(|(_, requirements)| requirements)
.map(|requirement| {
Requirement::from(requirement.with_origin(RequirementOrigin::Workspace))
})
.chain(dev_dependencies.into_iter().flatten().map(|requirement| {
Requirement::from(
requirement
.clone()
.with_origin(RequirementOrigin::Workspace),
)
}))
.collect();
Ok(dev_dependencies)
}
}
/// Returns the set of overrides for the workspace.
pub fn overrides(&self) -> Vec<Requirement> {
let Some(overrides) = self
.pyproject_toml
.tool
.as_ref()
.and_then(|tool| tool.uv.as_ref())
.and_then(|uv| uv.override_dependencies.as_ref())
else {
return vec![];
};
overrides
.iter()
.map(|requirement| {
Requirement::from(
requirement
.clone()
.with_origin(RequirementOrigin::Workspace),
)
})
.collect()
}
/// Returns the set of supported environments for the workspace.
pub fn environments(&self) -> Option<&SupportedEnvironments> {
self.pyproject_toml
.tool
.as_ref()
.and_then(|tool| tool.uv.as_ref())
.and_then(|uv| uv.environments.as_ref())
}
/// Returns the set of conflicts for the workspace.
pub fn conflicts(&self) -> Conflicts {
let mut conflicting = Conflicts::empty();
for member in self.packages.values() {
conflicting.append(&mut member.pyproject_toml.conflicts());
}
conflicting
}
/// Returns the set of constraints for the workspace.
pub fn constraints(&self) -> Vec<Requirement> {
let Some(constraints) = self
.pyproject_toml
.tool
.as_ref()
.and_then(|tool| tool.uv.as_ref())
.and_then(|uv| uv.constraint_dependencies.as_ref())
else {
return vec![];
};
constraints
.iter()
.map(|requirement| {
Requirement::from(
requirement
.clone()
.with_origin(RequirementOrigin::Workspace),
)
})
.collect()
}
/// Returns the set of all dependency group names defined in the workspace.
pub fn groups(&self) -> BTreeSet<&GroupName> {
self.pyproject_toml
.dependency_groups
.iter()
.flat_map(DependencyGroups::keys)
.chain(
self.packages
.values()
.filter_map(|member| member.pyproject_toml.dependency_groups.as_ref())
.flat_map(DependencyGroups::keys),
)
.chain(
if self
.pyproject_toml
.tool
.as_ref()
.and_then(|tool| tool.uv.as_ref())
.and_then(|uv| uv.dev_dependencies.as_ref())
.is_some()
|| self.packages.values().any(|member| {
member
.pyproject_toml
.tool
.as_ref()
.and_then(|tool| tool.uv.as_ref())
.and_then(|uv| uv.dev_dependencies.as_ref())
.is_some()
})
{
Some(&*DEV_DEPENDENCIES)
} else {
None
},
)
.collect()
}
/// The path to the workspace root, the directory containing the top level `pyproject.toml` with
/// the `uv.tool.workspace`, or the `pyproject.toml` in an implicit single workspace project.
pub fn install_path(&self) -> &PathBuf {
&self.install_path
}
/// The path to the workspace virtual environment.
///
/// Uses `.venv` in the install path directory by default.
///
/// If `UV_PROJECT_ENVIRONMENT` is set, it will take precedence. If a relative path is provided,
/// it is resolved relative to the install path.
pub fn venv(&self) -> PathBuf {
/// Resolve the `UV_PROJECT_ENVIRONMENT` value, if any.
fn from_project_environment_variable(workspace: &Workspace) -> Option<PathBuf> {
let value = std::env::var_os(EnvVars::UV_PROJECT_ENVIRONMENT)?;
if value.is_empty() {
return None;
};
let path = PathBuf::from(value);
if path.is_absolute() {
return Some(path);
};
// Resolve the path relative to the install path.
Some(workspace.install_path.join(path))
}
// Resolve the `VIRTUAL_ENV` variable, if any.
fn from_virtual_env_variable() -> Option<PathBuf> {
let value = std::env::var_os(EnvVars::VIRTUAL_ENV)?;
if value.is_empty() {
return None;
};
let path = PathBuf::from(value);
if path.is_absolute() {
return Some(path);
};
// Resolve the path relative to current directory.
// Note this differs from `UV_PROJECT_ENVIRONMENT`
Some(CWD.join(path))
}
// Attempt to check if the two paths refer to the same directory.
fn is_same_dir(left: &Path, right: &Path) -> Option<bool> {
// First, attempt to check directly
if let Ok(value) = same_file::is_same_file(left, right) {
return Some(value);
};
// Often, one of the directories won't exist yet so perform the comparison up a level
if let (Some(left_parent), Some(right_parent), Some(left_name), Some(right_name)) = (
left.parent(),
right.parent(),
left.file_name(),
right.file_name(),
) {
match same_file::is_same_file(left_parent, right_parent) {
Ok(true) => return Some(left_name == right_name),
Ok(false) => return Some(false),
_ => (),
}
};
// We couldn't determine if they're the same
None
}
// Determine the default value
let project_env = from_project_environment_variable(self)
.unwrap_or_else(|| self.install_path.join(".venv"));
// Warn if it conflicts with `VIRTUAL_ENV`
if let Some(from_virtual_env) = from_virtual_env_variable() {
if !is_same_dir(&from_virtual_env, &project_env).unwrap_or(false) {
warn_user_once!(
"`VIRTUAL_ENV={}` does not match the project environment path `{}` and will be ignored",
from_virtual_env.user_display(),
project_env.user_display()
);
}
}
project_env
}
/// The members of the workspace.
pub fn packages(&self) -> &BTreeMap<PackageName, WorkspaceMember> {
&self.packages
}
/// The sources table from the workspace `pyproject.toml`.
pub fn sources(&self) -> &BTreeMap<PackageName, Sources> {
&self.sources
}
/// The index table from the workspace `pyproject.toml`.
pub fn indexes(&self) -> &[Index] {
&self.indexes
}
/// The `pyproject.toml` of the workspace.
pub fn pyproject_toml(&self) -> &PyProjectToml {
&self.pyproject_toml
}
/// Returns `true` if the path is excluded by the workspace.
pub fn excludes(&self, project_path: &Path) -> Result<bool, WorkspaceError> {
if let Some(workspace) = self
.pyproject_toml
.tool
.as_ref()
.and_then(|tool| tool.uv.as_ref())
.and_then(|uv| uv.workspace.as_ref())
{
is_excluded_from_workspace(project_path, &self.install_path, workspace)
} else {
Ok(false)
}
}
/// Returns `true` if the path is included by the workspace.
pub fn includes(&self, project_path: &Path) -> Result<bool, WorkspaceError> {
if let Some(workspace) = self
.pyproject_toml
.tool
.as_ref()
.and_then(|tool| tool.uv.as_ref())
.and_then(|uv| uv.workspace.as_ref())
{
is_included_in_workspace(project_path, &self.install_path, workspace)
} else {
Ok(false)
}
}
/// Collect the workspace member projects from the `members` and `excludes` entries.
async fn collect_members(
workspace_root: PathBuf,
workspace_definition: ToolUvWorkspace,
workspace_pyproject_toml: PyProjectToml,
current_project: Option<WorkspaceMember>,
options: &DiscoveryOptions<'_>,
) -> Result<Workspace, WorkspaceError> {
let mut workspace_members = BTreeMap::new();
// Avoid reading a `pyproject.toml` more than once.
let mut seen = FxHashSet::default();
// Add the project at the workspace root, if it exists and if it's distinct from the current
// project.
if current_project
.as_ref()
.map(|root_member| root_member.root != workspace_root)
.unwrap_or(true)
{
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)
.map_err(|err| WorkspaceError::Toml(pyproject_path.clone(), Box::new(err)))?;
debug!(
"Adding root workspace member: `{}`",
workspace_root.simplified_display()
);
seen.insert(workspace_root.clone());
workspace_members.insert(
project.name.clone(),
WorkspaceMember {
root: workspace_root.clone(),
project: project.clone(),
pyproject_toml,
},
);
};
}
// The current project is a workspace member, especially in a single project workspace.
if let Some(root_member) = current_project {
debug!(
"Adding current workspace member: `{}`",
root_member.root.simplified_display()
);
seen.insert(root_member.root.clone());
workspace_members.insert(root_member.project.name.clone(), root_member);
}
// Add all other workspace members.
for member_glob in workspace_definition.clone().members.unwrap_or_default() {
let absolute_glob = PathBuf::from(glob::Pattern::escape(
workspace_root.simplified().to_string_lossy().as_ref(),
))
.join(member_glob.as_str())
.to_string_lossy()
.to_string();
for member_root in glob(&absolute_glob)
.map_err(|err| WorkspaceError::Pattern(absolute_glob.to_string(), err))?
{
let member_root = member_root
.map_err(|err| WorkspaceError::Glob(absolute_glob.to_string(), err))?;
if !seen.insert(member_root.clone()) {
continue;
}
let member_root = std::path::absolute(&member_root)
.map_err(WorkspaceError::Normalize)?
.clone();
// If the directory is explicitly ignored, skip it.
let skip = match &options.members {
MemberDiscovery::All => false,
MemberDiscovery::None => true,
MemberDiscovery::Ignore(ignore) => ignore.contains(member_root.as_path()),
};
if skip {
debug!(
"Ignoring workspace member: `{}`",
member_root.simplified_display()
);
continue;
}
// If the member is excluded, ignore it.
if is_excluded_from_workspace(&member_root, &workspace_root, &workspace_definition)?
{
debug!(
"Ignoring workspace member: `{}`",
member_root.simplified_display()
);
continue;
}
trace!(
"Processing workspace member: `{}`",
member_root.user_display()
);
// Read the member `pyproject.toml`.
let pyproject_path = member_root.join("pyproject.toml");
let contents = match fs_err::tokio::read_to_string(&pyproject_path).await {
Ok(contents) => contents,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
// If the directory is hidden, skip it.
if member_root
.file_name()
.map(|name| name.as_encoded_bytes().starts_with(b"."))
.unwrap_or(false)
{
debug!(
"Ignoring hidden workspace member: `{}`",
member_root.simplified_display()
);
continue;
}
return Err(WorkspaceError::MissingPyprojectTomlMember(
member_root,
member_glob.to_string(),
));
}
// If the entry is _not_ a directory, skip it.
Err(_) if !member_root.is_dir() => {
warn!(
"Ignoring non-directory workspace member: `{}`",
member_root.simplified_display()
);
continue;
}
Err(err) => return Err(err.into()),
};
let pyproject_toml = PyProjectToml::from_string(contents)
.map_err(|err| WorkspaceError::Toml(pyproject_path.clone(), Box::new(err)))?;
// Check if the current project is explicitly marked as unmanaged.
if pyproject_toml
.tool
.as_ref()
.and_then(|tool| tool.uv.as_ref())
.and_then(|uv| uv.managed)
== Some(false)
{
debug!(
"Project `{}` is marked as unmanaged; omitting from workspace members",
pyproject_toml.project.as_ref().unwrap().name
);
continue;
}
// Extract the package name.
let Some(project) = pyproject_toml.project.clone() else {
return Err(WorkspaceError::MissingProject(pyproject_path));
};
debug!(
"Adding discovered workspace member: `{}`",
member_root.simplified_display()
);
workspace_members.insert(
project.name.clone(),
WorkspaceMember {
root: member_root.clone(),
project,
pyproject_toml,
},
);
}
}
let workspace_sources = workspace_pyproject_toml
.tool
.clone()
.and_then(|tool| tool.uv)
.and_then(|uv| uv.sources)
.map(ToolUvSources::into_inner)
.unwrap_or_default();
let workspace_indexes = workspace_pyproject_toml
.tool
.clone()
.and_then(|tool| tool.uv)
.and_then(|uv| uv.index)
.unwrap_or_default();
Ok(Workspace {
install_path: workspace_root,
packages: workspace_members,
sources: workspace_sources,
indexes: workspace_indexes,
pyproject_toml: workspace_pyproject_toml,
})
}
}
/// A project in a workspace.
#[derive(Debug, Clone)]
#[cfg_attr(test, derive(serde::Serialize))]
pub struct WorkspaceMember {
/// The path to the project root.
root: PathBuf,
/// The `[project]` table, from the `pyproject.toml` of the project found at
/// `<root>/pyproject.toml`.
project: Project,
/// The `pyproject.toml` of the project, found at `<root>/pyproject.toml`.
pyproject_toml: PyProjectToml,
}
impl WorkspaceMember {
/// The path to the project root.
pub fn root(&self) -> &PathBuf {
&self.root
}
/// The `[project]` table, from the `pyproject.toml` of the project found at
/// `<root>/pyproject.toml`.
pub fn project(&self) -> &Project {
&self.project
}
/// The `pyproject.toml` of the project, found at `<root>/pyproject.toml`.
pub fn pyproject_toml(&self) -> &PyProjectToml {
&self.pyproject_toml
}
}
/// The current project and the workspace it is part of, with all of the workspace members.
///
/// # Structure
///
/// The workspace root is a directory with a `pyproject.toml`, all members need to be below that
/// directory. The workspace root defines members and exclusions. All packages below it must either
/// be a member or excluded. The workspace root can be a package itself or a virtual manifest.
///
/// For a simple single package project, the workspace root is implicitly the current project root
/// and the workspace has only this single member. Otherwise, a workspace root is declared through
/// a `tool.uv.workspace` section.
///
/// A workspace itself does not declare dependencies, instead one member is the current project used
/// as main requirement.
///
/// Each member is a directory with a `pyproject.toml` that contains a `[project]` section. Each
/// member is a Python package, with a name, a version and dependencies. Workspace members can
/// depend on other workspace members (`foo = { workspace = true }`). You can consider the
/// workspace another package source or index, similar to `--find-links`.
///
/// # Usage
///
/// There a two main usage patterns: A root package and helpers, and the flat workspace.
///
/// Root package and helpers:
///
/// ```text
/// albatross
/// ├── packages
/// │ ├── provider_a
/// │ │ ├── pyproject.toml
/// │ │ └── src
/// │ │ └── provider_a
/// │ │ ├── __init__.py
/// │ │ └── foo.py
/// │ └── provider_b
/// │ ├── pyproject.toml
/// │ └── src
/// │ └── provider_b
/// │ ├── __init__.py
/// │ └── bar.py
/// ├── pyproject.toml
/// ├── Readme.md
/// ├── uv.lock
/// └── src
/// └── albatross
/// ├── __init__.py
/// └── main.py
/// ```
///
/// Flat workspace:
///
/// ```text
/// albatross
/// ├── packages
/// │ ├── albatross
/// │ │ ├── pyproject.toml
/// │ │ └── src
/// │ │ └── albatross
/// │ │ ├── __init__.py
/// │ │ └── main.py
/// │ ├── provider_a
/// │ │ ├── pyproject.toml
/// │ │ └── src
/// │ │ └── provider_a
/// │ │ ├── __init__.py
/// │ │ └── foo.py
/// │ └── provider_b
/// │ ├── pyproject.toml
/// │ └── src
/// │ └── provider_b
/// │ ├── __init__.py
/// │ └── bar.py
/// ├── pyproject.toml
/// ├── Readme.md
/// └── uv.lock
/// ```
#[derive(Debug, Clone)]
#[cfg_attr(test, derive(serde::Serialize))]
pub struct ProjectWorkspace {
/// The path to the project root.
project_root: PathBuf,
/// The name of the package.
project_name: PackageName,
/// The workspace the project is part of.
workspace: Workspace,
}
impl ProjectWorkspace {
/// 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,
/// only directories between the current path and `stop_discovery_at` are considered.
pub async fn discover(
path: &Path,
options: &DiscoveryOptions<'_>,
) -> Result<Self, WorkspaceError> {
let project_root = path
.ancestors()
.take_while(|path| {
// Only walk up the given directory, if any.
options
.stop_discovery_at
.and_then(Path::parent)
.map(|stop_discovery_at| stop_discovery_at != *path)
.unwrap_or(true)
})
.find(|path| path.join("pyproject.toml").is_file())
.ok_or(WorkspaceError::MissingPyprojectToml)?;
debug!(
"Found project root: `{}`",
project_root.simplified_display()
);
Self::from_project_root(project_root, options).await
}
/// Discover the workspace starting from the directory containing the `pyproject.toml`.
async fn from_project_root(
project_root: &Path,
options: &DiscoveryOptions<'_>,
) -> Result<Self, WorkspaceError> {
// 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)
.map_err(|err| WorkspaceError::Toml(pyproject_path.clone(), Box::new(err)))?;
// It must have a `[project]` table.
let project = pyproject_toml
.project
.clone()
.ok_or_else(|| WorkspaceError::MissingProject(pyproject_path))?;
Self::from_project(project_root, &project, &pyproject_toml, options).await
}
/// If the current directory contains a `pyproject.toml` with a `project` table, discover the
/// workspace and return it, otherwise it is a dynamic path dependency and we return `Ok(None)`.
pub async fn from_maybe_project_root(
install_path: &Path,
options: &DiscoveryOptions<'_>,
) -> Result<Option<Self>, WorkspaceError> {
// Read the `pyproject.toml`.
let pyproject_path = install_path.join("pyproject.toml");
let Ok(contents) = fs_err::tokio::read_to_string(&pyproject_path).await else {
// No `pyproject.toml`, but there may still be a `setup.py` or `setup.cfg`.
return Ok(None);
};
let pyproject_toml = PyProjectToml::from_string(contents)
.map_err(|err| WorkspaceError::Toml(pyproject_path.clone(), Box::new(err)))?;
// Extract the `[project]` metadata.
let Some(project) = pyproject_toml.project.clone() else {
// We have to build to get the metadata.
return Ok(None);
};
match Self::from_project(install_path, &project, &pyproject_toml, options).await {
Ok(workspace) => Ok(Some(workspace)),
Err(WorkspaceError::NonWorkspace(_)) => Ok(None),
Err(err) => Err(err),
}
}
/// Returns the directory containing the closest `pyproject.toml` that defines the current
/// project.
pub fn project_root(&self) -> &Path {
&self.project_root
}
/// Returns the [`PackageName`] of the current project.
pub fn project_name(&self) -> &PackageName {
&self.project_name
}
/// Returns the [`Workspace`] containing the current project.
pub fn workspace(&self) -> &Workspace {
&self.workspace
}
/// Returns the current project as a [`WorkspaceMember`].
pub fn current_project(&self) -> &WorkspaceMember {
&self.workspace().packages[&self.project_name]
}
/// Set the `pyproject.toml` for the current project.
///
/// Assumes that the project name is unchanged in the updated [`PyProjectToml`].
#[must_use]
pub fn with_pyproject_toml(self, pyproject_toml: PyProjectToml) -> Option<Self> {
Some(Self {
workspace: self
.workspace
.with_pyproject_toml(&self.project_name, pyproject_toml)?,
..self
})
}
/// Find the workspace for a project.
pub async fn from_project(
install_path: &Path,
project: &Project,
project_pyproject_toml: &PyProjectToml,
options: &DiscoveryOptions<'_>,
) -> Result<Self, WorkspaceError> {
let project_path = std::path::absolute(install_path)
.map_err(WorkspaceError::Normalize)?
.clone();
// Check if workspaces are explicitly disabled for the project.
if project_pyproject_toml
.tool
.as_ref()
.and_then(|tool| tool.uv.as_ref())
.and_then(|uv| uv.managed)
== Some(false)
{
debug!("Project `{}` is marked as unmanaged", project.name);
return Err(WorkspaceError::NonWorkspace(project_path));
}
// Check if the current project is also an explicit workspace root.
let mut workspace = project_pyproject_toml
.tool
.as_ref()
.and_then(|tool| tool.uv.as_ref())
.and_then(|uv| uv.workspace.as_ref())
.map(|workspace| {
(
project_path.clone(),
workspace.clone(),
project_pyproject_toml.clone(),
)
});
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?;
}
let current_project = WorkspaceMember {
root: project_path.clone(),
project: project.clone(),
pyproject_toml: project_pyproject_toml.clone(),
};
let Some((workspace_root, workspace_definition, workspace_pyproject_toml)) = workspace
else {
// The project isn't an explicit workspace root, but there's also no workspace root
// above it, so the project is an implicit workspace root identical to the project root.
debug!("No workspace root found, using project root");
let current_project_as_members =
BTreeMap::from_iter([(project.name.clone(), current_project)]);
return Ok(Self {
project_root: project_path.clone(),
project_name: project.name.clone(),
workspace: Workspace {
install_path: project_path.clone(),
packages: current_project_as_members,
// There may be package sources, but we don't need to duplicate them into the
// workspace sources.
sources: BTreeMap::default(),
indexes: Vec::default(),
pyproject_toml: project_pyproject_toml.clone(),
},
});
};
debug!(
"Found workspace root: `{}`",
workspace_root.simplified_display()
);
let workspace = Workspace::collect_members(
workspace_root,
workspace_definition,
workspace_pyproject_toml,
Some(current_project),
options,
)
.await?;
Ok(Self {
project_root: project_path,
project_name: project.name.clone(),
workspace,
})
}
}
/// Find the workspace root above the current project, if any.
async fn find_workspace(
project_root: &Path,
options: &DiscoveryOptions<'_>,
) -> Result<Option<(PathBuf, ToolUvWorkspace, PyProjectToml)>, WorkspaceError> {
// Skip 1 to ignore the current project itself.
for workspace_root in project_root
.ancestors()
.take_while(|path| {
// Only walk up the given directory, if any.
options
.stop_discovery_at
.and_then(Path::parent)
.map(|stop_discovery_at| stop_discovery_at != *path)
.unwrap_or(true)
})
.skip(1)
{
let pyproject_path = workspace_root.join("pyproject.toml");
if !pyproject_path.is_file() {
continue;
}
trace!(
"Found `pyproject.toml` at: `{}`",
pyproject_path.simplified_display()
);
// Read the `pyproject.toml`.
let contents = fs_err::tokio::read_to_string(&pyproject_path).await?;
let pyproject_toml = PyProjectToml::from_string(contents)
.map_err(|err| WorkspaceError::Toml(pyproject_path.clone(), Box::new(err)))?;
return if let Some(workspace) = pyproject_toml
.tool
.as_ref()
.and_then(|tool| tool.uv.as_ref())
.and_then(|uv| uv.workspace.as_ref())
{
if !is_included_in_workspace(project_root, workspace_root, workspace)? {
debug!(
"Found workspace root `{}`, but project is not included",
workspace_root.simplified_display()
);
return Ok(None);
}
if is_excluded_from_workspace(project_root, workspace_root, workspace)? {
debug!(
"Found workspace root `{}`, but project is excluded",
workspace_root.simplified_display()
);
return Ok(None);
}
// We found a workspace root.
Ok(Some((
workspace_root.to_path_buf(),
workspace.clone(),
pyproject_toml,
)))
} else if pyproject_toml.project.is_some() {
// We're in a directory of another project, e.g. tests or examples.
// Example:
// ```
// albatross
// ├── examples
// │ └── bird-feeder [CURRENT DIRECTORY]
// │ ├── pyproject.toml
// │ └── src
// │ └── bird_feeder
// │ └── __init__.py
// ├── pyproject.toml
// └── src
// └── albatross
// └── __init__.py
// ```
// The current project is the example (non-workspace) `bird-feeder` in `albatross`,
// we ignore all `albatross` is doing and any potential workspace it might be
// contained in.
debug!(
"Project is contained in non-workspace project: `{}`",
workspace_root.simplified_display()
);
Ok(None)
} else {
// We require that a `project.toml` file either declares a workspace or a project.
warn!(
"`pyproject.toml` does not contain a `project` table: `{}`",
pyproject_path.simplified_display()
);
Ok(None)
};
}
Ok(None)
}
/// Warn when the valid workspace is included in another workspace.
pub fn check_nested_workspaces(inner_workspace_root: &Path, options: &DiscoveryOptions) {
for outer_workspace_root in inner_workspace_root
.ancestors()
.take_while(|path| {
// Only walk up the given directory, if any.
options
.stop_discovery_at
.and_then(Path::parent)
.map(|stop_discovery_at| stop_discovery_at != *path)
.unwrap_or(true)
})
.skip(1)
{
let pyproject_toml_path = outer_workspace_root.join("pyproject.toml");
if !pyproject_toml_path.is_file() {
continue;
}
let contents = match fs_err::read_to_string(&pyproject_toml_path) {
Ok(contents) => contents,
Err(err) => {
warn!(
"Unreadable pyproject.toml `{}`: {err}",
pyproject_toml_path.simplified_display()
);
return;
}
};
let pyproject_toml: PyProjectToml = match toml::from_str(&contents) {
Ok(contents) => contents,
Err(err) => {
warn!(
"Invalid pyproject.toml `{}`: {err}",
pyproject_toml_path.simplified_display()
);
return;
}
};
if let Some(workspace) = pyproject_toml
.tool
.as_ref()
.and_then(|tool| tool.uv.as_ref())
.and_then(|uv| uv.workspace.as_ref())
{
let is_included = match is_included_in_workspace(
inner_workspace_root,
outer_workspace_root,
workspace,
) {
Ok(contents) => contents,
Err(err) => {
warn!(
"Invalid pyproject.toml `{}`: {err}",
pyproject_toml_path.simplified_display()
);
return;
}
};
let is_excluded = match is_excluded_from_workspace(
inner_workspace_root,
outer_workspace_root,
workspace,
) {
Ok(contents) => contents,
Err(err) => {
warn!(
"Invalid pyproject.toml `{}`: {err}",
pyproject_toml_path.simplified_display()
);
return;
}
};
if is_included && !is_excluded {
warn_user!(
"Nested workspaces are not supported, but outer workspace (`{}`) includes `{}`",
outer_workspace_root.simplified_display().cyan(),
inner_workspace_root.simplified_display().cyan()
);
}
}
// We're in the examples or tests of another project (not a workspace), this is fine.
return;
}
}
/// Check if we're in the `tool.uv.workspace.excluded` of a workspace.
fn is_excluded_from_workspace(
project_path: &Path,
workspace_root: &Path,
workspace: &ToolUvWorkspace,
) -> Result<bool, WorkspaceError> {
for exclude_glob in workspace.exclude.iter().flatten() {
let absolute_glob = PathBuf::from(glob::Pattern::escape(
workspace_root.simplified().to_string_lossy().as_ref(),
))
.join(exclude_glob.as_str());
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))?;
if exclude_pattern.matches_path(project_path) {
return Ok(true);
}
}
Ok(false)
}
/// Check if we're in the `tool.uv.workspace.members` of a workspace.
fn is_included_in_workspace(
project_path: &Path,
workspace_root: &Path,
workspace: &ToolUvWorkspace,
) -> Result<bool, WorkspaceError> {
for member_glob in workspace.members.iter().flatten() {
let absolute_glob = PathBuf::from(glob::Pattern::escape(
workspace_root.simplified().to_string_lossy().as_ref(),
))
.join(member_glob.as_str());
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))?;
if include_pattern.matches_path(project_path) {
return Ok(true);
}
}
Ok(false)
}
/// A project that can be discovered.
///
/// The project could be a package within a workspace, a real workspace root, or a (legacy)
/// non-project workspace root, which can define its own dev dependencies.
#[derive(Debug, Clone)]
pub enum VirtualProject {
/// A project (which could be a workspace root or member).
Project(ProjectWorkspace),
/// A (legacy) non-project workspace root.
NonProject(Workspace),
}
impl VirtualProject {
/// Find the current project or virtual workspace root, given the current directory.
///
/// Similar to calling [`ProjectWorkspace::discover`] with a fallback to [`Workspace::discover`],
/// but avoids rereading the `pyproject.toml` (and relying on error-handling as control flow).
///
/// This method requires an absolute path and panics otherwise, i.e. this method only supports
/// discovering the main workspace.
pub async fn discover(
path: &Path,
options: &DiscoveryOptions<'_>,
) -> Result<Self, WorkspaceError> {
assert!(
path.is_absolute(),
"virtual project discovery with relative path"
);
let project_root = path
.ancestors()
.take_while(|path| {
// Only walk up the given directory, if any.
options
.stop_discovery_at
.and_then(Path::parent)
.map(|stop_discovery_at| stop_discovery_at != *path)
.unwrap_or(true)
})
.find(|path| path.join("pyproject.toml").is_file())
.ok_or(WorkspaceError::MissingPyprojectToml)?;
debug!(
"Found project root: `{}`",
project_root.simplified_display()
);
// 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)
.map_err(|err| WorkspaceError::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.
let project =
ProjectWorkspace::from_project(project_root, project, &pyproject_toml, options)
.await?;
Ok(Self::Project(project))
} else if let Some(workspace) = pyproject_toml
.tool
.as_ref()
.and_then(|tool| tool.uv.as_ref())
.and_then(|uv| uv.workspace.as_ref())
{
// 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)?
.clone();
check_nested_workspaces(&project_path, options);
let workspace = Workspace::collect_members(
project_path,
workspace.clone(),
pyproject_toml,
None,
options,
)
.await?;
Ok(Self::NonProject(workspace))
} else {
Err(WorkspaceError::MissingProject(pyproject_path))
}
}
/// Set the `pyproject.toml` for the current project.
///
/// Assumes that the project name is unchanged in the updated [`PyProjectToml`].
#[must_use]
pub fn with_pyproject_toml(self, pyproject_toml: PyProjectToml) -> Option<Self> {
match self {
Self::Project(project) => {
Some(Self::Project(project.with_pyproject_toml(pyproject_toml)?))
}
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 {
pyproject_toml,
..workspace.clone()
}))
}
}
}
/// Return the root of the project.
pub fn root(&self) -> &Path {
match self {
Self::Project(project) => project.project_root(),
Self::NonProject(workspace) => workspace.install_path(),
}
}
/// Return the [`PyProjectToml`] of the project.
pub fn pyproject_toml(&self) -> &PyProjectToml {
match self {
Self::Project(project) => project.current_project().pyproject_toml(),
Self::NonProject(workspace) => &workspace.pyproject_toml,
}
}
/// Return the [`Workspace`] of the project.
pub fn workspace(&self) -> &Workspace {
match self {
Self::Project(project) => project.workspace(),
Self::NonProject(workspace) => workspace,
}
}
/// Return the [`PackageName`] of the project, if available.
pub fn project_name(&self) -> Option<&PackageName> {
match self {
VirtualProject::Project(project) => Some(project.project_name()),
VirtualProject::NonProject(_) => None,
}
}
/// Returns `true` if the project is a virtual workspace root.
pub fn is_non_project(&self) -> bool {
matches!(self, VirtualProject::NonProject(_))
}
}
#[cfg(test)]
#[cfg(unix)] // Avoid path escaping for the unit tests
mod tests;