use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use glob::{GlobError, PatternError, glob};
use itertools::Itertools;
use rustc_hash::{FxHashMap, FxHashSet};
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_pep440::VersionSpecifiers;
use uv_pep508::{MarkerTree, VerbatimUrl};
use uv_pypi_types::{ConflictError, Conflicts, SupportedEnvironments, VerbatimParsedUrl};
use uv_static::EnvVars;
use uv_warnings::warn_user_once;
use crate::dependency_groups::{DependencyGroupError, FlatDependencyGroup, FlatDependencyGroups};
use crate::pyproject::{
Project, PyProjectToml, PyprojectTomlError, Source, Sources, ToolUvSources, ToolUvWorkspace,
};
type WorkspaceMembers = Arc<BTreeMap<PackageName, WorkspaceMember>>;
#[derive(Debug, Default, Clone, Hash, PartialEq, Eq)]
struct WorkspaceCacheKey {
workspace_root: PathBuf,
discovery_options: DiscoveryOptions,
}
#[derive(Debug, Default, Clone)]
pub struct WorkspaceCache(Arc<Mutex<FxHashMap<WorkspaceCacheKey, WorkspaceMembers>>>);
#[derive(thiserror::Error, Debug)]
pub enum WorkspaceError {
#[error("No `pyproject.toml` found in current directory or any parent directory")]
MissingPyprojectToml,
#[error("Workspace member `{}` is missing a `pyproject.toml` (matches: `{}`)", _0.simplified_display(), _1)]
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("Nested workspaces are not supported, but workspace member has a `tool.uv.workspace` table: {}", _0.simplified_display())]
NestedWorkspace(PathBuf),
#[error("The workspace does not have a member {}: {}", _0, _1.simplified_display())]
NoSuchMember(PackageName, PathBuf),
#[error("Two workspace members are both named `{name}`: `{}` and `{}`", first.simplified_display(), second.simplified_display())]
DuplicatePackage {
name: PackageName,
first: PathBuf,
second: PathBuf,
},
#[error("pyproject.toml section is declared as dynamic, but must be static: `{0}`")]
DynamicNotAllowed(&'static str),
#[error(
"Workspace member `{}` was requested as both `editable = true` and `editable = false`",
_0
)]
EditableConflict(PackageName),
#[error("Failed to find directories for glob: `{0}`")]
Pattern(String, #[source] PatternError),
#[error("Directory walking failed for `tool.uv.workspace.members` glob: `{0}`")]
GlobWalk(String, #[source] GlobError),
#[error(transparent)]
Io(#[from] std::io::Error),
#[error("Failed to parse: `{}`", _0.user_display())]
Toml(PathBuf, #[source] Box<PyprojectTomlError>),
#[error(transparent)]
Conflicts(#[from] ConflictError),
#[error("Failed to normalize workspace member path")]
Normalize(#[source] std::io::Error),
}
#[derive(Debug, Default, Clone, Hash, PartialEq, Eq)]
pub enum MemberDiscovery {
#[default]
All,
None,
Ignore(BTreeSet<PathBuf>),
}
#[derive(Debug, Default, Clone, Hash, PartialEq, Eq)]
pub enum ProjectDiscovery {
#[default]
Optional,
Required,
}
impl ProjectDiscovery {
pub fn allows_implicit_workspace(&self) -> bool {
matches!(self, Self::Optional)
}
pub fn allows_non_project_workspace(&self) -> bool {
matches!(self, Self::Optional)
}
}
#[derive(Debug, Default, Clone, Hash, PartialEq, Eq)]
pub struct DiscoveryOptions {
pub stop_discovery_at: Option<PathBuf>,
pub members: MemberDiscovery,
pub project: ProjectDiscovery,
}
pub type RequiresPythonSources = BTreeMap<(PackageName, Option<GroupName>), VersionSpecifiers>;
pub type Editability = Option<bool>;
#[derive(Debug, Clone)]
#[cfg_attr(test, derive(serde::Serialize))]
pub struct Workspace {
install_path: PathBuf,
packages: WorkspaceMembers,
required_members: BTreeMap<PackageName, Editability>,
sources: BTreeMap<PackageName, Sources>,
indexes: Vec<Index>,
pyproject_toml: PyProjectToml,
}
impl Workspace {
pub async fn discover(
path: &Path,
options: &DiscoveryOptions,
cache: &WorkspaceCache,
) -> Result<Self, WorkspaceError> {
let path = std::path::absolute(path)
.map_err(WorkspaceError::Normalize)?
.clone();
let path = normalize_path(&path);
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, &pyproject_path)
.map_err(|err| WorkspaceError::Toml(pyproject_path.clone(), Box::new(err)))?;
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));
}
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 {
workspace
} else if pyproject_toml.project.is_none() {
return Err(WorkspaceError::MissingProject(pyproject_path));
} else if let Some(workspace) = find_workspace(&project_path, options).await? {
workspace
} else {
(
project_path.clone(),
ToolUvWorkspace::default(),
pyproject_toml.clone(),
)
};
debug!(
"Found workspace root: `{}`",
workspace_root.simplified_display()
);
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,
cache,
)
.await
}
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,
})
}
pub fn update_member(
self,
package_name: &PackageName,
pyproject_toml: PyProjectToml,
) -> Result<Option<Self>, WorkspaceError> {
let mut packages = self.packages;
let Some(member) = Arc::make_mut(&mut packages).get_mut(package_name) else {
return Ok(None);
};
if member.root == self.install_path {
let workspace_pyproject_toml = pyproject_toml.clone();
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();
member.pyproject_toml = pyproject_toml;
let required_members = Self::collect_required_members(
&packages,
&workspace_sources,
&workspace_pyproject_toml,
)?;
Ok(Some(Self {
pyproject_toml: workspace_pyproject_toml,
sources: workspace_sources,
packages,
required_members,
..self
}))
} else {
member.pyproject_toml = pyproject_toml;
let required_members =
Self::collect_required_members(&packages, &self.sources, &self.pyproject_toml)?;
Ok(Some(Self {
packages,
required_members,
..self
}))
}
}
pub fn is_non_project(&self) -> bool {
!self
.packages
.values()
.any(|member| *member.root() == self.install_path)
}
pub fn members_requirements(&self) -> impl Iterator<Item = Requirement> + '_ {
self.packages.iter().filter_map(|(name, member)| {
let url = VerbatimUrl::from_absolute_path(&member.root).expect("path is valid URL");
Some(Requirement {
name: member.pyproject_toml.project.as_ref()?.name.clone(),
extras: Box::new([]),
groups: Box::new([]),
marker: MarkerTree::TRUE,
source: if member
.pyproject_toml()
.is_package(!self.is_required_member(name))
{
RequirementSource::Directory {
install_path: member.root.clone().into_boxed_path(),
editable: Some(
self.required_members
.get(name)
.copied()
.flatten()
.unwrap_or(true),
),
r#virtual: Some(false),
url,
}
} else {
RequirementSource::Directory {
install_path: member.root.clone().into_boxed_path(),
editable: Some(false),
r#virtual: Some(true),
url,
}
},
origin: None,
})
})
}
pub fn required_members(&self) -> &BTreeMap<PackageName, Editability> {
&self.required_members
}
fn collect_required_members(
packages: &BTreeMap<PackageName, WorkspaceMember>,
sources: &BTreeMap<PackageName, Sources>,
pyproject_toml: &PyProjectToml,
) -> Result<BTreeMap<PackageName, Editability>, WorkspaceError> {
let mut required_members = BTreeMap::new();
for (package, sources) in sources
.iter()
.filter(|(name, _)| {
pyproject_toml
.project
.as_ref()
.is_none_or(|project| project.name != **name)
})
.chain(
packages
.iter()
.filter_map(|(name, member)| {
member
.pyproject_toml
.tool
.as_ref()
.and_then(|tool| tool.uv.as_ref())
.and_then(|uv| uv.sources.as_ref())
.map(ToolUvSources::inner)
.map(move |sources| {
sources
.iter()
.filter(move |(source_name, _)| name != *source_name)
})
})
.flatten(),
)
{
for source in sources.iter() {
let Source::Workspace { editable, .. } = &source else {
continue;
};
let existing = required_members.insert(package.clone(), *editable);
if let Some(Some(existing)) = existing {
if let Some(editable) = editable {
if existing != *editable {
return Err(WorkspaceError::EditableConflict(package.clone()));
}
}
}
}
}
Ok(required_members)
}
pub fn is_required_member(&self, name: &PackageName) -> bool {
self.required_members().contains_key(name)
}
pub fn group_requirements(&self) -> impl Iterator<Item = Requirement> + '_ {
self.packages.iter().filter_map(|(name, member)| {
let url = VerbatimUrl::from_absolute_path(&member.root).expect("path is valid URL");
let groups = {
let mut groups = member
.pyproject_toml
.dependency_groups
.as_ref()
.map(|groups| groups.keys().cloned().collect::<Vec<_>>())
.unwrap_or_default();
if member
.pyproject_toml
.tool
.as_ref()
.and_then(|tool| tool.uv.as_ref())
.and_then(|uv| uv.dev_dependencies.as_ref())
.is_some()
{
groups.push(DEV_DEPENDENCIES.clone());
groups.sort_unstable();
}
groups
};
if groups.is_empty() {
return None;
}
let value = self.required_members.get(name);
let is_required_member = value.is_some();
let editability = value.copied().flatten();
Some(Requirement {
name: member.pyproject_toml.project.as_ref()?.name.clone(),
extras: Box::new([]),
groups: groups.into_boxed_slice(),
marker: MarkerTree::TRUE,
source: if member.pyproject_toml().is_package(!is_required_member) {
RequirementSource::Directory {
install_path: member.root.clone().into_boxed_path(),
editable: Some(editability.unwrap_or(true)),
r#virtual: Some(false),
url,
}
} else {
RequirementSource::Directory {
install_path: member.root.clone().into_boxed_path(),
editable: Some(false),
r#virtual: Some(true),
url,
}
},
origin: None,
})
})
}
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())
}
pub fn required_environments(&self) -> Option<&SupportedEnvironments> {
self.pyproject_toml
.tool
.as_ref()
.and_then(|tool| tool.uv.as_ref())
.and_then(|uv| uv.required_environments.as_ref())
}
pub fn conflicts(&self) -> Result<Conflicts, WorkspaceError> {
let mut conflicting = Conflicts::empty();
if self.is_non_project() {
if let Some(root_conflicts) = self
.pyproject_toml
.tool
.as_ref()
.and_then(|tool| tool.uv.as_ref())
.and_then(|uv| uv.conflicts.as_ref())
{
let mut root_conflicts = root_conflicts.to_conflicts()?;
conflicting.append(&mut root_conflicts);
}
}
for member in self.packages.values() {
conflicting.append(&mut member.pyproject_toml.conflicts());
}
Ok(conflicting)
}
pub fn requires_python(
&self,
groups: &DependencyGroupsWithDefaults,
) -> Result<RequiresPythonSources, DependencyGroupError> {
let mut requires = RequiresPythonSources::new();
for (name, member) in self.packages() {
let top_requires = member
.pyproject_toml()
.project
.as_ref()
.and_then(|project| project.requires_python.as_ref())
.map(|requires_python| ((name.to_owned(), None), requires_python.clone()));
requires.extend(top_requires);
let dependency_groups =
FlatDependencyGroups::from_pyproject_toml(member.root(), &member.pyproject_toml)?;
let group_requires =
dependency_groups
.into_iter()
.filter_map(move |(group_name, flat_group)| {
if groups.contains(&group_name) {
flat_group.requires_python.map(|requires_python| {
((name.to_owned(), Some(group_name)), requires_python)
})
} else {
None
}
});
requires.extend(group_requires);
}
Ok(requires)
}
pub fn requirements(&self) -> Vec<uv_pep508::Requirement<VerbatimParsedUrl>> {
Vec::new()
}
pub fn workspace_dependency_groups(
&self,
) -> Result<BTreeMap<GroupName, FlatDependencyGroup>, DependencyGroupError> {
if self
.packages
.values()
.any(|member| *member.root() == self.install_path)
{
Ok(BTreeMap::default())
} else {
let dependency_groups = FlatDependencyGroups::from_pyproject_toml(
&self.install_path,
&self.pyproject_toml,
)?;
Ok(dependency_groups.into_inner())
}
}
pub fn overrides(&self) -> Vec<uv_pep508::Requirement<VerbatimParsedUrl>> {
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.clone()
}
pub fn exclude_dependencies(&self) -> Vec<uv_normalize::PackageName> {
let Some(excludes) = self
.pyproject_toml
.tool
.as_ref()
.and_then(|tool| tool.uv.as_ref())
.and_then(|uv| uv.exclude_dependencies.as_ref())
else {
return vec![];
};
excludes.clone()
}
pub fn constraints(&self) -> Vec<uv_pep508::Requirement<VerbatimParsedUrl>> {
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.clone()
}
pub fn build_constraints(&self) -> Vec<uv_pep508::Requirement<VerbatimParsedUrl>> {
let Some(build_constraints) = self
.pyproject_toml
.tool
.as_ref()
.and_then(|tool| tool.uv.as_ref())
.and_then(|uv| uv.build_constraint_dependencies.as_ref())
else {
return vec![];
};
build_constraints.clone()
}
pub fn install_path(&self) -> &PathBuf {
&self.install_path
}
pub fn venv(&self, active: Option<bool>) -> PathBuf {
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);
}
Some(workspace.install_path.join(path))
}
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);
}
Some(CWD.join(path))
}
let project_env = from_project_environment_variable(self)
.unwrap_or_else(|| self.install_path.join(".venv"));
if let Some(from_virtual_env) = from_virtual_env_variable() {
if !uv_fs::is_same_file_allow_missing(&from_virtual_env, &project_env).unwrap_or(false)
{
match active {
Some(true) => {
debug!(
"Using active virtual environment `{}` instead of project environment `{}`",
from_virtual_env.user_display(),
project_env.user_display()
);
return from_virtual_env;
}
Some(false) => {}
None => {
warn_user_once!(
"`VIRTUAL_ENV={}` does not match the project environment path `{}` and will be ignored; use `--active` to target the active environment instead",
from_virtual_env.user_display(),
project_env.user_display()
);
}
}
}
} else {
if active.unwrap_or_default() {
debug!(
"Use of the active virtual environment was requested, but `VIRTUAL_ENV` is not set"
);
}
}
project_env
}
pub fn packages(&self) -> &BTreeMap<PackageName, WorkspaceMember> {
&self.packages
}
pub fn sources(&self) -> &BTreeMap<PackageName, Sources> {
&self.sources
}
pub fn indexes(&self) -> &[Index] {
&self.indexes
}
pub fn pyproject_toml(&self) -> &PyProjectToml {
&self.pyproject_toml
}
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)
}
}
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)
}
}
async fn collect_members(
workspace_root: PathBuf,
workspace_definition: ToolUvWorkspace,
workspace_pyproject_toml: PyProjectToml,
current_project: Option<WorkspaceMember>,
options: &DiscoveryOptions,
cache: &WorkspaceCache,
) -> Result<Self, WorkspaceError> {
let cache_key = WorkspaceCacheKey {
workspace_root: workspace_root.clone(),
discovery_options: options.clone(),
};
let cache_entry = {
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?;
{
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)
};
if let Some(root_member) = current_project {
if !workspace_members.contains_key(&root_member.project.name) {
debug!(
"Adding current workspace member: `{}`",
root_member.root.simplified_display()
);
Arc::make_mut(&mut workspace_members)
.insert(root_member.project.name.clone(), root_member);
}
}
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();
let required_members = Self::collect_required_members(
&workspace_members,
&workspace_sources,
&workspace_pyproject_toml,
)?;
let dev_dependencies_members = workspace_members
.values()
.filter_map(|member| {
member
.pyproject_toml
.tool
.as_ref()
.and_then(|tool| tool.uv.as_ref())
.and_then(|uv| uv.dev_dependencies.as_ref())
.map(|_| format!("`{}`", member.root().join("pyproject.toml").user_display()))
})
.join(", ");
if !dev_dependencies_members.is_empty() {
warn_user_once!(
"The `tool.uv.dev-dependencies` field (used in {}) is deprecated and will be removed in a future release; use `dependency-groups.dev` instead",
dev_dependencies_members
);
}
Ok(Self {
install_path: workspace_root,
packages: workspace_members,
required_members,
sources: workspace_sources,
indexes: workspace_indexes,
pyproject_toml: workspace_pyproject_toml,
})
}
async fn collect_members_only(
workspace_root: &PathBuf,
workspace_definition: &ToolUvWorkspace,
workspace_pyproject_toml: &PyProjectToml,
options: &DiscoveryOptions,
) -> Result<BTreeMap<PackageName, WorkspaceMember>, WorkspaceError> {
let mut workspace_members = BTreeMap::new();
let mut seen = FxHashSet::default();
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()
);
seen.insert(workspace_root.clone());
workspace_members.insert(
project.name.clone(),
WorkspaceMember {
root: workspace_root.clone(),
project: project.clone(),
pyproject_toml,
},
);
}
for member_glob in workspace_definition.clone().members.unwrap_or_default() {
let normalized_glob = normalize_path(Path::new(member_glob.as_str()));
let absolute_glob = PathBuf::from(glob::Pattern::escape(
workspace_root.simplified().to_string_lossy().as_ref(),
))
.join(normalized_glob.as_ref())
.to_string_lossy()
.to_string();
for member_root in glob(&absolute_glob)
.map_err(|err| WorkspaceError::Pattern(absolute_glob.clone(), err))?
{
let member_root = member_root
.map_err(|err| WorkspaceError::GlobWalk(absolute_glob.clone(), err))?;
if !seen.insert(member_root.clone()) {
continue;
}
let member_root = std::path::absolute(&member_root)
.map_err(WorkspaceError::Normalize)?
.clone();
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 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()
);
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 !fs_err::metadata(&member_root)?.is_dir() {
warn!(
"Ignoring non-directory workspace member: `{}`",
member_root.simplified_display()
);
continue;
}
if err.kind() == std::io::ErrorKind::NotFound {
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;
}
if has_only_gitignored_files(&member_root) {
debug!(
"Ignoring workspace member with only gitignored files: `{}`",
member_root.simplified_display()
);
continue;
}
return Err(WorkspaceError::MissingPyprojectTomlMember(
member_root,
member_glob.to_string(),
));
}
return Err(err.into());
}
};
let pyproject_toml = PyProjectToml::from_string(contents, &pyproject_path)
.map_err(|err| WorkspaceError::Toml(pyproject_path.clone(), Box::new(err)))?;
if pyproject_toml
.tool
.as_ref()
.and_then(|tool| tool.uv.as_ref())
.and_then(|uv| uv.managed)
== Some(false)
{
if let Some(project) = pyproject_toml.project.as_ref() {
debug!(
"Project `{}` is marked as unmanaged; omitting from workspace members",
project.name
);
} else {
debug!(
"Workspace member at `{}` is marked as unmanaged; omitting from workspace members",
member_root.simplified_display()
);
}
continue;
}
let Some(project) = pyproject_toml.project.clone() else {
return Err(WorkspaceError::MissingProject(pyproject_path));
};
debug!(
"Adding discovered workspace member: `{}`",
member_root.simplified_display()
);
if let Some(existing) = workspace_members.insert(
project.name.clone(),
WorkspaceMember {
root: member_root.clone(),
project,
pyproject_toml,
},
) {
return Err(WorkspaceError::DuplicatePackage {
name: existing.project.name,
first: existing.root.clone(),
second: member_root,
});
}
}
}
for member in workspace_members.values() {
if member.root() != workspace_root
&& member
.pyproject_toml
.tool
.as_ref()
.and_then(|tool| tool.uv.as_ref())
.and_then(|uv| uv.workspace.as_ref())
.is_some()
{
return Err(WorkspaceError::NestedWorkspace(member.root.clone()));
}
}
Ok(workspace_members)
}
}
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(test, derive(serde::Serialize))]
pub struct WorkspaceMember {
root: PathBuf,
project: Project,
pyproject_toml: PyProjectToml,
}
impl WorkspaceMember {
pub fn root(&self) -> &PathBuf {
&self.root
}
pub fn project(&self) -> &Project {
&self.project
}
pub fn pyproject_toml(&self) -> &PyProjectToml {
&self.pyproject_toml
}
}
#[derive(Debug, Clone)]
#[cfg_attr(test, derive(serde::Serialize))]
pub struct ProjectWorkspace {
project_root: PathBuf,
project_name: PackageName,
workspace: Workspace,
}
impl ProjectWorkspace {
pub async fn discover(
path: &Path,
options: &DiscoveryOptions,
cache: &WorkspaceCache,
) -> Result<Self, WorkspaceError> {
let project_root = path
.ancestors()
.take_while(|path| {
options
.stop_discovery_at
.as_deref()
.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, cache).await
}
async fn from_project_root(
project_root: &Path,
options: &DiscoveryOptions,
cache: &WorkspaceCache,
) -> Result<Self, WorkspaceError> {
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)))?;
let project = pyproject_toml
.project
.clone()
.ok_or(WorkspaceError::MissingProject(pyproject_path))?;
Self::from_project(project_root, &project, &pyproject_toml, options, cache).await
}
pub async fn from_maybe_project_root(
install_path: &Path,
options: &DiscoveryOptions,
cache: &WorkspaceCache,
) -> Result<Option<Self>, WorkspaceError> {
let pyproject_path = install_path.join("pyproject.toml");
let Ok(contents) = fs_err::tokio::read_to_string(&pyproject_path).await else {
return Ok(None);
};
let pyproject_toml = PyProjectToml::from_string(contents, &pyproject_path)
.map_err(|err| WorkspaceError::Toml(pyproject_path.clone(), Box::new(err)))?;
let Some(project) = pyproject_toml.project.clone() else {
return Ok(None);
};
match Self::from_project(install_path, &project, &pyproject_toml, options, cache).await {
Ok(workspace) => Ok(Some(workspace)),
Err(WorkspaceError::NonWorkspace(_)) => Ok(None),
Err(err) => Err(err),
}
}
pub fn project_root(&self) -> &Path {
&self.project_root
}
pub fn project_name(&self) -> &PackageName {
&self.project_name
}
pub fn workspace(&self) -> &Workspace {
&self.workspace
}
pub fn current_project(&self) -> &WorkspaceMember {
&self.workspace().packages[&self.project_name]
}
pub fn update_member(
self,
pyproject_toml: PyProjectToml,
) -> Result<Option<Self>, WorkspaceError> {
let Some(workspace) = self
.workspace
.update_member(&self.project_name, pyproject_toml)?
else {
return Ok(None);
};
Ok(Some(Self { workspace, ..self }))
}
async fn from_project(
install_path: &Path,
project: &Project,
project_pyproject_toml: &PyProjectToml,
options: &DiscoveryOptions,
cache: &WorkspaceCache,
) -> Result<Self, WorkspaceError> {
let project_path = std::path::absolute(install_path)
.map_err(WorkspaceError::Normalize)?
.clone();
let project_path = normalize_path(&project_path);
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.to_path_buf()));
}
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.to_path_buf(),
workspace.clone(),
project_pyproject_toml.clone(),
)
});
if workspace.is_none() {
workspace = find_workspace(&project_path, options).await?;
}
let current_project = WorkspaceMember {
root: project_path.to_path_buf(),
project: project.clone(),
pyproject_toml: project_pyproject_toml.clone(),
};
let Some((workspace_root, workspace_definition, workspace_pyproject_toml)) = workspace
else {
debug!("No workspace root found, using project root");
let current_project_as_members = Arc::new(BTreeMap::from_iter([(
project.name.clone(),
current_project,
)]));
let workspace_sources = BTreeMap::default();
let required_members = Workspace::collect_required_members(
¤t_project_as_members,
&workspace_sources,
project_pyproject_toml,
)?;
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,
sources: workspace_sources,
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,
cache,
)
.await?;
Ok(Self {
project_root: project_path.to_path_buf(),
project_name: project.name.clone(),
workspace,
})
}
}
async fn find_workspace(
project_root: &Path,
options: &DiscoveryOptions,
) -> Result<Option<(PathBuf, ToolUvWorkspace, PyProjectToml)>, WorkspaceError> {
for workspace_root in project_root
.ancestors()
.take_while(|path| {
options
.stop_discovery_at
.as_deref()
.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()
);
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)))?;
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);
}
Ok(Some((
workspace_root.to_path_buf(),
workspace.clone(),
pyproject_toml,
)))
} else if pyproject_toml.project.is_some() {
debug!(
"Project is contained in non-workspace project: `{}`",
workspace_root.simplified_display()
);
Ok(None)
} else {
warn!(
"`pyproject.toml` does not contain a `project` table: `{}`",
pyproject_path.simplified_display()
);
Ok(None)
};
}
Ok(None)
}
fn has_only_gitignored_files(path: &Path) -> bool {
let walker = ignore::WalkBuilder::new(path)
.hidden(false)
.parents(true)
.ignore(true)
.git_ignore(true)
.git_global(true)
.git_exclude(true)
.build();
for entry in walker {
let Ok(entry) = entry else {
return false;
};
if entry.path().is_dir() {
continue;
}
return false;
}
true
}
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 normalized_glob = normalize_path(Path::new(exclude_glob.as_str()));
let absolute_glob = PathBuf::from(glob::Pattern::escape(
workspace_root.simplified().to_string_lossy().as_ref(),
))
.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))?;
if exclude_pattern.matches_path(project_path) {
return Ok(true);
}
}
Ok(false)
}
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 normalized_glob = normalize_path(Path::new(member_glob.as_str()));
let absolute_glob = PathBuf::from(glob::Pattern::escape(
workspace_root.simplified().to_string_lossy().as_ref(),
))
.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))?;
if include_pattern.matches_path(project_path) {
return Ok(true);
}
}
Ok(false)
}
#[derive(Debug, Clone)]
pub enum VirtualProject {
Project(ProjectWorkspace),
NonProject(Workspace),
}
impl VirtualProject {
pub async fn discover(
path: &Path,
options: &DiscoveryOptions,
cache: &WorkspaceCache,
) -> Result<Self, WorkspaceError> {
assert!(
path.is_absolute(),
"virtual project discovery with relative path"
);
let project_root = path
.ancestors()
.take_while(|path| {
options
.stop_discovery_at
.as_deref()
.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()
);
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)))?;
if let Some(project) = pyproject_toml.project.as_ref() {
let project = ProjectWorkspace::from_project(
project_root,
project,
&pyproject_toml,
options,
cache,
)
.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())
.filter(|_| options.project.allows_non_project_workspace())
{
let project_path = std::path::absolute(project_root)
.map_err(WorkspaceError::Normalize)?
.clone();
let workspace = Workspace::collect_members(
project_path,
workspace.clone(),
pyproject_toml,
None,
options,
cache,
)
.await?;
Ok(Self::NonProject(workspace))
} else if options.project.allows_implicit_workspace() {
let project_path = std::path::absolute(project_root)
.map_err(WorkspaceError::Normalize)?
.clone();
let workspace = Workspace::collect_members(
project_path,
ToolUvWorkspace::default(),
pyproject_toml,
None,
options,
cache,
)
.await?;
Ok(Self::NonProject(workspace))
} else {
Err(WorkspaceError::MissingProject(pyproject_path))
}
}
pub async fn discover_with_package(
path: &Path,
options: &DiscoveryOptions,
cache: &WorkspaceCache,
package: PackageName,
) -> Result<Self, WorkspaceError> {
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)
})?))
}
pub fn update_member(
self,
pyproject_toml: PyProjectToml,
) -> Result<Option<Self>, WorkspaceError> {
Ok(match self {
Self::Project(project) => {
let Some(project) = project.update_member(pyproject_toml)? else {
return Ok(None);
};
Some(Self::Project(project))
}
Self::NonProject(workspace) => {
Some(Self::NonProject(Workspace {
pyproject_toml,
..workspace.clone()
}))
}
})
}
pub fn root(&self) -> &Path {
match self {
Self::Project(project) => project.project_root(),
Self::NonProject(workspace) => workspace.install_path(),
}
}
pub fn pyproject_toml(&self) -> &PyProjectToml {
match self {
Self::Project(project) => project.current_project().pyproject_toml(),
Self::NonProject(workspace) => &workspace.pyproject_toml,
}
}
pub fn workspace(&self) -> &Workspace {
match self {
Self::Project(project) => project.workspace(),
Self::NonProject(workspace) => workspace,
}
}
pub fn project_name(&self) -> Option<&PackageName> {
match self {
Self::Project(project) => Some(project.project_name()),
Self::NonProject(_) => None,
}
}
pub fn is_non_project(&self) -> bool {
matches!(self, Self::NonProject(_))
}
}
#[cfg(test)]
#[cfg(unix)] mod tests {
use std::env;
use std::path::Path;
use std::str::FromStr;
use anyhow::Result;
use assert_fs::fixture::ChildPath;
use assert_fs::prelude::*;
use insta::{assert_json_snapshot, assert_snapshot};
use uv_normalize::GroupName;
use uv_pypi_types::DependencyGroupSpecifier;
use crate::pyproject::PyProjectToml;
use crate::workspace::{DiscoveryOptions, ProjectWorkspace};
use crate::{WorkspaceCache, WorkspaceError};
async fn workspace_test(folder: &str) -> (ProjectWorkspace, String) {
let root_dir = env::current_dir()
.unwrap()
.parent()
.unwrap()
.parent()
.unwrap()
.join("test")
.join("workspaces");
let project = ProjectWorkspace::discover(
&root_dir.join(folder),
&DiscoveryOptions::default(),
&WorkspaceCache::default(),
)
.await
.unwrap();
let root_escaped = regex::escape(root_dir.to_string_lossy().as_ref());
(project, root_escaped)
}
async fn temporary_test(
folder: &Path,
) -> Result<(ProjectWorkspace, String), (WorkspaceError, String)> {
let root_escaped = regex::escape(folder.to_string_lossy().as_ref());
let project = ProjectWorkspace::discover(
folder,
&DiscoveryOptions::default(),
&WorkspaceCache::default(),
)
.await
.map_err(|error| (error, root_escaped.clone()))?;
Ok((project, root_escaped))
}
#[tokio::test]
async fn albatross_in_example() {
let (project, root_escaped) =
workspace_test("albatross-in-example/examples/bird-feeder").await;
let filters = vec![(root_escaped.as_str(), "[ROOT]")];
insta::with_settings!({filters => filters}, {
assert_json_snapshot!(
project,
{
".workspace.packages.*.pyproject_toml" => "[PYPROJECT_TOML]"
},
@r#"
{
"project_root": "[ROOT]/albatross-in-example/examples/bird-feeder",
"project_name": "bird-feeder",
"workspace": {
"install_path": "[ROOT]/albatross-in-example/examples/bird-feeder",
"packages": {
"bird-feeder": {
"root": "[ROOT]/albatross-in-example/examples/bird-feeder",
"project": {
"name": "bird-feeder",
"version": "1.0.0",
"requires-python": ">=3.12",
"dependencies": [
"iniconfig>=2,<3"
],
"optional-dependencies": null
},
"pyproject_toml": "[PYPROJECT_TOML]"
}
},
"required_members": {},
"sources": {},
"indexes": [],
"pyproject_toml": {
"project": {
"name": "bird-feeder",
"version": "1.0.0",
"requires-python": ">=3.12",
"dependencies": [
"iniconfig>=2,<3"
],
"optional-dependencies": null
},
"tool": null,
"dependency-groups": null
}
}
}
"#);
});
}
#[tokio::test]
async fn albatross_project_in_excluded() {
let (project, root_escaped) =
workspace_test("albatross-project-in-excluded/excluded/bird-feeder").await;
let filters = vec![(root_escaped.as_str(), "[ROOT]")];
insta::with_settings!({filters => filters}, {
assert_json_snapshot!(
project,
{
".workspace.packages.*.pyproject_toml" => "[PYPROJECT_TOML]"
},
@r#"
{
"project_root": "[ROOT]/albatross-project-in-excluded/excluded/bird-feeder",
"project_name": "bird-feeder",
"workspace": {
"install_path": "[ROOT]/albatross-project-in-excluded/excluded/bird-feeder",
"packages": {
"bird-feeder": {
"root": "[ROOT]/albatross-project-in-excluded/excluded/bird-feeder",
"project": {
"name": "bird-feeder",
"version": "1.0.0",
"requires-python": ">=3.12",
"dependencies": [
"iniconfig>=2,<3"
],
"optional-dependencies": null
},
"pyproject_toml": "[PYPROJECT_TOML]"
}
},
"required_members": {},
"sources": {},
"indexes": [],
"pyproject_toml": {
"project": {
"name": "bird-feeder",
"version": "1.0.0",
"requires-python": ">=3.12",
"dependencies": [
"iniconfig>=2,<3"
],
"optional-dependencies": null
},
"tool": null,
"dependency-groups": null
}
}
}
"#);
});
}
#[tokio::test]
async fn albatross_root_workspace() {
let (project, root_escaped) = workspace_test("albatross-root-workspace").await;
let filters = vec![(root_escaped.as_str(), "[ROOT]")];
insta::with_settings!({filters => filters}, {
assert_json_snapshot!(
project,
{
".workspace.packages.*.pyproject_toml" => "[PYPROJECT_TOML]"
},
@r#"
{
"project_root": "[ROOT]/albatross-root-workspace",
"project_name": "albatross",
"workspace": {
"install_path": "[ROOT]/albatross-root-workspace",
"packages": {
"albatross": {
"root": "[ROOT]/albatross-root-workspace",
"project": {
"name": "albatross",
"version": "0.1.0",
"requires-python": ">=3.12",
"dependencies": [
"bird-feeder",
"iniconfig>=2,<3"
],
"optional-dependencies": null
},
"pyproject_toml": "[PYPROJECT_TOML]"
},
"bird-feeder": {
"root": "[ROOT]/albatross-root-workspace/packages/bird-feeder",
"project": {
"name": "bird-feeder",
"version": "1.0.0",
"requires-python": ">=3.8",
"dependencies": [
"iniconfig>=2,<3",
"seeds"
],
"optional-dependencies": null
},
"pyproject_toml": "[PYPROJECT_TOML]"
},
"seeds": {
"root": "[ROOT]/albatross-root-workspace/packages/seeds",
"project": {
"name": "seeds",
"version": "1.0.0",
"requires-python": ">=3.12",
"dependencies": [
"idna==3.6"
],
"optional-dependencies": null
},
"pyproject_toml": "[PYPROJECT_TOML]"
}
},
"required_members": {
"bird-feeder": null,
"seeds": null
},
"sources": {
"bird-feeder": [
{
"workspace": true,
"editable": null,
"extra": null,
"group": null
}
]
},
"indexes": [],
"pyproject_toml": {
"project": {
"name": "albatross",
"version": "0.1.0",
"requires-python": ">=3.12",
"dependencies": [
"bird-feeder",
"iniconfig>=2,<3"
],
"optional-dependencies": null
},
"tool": {
"uv": {
"sources": {
"bird-feeder": [
{
"workspace": true,
"editable": null,
"extra": null,
"group": null
}
]
},
"index": null,
"workspace": {
"members": [
"packages/*"
],
"exclude": null
},
"managed": null,
"package": null,
"default-groups": null,
"dependency-groups": null,
"dev-dependencies": null,
"override-dependencies": null,
"exclude-dependencies": null,
"constraint-dependencies": null,
"build-constraint-dependencies": null,
"environments": null,
"required-environments": null,
"conflicts": null,
"build-backend": null
}
},
"dependency-groups": null
}
}
}
"#);
});
}
#[tokio::test]
async fn albatross_virtual_workspace() {
let (project, root_escaped) =
workspace_test("albatross-virtual-workspace/packages/albatross").await;
let filters = vec![(root_escaped.as_str(), "[ROOT]")];
insta::with_settings!({filters => filters}, {
assert_json_snapshot!(
project,
{
".workspace.packages.*.pyproject_toml" => "[PYPROJECT_TOML]"
},
@r#"
{
"project_root": "[ROOT]/albatross-virtual-workspace/packages/albatross",
"project_name": "albatross",
"workspace": {
"install_path": "[ROOT]/albatross-virtual-workspace",
"packages": {
"albatross": {
"root": "[ROOT]/albatross-virtual-workspace/packages/albatross",
"project": {
"name": "albatross",
"version": "0.1.0",
"requires-python": ">=3.12",
"dependencies": [
"bird-feeder",
"iniconfig>=2,<3"
],
"optional-dependencies": null
},
"pyproject_toml": "[PYPROJECT_TOML]"
},
"bird-feeder": {
"root": "[ROOT]/albatross-virtual-workspace/packages/bird-feeder",
"project": {
"name": "bird-feeder",
"version": "1.0.0",
"requires-python": ">=3.12",
"dependencies": [
"anyio>=4.3.0,<5",
"seeds"
],
"optional-dependencies": null
},
"pyproject_toml": "[PYPROJECT_TOML]"
},
"seeds": {
"root": "[ROOT]/albatross-virtual-workspace/packages/seeds",
"project": {
"name": "seeds",
"version": "1.0.0",
"requires-python": ">=3.12",
"dependencies": [
"idna==3.6"
],
"optional-dependencies": null
},
"pyproject_toml": "[PYPROJECT_TOML]"
}
},
"required_members": {
"bird-feeder": null,
"seeds": null
},
"sources": {},
"indexes": [],
"pyproject_toml": {
"project": null,
"tool": {
"uv": {
"sources": null,
"index": null,
"workspace": {
"members": [
"packages/*"
],
"exclude": null
},
"managed": null,
"package": null,
"default-groups": null,
"dependency-groups": null,
"dev-dependencies": null,
"override-dependencies": null,
"exclude-dependencies": null,
"constraint-dependencies": null,
"build-constraint-dependencies": null,
"environments": null,
"required-environments": null,
"conflicts": null,
"build-backend": null
}
},
"dependency-groups": null
}
}
}
"#);
});
}
#[tokio::test]
async fn albatross_just_project() {
let (project, root_escaped) = workspace_test("albatross-just-project").await;
let filters = vec![(root_escaped.as_str(), "[ROOT]")];
insta::with_settings!({filters => filters}, {
assert_json_snapshot!(
project,
{
".workspace.packages.*.pyproject_toml" => "[PYPROJECT_TOML]"
},
@r#"
{
"project_root": "[ROOT]/albatross-just-project",
"project_name": "albatross",
"workspace": {
"install_path": "[ROOT]/albatross-just-project",
"packages": {
"albatross": {
"root": "[ROOT]/albatross-just-project",
"project": {
"name": "albatross",
"version": "0.1.0",
"requires-python": ">=3.12",
"dependencies": [
"iniconfig>=2,<3"
],
"optional-dependencies": null
},
"pyproject_toml": "[PYPROJECT_TOML]"
}
},
"required_members": {},
"sources": {},
"indexes": [],
"pyproject_toml": {
"project": {
"name": "albatross",
"version": "0.1.0",
"requires-python": ">=3.12",
"dependencies": [
"iniconfig>=2,<3"
],
"optional-dependencies": null
},
"tool": null,
"dependency-groups": null
}
}
}
"#);
});
}
#[tokio::test]
async fn exclude_package() -> 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"
dependencies = ["tqdm>=4,<5"]
[tool.uv.workspace]
members = ["packages/*"]
exclude = ["packages/bird-feeder"]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
"#,
)?;
root.child("albatross").child("__init__.py").touch()?;
root.child("packages")
.child("seeds")
.child("pyproject.toml")
.write_str(
r#"
[project]
name = "seeds"
version = "1.0.0"
requires-python = ">=3.12"
dependencies = ["idna==3.6"]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
"#,
)?;
root.child("packages")
.child("seeds")
.child("seeds")
.child("__init__.py")
.touch()?;
root.child("packages")
.child("bird-feeder")
.child("pyproject.toml")
.write_str(
r#"
[project]
name = "bird-feeder"
version = "1.0.0"
requires-python = ">=3.12"
dependencies = ["anyio>=4.3.0,<5"]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
"#,
)?;
root.child("packages")
.child("bird-feeder")
.child("bird_feeder")
.child("__init__.py")
.touch()?;
let (project, root_escaped) = temporary_test(root.as_ref()).await.unwrap();
let filters = vec![(root_escaped.as_str(), "[ROOT]")];
insta::with_settings!({filters => filters}, {
assert_json_snapshot!(
project,
{
".workspace.packages.*.pyproject_toml" => "[PYPROJECT_TOML]"
},
@r#"
{
"project_root": "[ROOT]",
"project_name": "albatross",
"workspace": {
"install_path": "[ROOT]",
"packages": {
"albatross": {
"root": "[ROOT]",
"project": {
"name": "albatross",
"version": "0.1.0",
"requires-python": ">=3.12",
"dependencies": [
"tqdm>=4,<5"
],
"optional-dependencies": null
},
"pyproject_toml": "[PYPROJECT_TOML]"
},
"seeds": {
"root": "[ROOT]/packages/seeds",
"project": {
"name": "seeds",
"version": "1.0.0",
"requires-python": ">=3.12",
"dependencies": [
"idna==3.6"
],
"optional-dependencies": null
},
"pyproject_toml": "[PYPROJECT_TOML]"
}
},
"required_members": {},
"sources": {},
"indexes": [],
"pyproject_toml": {
"project": {
"name": "albatross",
"version": "0.1.0",
"requires-python": ">=3.12",
"dependencies": [
"tqdm>=4,<5"
],
"optional-dependencies": null
},
"tool": {
"uv": {
"sources": null,
"index": null,
"workspace": {
"members": [
"packages/*"
],
"exclude": [
"packages/bird-feeder"
]
},
"managed": null,
"package": null,
"default-groups": null,
"dependency-groups": null,
"dev-dependencies": null,
"override-dependencies": null,
"exclude-dependencies": null,
"constraint-dependencies": null,
"build-constraint-dependencies": null,
"environments": null,
"required-environments": null,
"conflicts": null,
"build-backend": null
}
},
"dependency-groups": null
}
}
}
"#);
});
root.child("pyproject.toml").write_str(
r#"
[project]
name = "albatross"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = ["tqdm>=4,<5"]
[tool.uv.workspace]
members = ["packages/seeds", "packages/bird-feeder"]
exclude = ["packages/bird-feeder"]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
"#,
)?;
let (project, root_escaped) = temporary_test(root.as_ref()).await.unwrap();
let filters = vec![(root_escaped.as_str(), "[ROOT]")];
insta::with_settings!({filters => filters}, {
assert_json_snapshot!(
project,
{
".workspace.packages.*.pyproject_toml" => "[PYPROJECT_TOML]"
},
@r#"
{
"project_root": "[ROOT]",
"project_name": "albatross",
"workspace": {
"install_path": "[ROOT]",
"packages": {
"albatross": {
"root": "[ROOT]",
"project": {
"name": "albatross",
"version": "0.1.0",
"requires-python": ">=3.12",
"dependencies": [
"tqdm>=4,<5"
],
"optional-dependencies": null
},
"pyproject_toml": "[PYPROJECT_TOML]"
},
"seeds": {
"root": "[ROOT]/packages/seeds",
"project": {
"name": "seeds",
"version": "1.0.0",
"requires-python": ">=3.12",
"dependencies": [
"idna==3.6"
],
"optional-dependencies": null
},
"pyproject_toml": "[PYPROJECT_TOML]"
}
},
"required_members": {},
"sources": {},
"indexes": [],
"pyproject_toml": {
"project": {
"name": "albatross",
"version": "0.1.0",
"requires-python": ">=3.12",
"dependencies": [
"tqdm>=4,<5"
],
"optional-dependencies": null
},
"tool": {
"uv": {
"sources": null,
"index": null,
"workspace": {
"members": [
"packages/seeds",
"packages/bird-feeder"
],
"exclude": [
"packages/bird-feeder"
]
},
"managed": null,
"package": null,
"default-groups": null,
"dependency-groups": null,
"dev-dependencies": null,
"override-dependencies": null,
"exclude-dependencies": null,
"constraint-dependencies": null,
"build-constraint-dependencies": null,
"environments": null,
"required-environments": null,
"conflicts": null,
"build-backend": null
}
},
"dependency-groups": null
}
}
}
"#);
});
root.child("pyproject.toml").write_str(
r#"
[project]
name = "albatross"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = ["tqdm>=4,<5"]
[tool.uv.workspace]
members = ["packages/seeds", "packages/bird-feeder"]
exclude = ["packages"]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
"#,
)?;
let (project, root_escaped) = temporary_test(root.as_ref()).await.unwrap();
let filters = vec![(root_escaped.as_str(), "[ROOT]")];
insta::with_settings!({filters => filters}, {
assert_json_snapshot!(
project,
{
".workspace.packages.*.pyproject_toml" => "[PYPROJECT_TOML]"
},
@r#"
{
"project_root": "[ROOT]",
"project_name": "albatross",
"workspace": {
"install_path": "[ROOT]",
"packages": {
"albatross": {
"root": "[ROOT]",
"project": {
"name": "albatross",
"version": "0.1.0",
"requires-python": ">=3.12",
"dependencies": [
"tqdm>=4,<5"
],
"optional-dependencies": null
},
"pyproject_toml": "[PYPROJECT_TOML]"
},
"bird-feeder": {
"root": "[ROOT]/packages/bird-feeder",
"project": {
"name": "bird-feeder",
"version": "1.0.0",
"requires-python": ">=3.12",
"dependencies": [
"anyio>=4.3.0,<5"
],
"optional-dependencies": null
},
"pyproject_toml": "[PYPROJECT_TOML]"
},
"seeds": {
"root": "[ROOT]/packages/seeds",
"project": {
"name": "seeds",
"version": "1.0.0",
"requires-python": ">=3.12",
"dependencies": [
"idna==3.6"
],
"optional-dependencies": null
},
"pyproject_toml": "[PYPROJECT_TOML]"
}
},
"required_members": {},
"sources": {},
"indexes": [],
"pyproject_toml": {
"project": {
"name": "albatross",
"version": "0.1.0",
"requires-python": ">=3.12",
"dependencies": [
"tqdm>=4,<5"
],
"optional-dependencies": null
},
"tool": {
"uv": {
"sources": null,
"index": null,
"workspace": {
"members": [
"packages/seeds",
"packages/bird-feeder"
],
"exclude": [
"packages"
]
},
"managed": null,
"package": null,
"default-groups": null,
"dependency-groups": null,
"dev-dependencies": null,
"override-dependencies": null,
"exclude-dependencies": null,
"constraint-dependencies": null,
"build-constraint-dependencies": null,
"environments": null,
"required-environments": null,
"conflicts": null,
"build-backend": null
}
},
"dependency-groups": null
}
}
}
"#);
});
root.child("pyproject.toml").write_str(
r#"
[project]
name = "albatross"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = ["tqdm>=4,<5"]
[tool.uv.workspace]
members = ["packages/seeds", "packages/bird-feeder"]
exclude = ["packages/*"]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
"#,
)?;
let (project, root_escaped) = temporary_test(root.as_ref()).await.unwrap();
let filters = vec![(root_escaped.as_str(), "[ROOT]")];
insta::with_settings!({filters => filters}, {
assert_json_snapshot!(
project,
{
".workspace.packages.*.pyproject_toml" => "[PYPROJECT_TOML]"
},
@r#"
{
"project_root": "[ROOT]",
"project_name": "albatross",
"workspace": {
"install_path": "[ROOT]",
"packages": {
"albatross": {
"root": "[ROOT]",
"project": {
"name": "albatross",
"version": "0.1.0",
"requires-python": ">=3.12",
"dependencies": [
"tqdm>=4,<5"
],
"optional-dependencies": null
},
"pyproject_toml": "[PYPROJECT_TOML]"
}
},
"required_members": {},
"sources": {},
"indexes": [],
"pyproject_toml": {
"project": {
"name": "albatross",
"version": "0.1.0",
"requires-python": ">=3.12",
"dependencies": [
"tqdm>=4,<5"
],
"optional-dependencies": null
},
"tool": {
"uv": {
"sources": null,
"index": null,
"workspace": {
"members": [
"packages/seeds",
"packages/bird-feeder"
],
"exclude": [
"packages/*"
]
},
"managed": null,
"package": null,
"default-groups": null,
"dependency-groups": null,
"dev-dependencies": null,
"override-dependencies": null,
"exclude-dependencies": null,
"constraint-dependencies": null,
"build-constraint-dependencies": null,
"environments": null,
"required-environments": null,
"conflicts": null,
"build-backend": null
}
},
"dependency-groups": null
}
}
}
"#);
});
Ok(())
}
#[test]
fn read_dependency_groups() {
let toml = r#"
[dependency-groups]
foo = ["a", {include-group = "bar"}]
bar = ["b"]
"#;
let result = PyProjectToml::from_string(toml.to_string(), "pyproject.toml")
.expect("Deserialization should succeed");
let groups = result
.dependency_groups
.expect("`dependency-groups` should be present");
let foo = groups
.get(&GroupName::from_str("foo").unwrap())
.expect("Group `foo` should be present");
assert_eq!(
foo,
&[
DependencyGroupSpecifier::Requirement("a".to_string()),
DependencyGroupSpecifier::IncludeGroup {
include_group: GroupName::from_str("bar").unwrap(),
}
]
);
let bar = groups
.get(&GroupName::from_str("bar").unwrap())
.expect("Group `bar` should be present");
assert_eq!(
bar,
&[DependencyGroupSpecifier::Requirement("b".to_string())]
);
}
#[tokio::test]
async fn nested_workspace() -> 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"
dependencies = ["tqdm>=4,<5"]
[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"
dependencies = ["idna==3.6"]
[tool.uv.workspace]
members = ["nested_packages/*"]
"#,
)?;
let (error, root_escaped) = temporary_test(root.as_ref()).await.unwrap_err();
let filters = vec![(root_escaped.as_str(), "[ROOT]")];
insta::with_settings!({filters => filters}, {
assert_snapshot!(
error,
@"Nested workspaces are not supported, but workspace member has a `tool.uv.workspace` table: [ROOT]/packages/seeds");
});
Ok(())
}
#[tokio::test]
async fn duplicate_names() -> 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"
dependencies = ["tqdm>=4,<5"]
[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"
dependencies = ["idna==3.6"]
[tool.uv.workspace]
members = ["nested_packages/*"]
"#,
)?;
root.child("packages")
.child("seeds2")
.child("pyproject.toml")
.write_str(
r#"
[project]
name = "seeds"
version = "1.0.0"
requires-python = ">=3.12"
dependencies = ["idna==3.6"]
[tool.uv.workspace]
members = ["nested_packages/*"]
"#,
)?;
let (error, root_escaped) = temporary_test(root.as_ref()).await.unwrap_err();
let filters = vec![(root_escaped.as_str(), "[ROOT]")];
insta::with_settings!({filters => filters}, {
assert_snapshot!(
error,
@"Two workspace members are both named `seeds`: `[ROOT]/packages/seeds` and `[ROOT]/packages/seeds2`");
});
Ok(())
}
}