Skip to main content

uv_workspace/
workspace.rs

1//! Resolve the current [`ProjectWorkspace`] or [`Workspace`].
2
3use std::collections::{BTreeMap, BTreeSet};
4use std::path::{Path, PathBuf};
5use std::sync::{Arc, Mutex};
6
7use glob::{GlobError, PatternError, glob};
8use itertools::Itertools;
9use rustc_hash::{FxHashMap, FxHashSet};
10use tracing::{debug, trace, warn};
11
12use uv_configuration::DependencyGroupsWithDefaults;
13use uv_distribution_types::{Index, Requirement, RequirementSource};
14use uv_fs::{CWD, Simplified};
15use uv_normalize::{DEV_DEPENDENCIES, GroupName, PackageName};
16use uv_pep440::VersionSpecifiers;
17use uv_pep508::{MarkerTree, VerbatimUrl};
18use uv_pypi_types::{ConflictError, Conflicts, SupportedEnvironments, VerbatimParsedUrl};
19use uv_static::EnvVars;
20use uv_warnings::warn_user_once;
21
22use crate::dependency_groups::{DependencyGroupError, FlatDependencyGroup, FlatDependencyGroups};
23use crate::pyproject::{
24    Project, PyProjectToml, PyprojectTomlError, Source, Sources, ToolUvSources, ToolUvWorkspace,
25};
26
27type WorkspaceMembers = Arc<BTreeMap<PackageName, WorkspaceMember>>;
28
29/// Cache key for workspace discovery.
30///
31/// Given this key, the discovered workspace member list is the same.
32#[derive(Debug, Default, Clone, Hash, PartialEq, Eq)]
33struct WorkspaceCacheKey {
34    workspace_root: PathBuf,
35    discovery_options: DiscoveryOptions,
36}
37
38/// Cache for workspace discovery.
39///
40/// Avoid re-reading the `pyproject.toml` files in a workspace for each member by caching the
41/// workspace members by their workspace root.
42#[derive(Debug, Default, Clone)]
43pub struct WorkspaceCache(Arc<Mutex<FxHashMap<WorkspaceCacheKey, WorkspaceMembers>>>);
44
45#[derive(thiserror::Error, Debug)]
46pub enum WorkspaceError {
47    // Workspace structure errors.
48    #[error("No `pyproject.toml` found in current directory or any parent directory")]
49    MissingPyprojectToml,
50    #[error("Workspace member `{}` is missing a `pyproject.toml` (matches: `{}`)", _0.simplified_display(), _1)]
51    MissingPyprojectTomlMember(PathBuf, String),
52    #[error("No `project` table found in: {}", _0.simplified_display())]
53    MissingProject(PathBuf),
54    #[error("No workspace found for: {}", _0.simplified_display())]
55    MissingWorkspace(PathBuf),
56    #[error("The project is marked as unmanaged: {}", _0.simplified_display())]
57    NonWorkspace(PathBuf),
58    #[error("Nested workspaces are not supported, but workspace member has a `tool.uv.workspace` table: {}", _0.simplified_display())]
59    NestedWorkspace(PathBuf),
60    #[error("The workspace does not have a member {}: {}", _0, _1.simplified_display())]
61    NoSuchMember(PackageName, PathBuf),
62    #[error("Two workspace members are both named `{name}`: `{}` and `{}`", first.simplified_display(), second.simplified_display())]
63    DuplicatePackage {
64        name: PackageName,
65        first: PathBuf,
66        second: PathBuf,
67    },
68    #[error("pyproject.toml section is declared as dynamic, but must be static: `{0}`")]
69    DynamicNotAllowed(&'static str),
70    #[error(
71        "Workspace member `{}` was requested as both `editable = true` and `editable = false`",
72        _0
73    )]
74    EditableConflict(PackageName),
75    #[error("Failed to find directories for glob: `{0}`")]
76    Pattern(String, #[source] PatternError),
77    // Syntax and other errors.
78    #[error("Directory walking failed for `tool.uv.workspace.members` glob: `{0}`")]
79    GlobWalk(String, #[source] GlobError),
80    #[error(transparent)]
81    Io(#[from] std::io::Error),
82    #[error("Failed to parse: `{}`", _0.user_display())]
83    Toml(PathBuf, #[source] Box<PyprojectTomlError>),
84    #[error(transparent)]
85    Conflicts(#[from] ConflictError),
86    #[error("Failed to normalize workspace member path")]
87    Normalize(#[source] std::io::Error),
88}
89
90#[derive(Debug, Default, Clone, Hash, PartialEq, Eq)]
91pub enum MemberDiscovery {
92    /// Discover all workspace members.
93    #[default]
94    All,
95    /// Don't discover any workspace members.
96    None,
97    /// Discover workspace members, but ignore the given paths.
98    Ignore(BTreeSet<PathBuf>),
99}
100
101/// Whether a "project" must be defined via a `[project]` table.
102#[derive(Debug, Default, Clone, Hash, PartialEq, Eq)]
103pub enum ProjectDiscovery {
104    /// The `[project]` table is optional; when missing, the target is treated as a virtual
105    /// project with only dependency groups.
106    #[default]
107    Optional,
108    /// A `[project]` table must be defined.
109    ///
110    /// If not defined, discovery will fail.
111    Required,
112}
113
114impl ProjectDiscovery {
115    /// Whether a `[project]` table is required.
116    pub fn allows_implicit_workspace(&self) -> bool {
117        matches!(self, Self::Optional)
118    }
119
120    /// Whether a non-project workspace root is allowed.
121    pub fn allows_non_project_workspace(&self) -> bool {
122        matches!(self, Self::Optional)
123    }
124}
125
126#[derive(Debug, Default, Clone, Hash, PartialEq, Eq)]
127pub struct DiscoveryOptions {
128    /// The path to stop discovery at.
129    pub stop_discovery_at: Option<PathBuf>,
130    /// The strategy to use when discovering workspace members.
131    pub members: MemberDiscovery,
132    /// The strategy to use when discovering the project.
133    pub project: ProjectDiscovery,
134}
135
136pub type RequiresPythonSources = BTreeMap<(PackageName, Option<GroupName>), VersionSpecifiers>;
137
138pub type Editability = Option<bool>;
139
140/// A workspace, consisting of a root directory and members. See [`ProjectWorkspace`].
141#[derive(Debug, Clone)]
142#[cfg_attr(test, derive(serde::Serialize))]
143pub struct Workspace {
144    /// The path to the workspace root.
145    ///
146    /// The workspace root is the directory containing the top level `pyproject.toml` with
147    /// the `uv.tool.workspace`, or the `pyproject.toml` in an implicit single workspace project.
148    install_path: PathBuf,
149    /// The members of the workspace.
150    packages: WorkspaceMembers,
151    /// The workspace members that are required by other members, and whether they were requested
152    /// as editable.
153    required_members: BTreeMap<PackageName, Editability>,
154    /// The sources table from the workspace `pyproject.toml`.
155    ///
156    /// This table is overridden by the project sources.
157    sources: BTreeMap<PackageName, Sources>,
158    /// The index table from the workspace `pyproject.toml`.
159    ///
160    /// This table is overridden by the project indexes.
161    indexes: Vec<Index>,
162    /// The `pyproject.toml` of the workspace root.
163    pyproject_toml: PyProjectToml,
164}
165
166impl Workspace {
167    /// Find the workspace containing the given path.
168    ///
169    /// Unlike the [`ProjectWorkspace`] discovery, this does not require a current project. It also
170    /// always uses absolute path, i.e., this method only supports discovering the main workspace.
171    ///
172    /// Steps of workspace discovery: Start by looking at the closest `pyproject.toml`:
173    /// * If it's an explicit workspace root: Collect workspace from this root, we're done.
174    /// * If it's also not a project: Error, must be either a workspace root or a project.
175    /// * Otherwise, try to find an explicit workspace root above:
176    ///   * If an explicit workspace root exists: Collect workspace from this root, we're done.
177    ///   * If there is no explicit workspace: We have a single project workspace, we're done.
178    ///
179    /// Note that there are two kinds of workspace roots: projects, and non-project roots.
180    /// The non-project roots lack a `[project]` table, and so are not themselves projects, as in:
181    /// ```toml
182    /// [tool.uv.workspace]
183    /// members = ["packages/*"]
184    ///
185    /// [tool.uv]
186    /// dev-dependencies = ["ruff"]
187    /// ```
188    pub async fn discover(
189        path: &Path,
190        options: &DiscoveryOptions,
191        cache: &WorkspaceCache,
192    ) -> Result<Self, WorkspaceError> {
193        let path = std::path::absolute(path)
194            .map_err(WorkspaceError::Normalize)?
195            .clone();
196        // Remove `.` and `..`
197        let path = uv_fs::normalize_path(&path);
198        // Trim trailing slashes.
199        let path = path.components().collect::<PathBuf>();
200
201        let project_path = path
202            .ancestors()
203            .find(|path| path.join("pyproject.toml").is_file())
204            .ok_or(WorkspaceError::MissingPyprojectToml)?
205            .to_path_buf();
206
207        let pyproject_path = project_path.join("pyproject.toml");
208        let contents = fs_err::tokio::read_to_string(&pyproject_path).await?;
209        let pyproject_toml = PyProjectToml::from_string(contents, &pyproject_path)
210            .map_err(|err| WorkspaceError::Toml(pyproject_path.clone(), Box::new(err)))?;
211
212        // Check if the project is explicitly marked as unmanaged.
213        if pyproject_toml
214            .tool
215            .as_ref()
216            .and_then(|tool| tool.uv.as_ref())
217            .and_then(|uv| uv.managed)
218            == Some(false)
219        {
220            debug!(
221                "Project `{}` is marked as unmanaged",
222                project_path.simplified_display()
223            );
224            return Err(WorkspaceError::NonWorkspace(project_path));
225        }
226
227        // Check if the current project is also an explicit workspace root.
228        let explicit_root = pyproject_toml
229            .tool
230            .as_ref()
231            .and_then(|tool| tool.uv.as_ref())
232            .and_then(|uv| uv.workspace.as_ref())
233            .map(|workspace| {
234                (
235                    project_path.clone(),
236                    workspace.clone(),
237                    pyproject_toml.clone(),
238                )
239            });
240
241        let (workspace_root, workspace_definition, workspace_pyproject_toml) =
242            if let Some(workspace) = explicit_root {
243                // We have found the explicit root immediately.
244                workspace
245            } else if pyproject_toml.project.is_none() {
246                // Without a project, it can't be an implicit root
247                return Err(WorkspaceError::MissingProject(pyproject_path));
248            } else if let Some(workspace) = find_workspace(&project_path, options).await? {
249                // We have found an explicit root above.
250                workspace
251            } else {
252                // Support implicit single project workspaces.
253                (
254                    project_path.clone(),
255                    ToolUvWorkspace::default(),
256                    pyproject_toml.clone(),
257                )
258            };
259
260        debug!(
261            "Found workspace root: `{}`",
262            workspace_root.simplified_display()
263        );
264
265        // Unlike in `ProjectWorkspace` discovery, we might be in a non-project root without
266        // being in any specific project.
267        let current_project = pyproject_toml
268            .project
269            .clone()
270            .map(|project| WorkspaceMember {
271                root: project_path,
272                project,
273                pyproject_toml,
274            });
275
276        Self::collect_members(
277            workspace_root.clone(),
278            workspace_definition,
279            workspace_pyproject_toml,
280            current_project,
281            options,
282            cache,
283        )
284        .await
285    }
286
287    /// Set the current project to the given workspace member.
288    ///
289    /// Returns `None` if the package is not part of the workspace.
290    pub fn with_current_project(self, package_name: PackageName) -> Option<ProjectWorkspace> {
291        let member = self.packages.get(&package_name)?;
292        Some(ProjectWorkspace {
293            project_root: member.root().clone(),
294            project_name: package_name,
295            workspace: self,
296        })
297    }
298
299    /// Set the [`ProjectWorkspace`] for a given workspace member.
300    ///
301    /// Assumes that the project name is unchanged in the updated [`PyProjectToml`].
302    pub fn update_member(
303        self,
304        package_name: &PackageName,
305        pyproject_toml: PyProjectToml,
306    ) -> Result<Option<Self>, WorkspaceError> {
307        let mut packages = self.packages;
308
309        let Some(member) = Arc::make_mut(&mut packages).get_mut(package_name) else {
310            return Ok(None);
311        };
312
313        if member.root == self.install_path {
314            // If the member is also the workspace root, update _both_ the member entry and the
315            // root `pyproject.toml`.
316            let workspace_pyproject_toml = pyproject_toml.clone();
317
318            // Refresh the workspace sources.
319            let workspace_sources = workspace_pyproject_toml
320                .tool
321                .clone()
322                .and_then(|tool| tool.uv)
323                .and_then(|uv| uv.sources)
324                .map(ToolUvSources::into_inner)
325                .unwrap_or_default();
326
327            // Set the `pyproject.toml` for the member.
328            member.pyproject_toml = pyproject_toml;
329
330            // Recompute required_members with the updated data
331            let required_members = Self::collect_required_members(
332                &packages,
333                &workspace_sources,
334                &workspace_pyproject_toml,
335            )?;
336
337            Ok(Some(Self {
338                pyproject_toml: workspace_pyproject_toml,
339                sources: workspace_sources,
340                packages,
341                required_members,
342                ..self
343            }))
344        } else {
345            // Set the `pyproject.toml` for the member.
346            member.pyproject_toml = pyproject_toml;
347
348            // Recompute required_members with the updated member data
349            let required_members =
350                Self::collect_required_members(&packages, &self.sources, &self.pyproject_toml)?;
351
352            Ok(Some(Self {
353                packages,
354                required_members,
355                ..self
356            }))
357        }
358    }
359
360    /// Returns `true` if the workspace has a non-project root.
361    pub fn is_non_project(&self) -> bool {
362        !self
363            .packages
364            .values()
365            .any(|member| *member.root() == self.install_path)
366    }
367
368    /// Returns the set of all workspace members.
369    pub fn members_requirements(&self) -> impl Iterator<Item = Requirement> + '_ {
370        self.packages.iter().filter_map(|(name, member)| {
371            let url = VerbatimUrl::from_absolute_path(&member.root).expect("path is valid URL");
372            Some(Requirement {
373                name: member.pyproject_toml.project.as_ref()?.name.clone(),
374                extras: Box::new([]),
375                groups: Box::new([]),
376                marker: MarkerTree::TRUE,
377                source: if member
378                    .pyproject_toml()
379                    .is_package(!self.is_required_member(name))
380                {
381                    RequirementSource::Directory {
382                        install_path: member.root.clone().into_boxed_path(),
383                        editable: Some(
384                            self.required_members
385                                .get(name)
386                                .copied()
387                                .flatten()
388                                .unwrap_or(true),
389                        ),
390                        r#virtual: Some(false),
391                        url,
392                    }
393                } else {
394                    RequirementSource::Directory {
395                        install_path: member.root.clone().into_boxed_path(),
396                        editable: Some(false),
397                        r#virtual: Some(true),
398                        url,
399                    }
400                },
401                origin: None,
402            })
403        })
404    }
405
406    /// The workspace members that are required my another member of the workspace.
407    pub fn required_members(&self) -> &BTreeMap<PackageName, Editability> {
408        &self.required_members
409    }
410
411    /// Compute the workspace members that are required by another member of the workspace, and
412    /// determine whether they should be installed as editable or non-editable.
413    ///
414    /// N.B. this checks if a workspace member is required by inspecting `tool.uv.source` entries,
415    /// but does not actually check if the source is _used_, which could result in false positives
416    /// but is easier to compute.
417    fn collect_required_members(
418        packages: &BTreeMap<PackageName, WorkspaceMember>,
419        sources: &BTreeMap<PackageName, Sources>,
420        pyproject_toml: &PyProjectToml,
421    ) -> Result<BTreeMap<PackageName, Editability>, WorkspaceError> {
422        let mut required_members = BTreeMap::new();
423
424        for (package, sources) in sources
425            .iter()
426            .filter(|(name, _)| {
427                pyproject_toml
428                    .project
429                    .as_ref()
430                    .is_none_or(|project| project.name != **name)
431            })
432            .chain(
433                packages
434                    .iter()
435                    .filter_map(|(name, member)| {
436                        member
437                            .pyproject_toml
438                            .tool
439                            .as_ref()
440                            .and_then(|tool| tool.uv.as_ref())
441                            .and_then(|uv| uv.sources.as_ref())
442                            .map(ToolUvSources::inner)
443                            .map(move |sources| {
444                                sources
445                                    .iter()
446                                    .filter(move |(source_name, _)| name != *source_name)
447                            })
448                    })
449                    .flatten(),
450            )
451        {
452            for source in sources.iter() {
453                let Source::Workspace { editable, .. } = &source else {
454                    continue;
455                };
456                let existing = required_members.insert(package.clone(), *editable);
457                if let Some(Some(existing)) = existing {
458                    if let Some(editable) = editable {
459                        // If there are conflicting `editable` values, raise an error.
460                        if existing != *editable {
461                            return Err(WorkspaceError::EditableConflict(package.clone()));
462                        }
463                    }
464                }
465            }
466        }
467
468        Ok(required_members)
469    }
470
471    /// Whether a given workspace member is required by another member.
472    pub fn is_required_member(&self, name: &PackageName) -> bool {
473        self.required_members().contains_key(name)
474    }
475
476    /// Returns the set of all workspace member dependency groups.
477    pub fn group_requirements(&self) -> impl Iterator<Item = Requirement> + '_ {
478        self.packages.iter().filter_map(|(name, member)| {
479            let url = VerbatimUrl::from_absolute_path(&member.root).expect("path is valid URL");
480
481            let groups = {
482                let mut groups = member
483                    .pyproject_toml
484                    .dependency_groups
485                    .as_ref()
486                    .map(|groups| groups.keys().cloned().collect::<Vec<_>>())
487                    .unwrap_or_default();
488                if member
489                    .pyproject_toml
490                    .tool
491                    .as_ref()
492                    .and_then(|tool| tool.uv.as_ref())
493                    .and_then(|uv| uv.dev_dependencies.as_ref())
494                    .is_some()
495                {
496                    groups.push(DEV_DEPENDENCIES.clone());
497                    groups.sort_unstable();
498                }
499                groups
500            };
501            if groups.is_empty() {
502                return None;
503            }
504
505            let value = self.required_members.get(name);
506            let is_required_member = value.is_some();
507            let editability = value.copied().flatten();
508
509            Some(Requirement {
510                name: member.pyproject_toml.project.as_ref()?.name.clone(),
511                extras: Box::new([]),
512                groups: groups.into_boxed_slice(),
513                marker: MarkerTree::TRUE,
514                source: if member.pyproject_toml().is_package(!is_required_member) {
515                    RequirementSource::Directory {
516                        install_path: member.root.clone().into_boxed_path(),
517                        editable: Some(editability.unwrap_or(true)),
518                        r#virtual: Some(false),
519                        url,
520                    }
521                } else {
522                    RequirementSource::Directory {
523                        install_path: member.root.clone().into_boxed_path(),
524                        editable: Some(false),
525                        r#virtual: Some(true),
526                        url,
527                    }
528                },
529                origin: None,
530            })
531        })
532    }
533
534    /// Returns the set of supported environments for the workspace.
535    pub fn environments(&self) -> Option<&SupportedEnvironments> {
536        self.pyproject_toml
537            .tool
538            .as_ref()
539            .and_then(|tool| tool.uv.as_ref())
540            .and_then(|uv| uv.environments.as_ref())
541    }
542
543    /// Returns the set of required platforms for the workspace.
544    pub fn required_environments(&self) -> Option<&SupportedEnvironments> {
545        self.pyproject_toml
546            .tool
547            .as_ref()
548            .and_then(|tool| tool.uv.as_ref())
549            .and_then(|uv| uv.required_environments.as_ref())
550    }
551
552    /// Returns the set of conflicts for the workspace.
553    pub fn conflicts(&self) -> Result<Conflicts, WorkspaceError> {
554        let mut conflicting = Conflicts::empty();
555        if self.is_non_project() {
556            if let Some(root_conflicts) = self
557                .pyproject_toml
558                .tool
559                .as_ref()
560                .and_then(|tool| tool.uv.as_ref())
561                .and_then(|uv| uv.conflicts.as_ref())
562            {
563                let mut root_conflicts = root_conflicts.to_conflicts()?;
564                conflicting.append(&mut root_conflicts);
565            }
566        }
567        for member in self.packages.values() {
568            conflicting.append(&mut member.pyproject_toml.conflicts());
569        }
570        Ok(conflicting)
571    }
572
573    /// Returns an iterator over the `requires-python` values for each member of the workspace.
574    pub fn requires_python(
575        &self,
576        groups: &DependencyGroupsWithDefaults,
577    ) -> Result<RequiresPythonSources, DependencyGroupError> {
578        let mut requires = RequiresPythonSources::new();
579        for (name, member) in self.packages() {
580            // Get the top-level requires-python for this package, which is always active
581            //
582            // Arguably we could check groups.prod() to disable this, since, the requires-python
583            // of the project is *technically* not relevant if you're doing `--only-group`, but,
584            // that would be a big surprising change so let's *not* do that until someone asks!
585            let top_requires = member
586                .pyproject_toml()
587                .project
588                .as_ref()
589                .and_then(|project| project.requires_python.as_ref())
590                .map(|requires_python| ((name.to_owned(), None), requires_python.clone()));
591            requires.extend(top_requires);
592
593            // Get the requires-python for each enabled group on this package
594            // We need to do full flattening here because include-group can transfer requires-python
595            let dependency_groups =
596                FlatDependencyGroups::from_pyproject_toml(member.root(), &member.pyproject_toml)?;
597            let group_requires =
598                dependency_groups
599                    .into_iter()
600                    .filter_map(move |(group_name, flat_group)| {
601                        if groups.contains(&group_name) {
602                            flat_group.requires_python.map(|requires_python| {
603                                ((name.to_owned(), Some(group_name)), requires_python)
604                            })
605                        } else {
606                            None
607                        }
608                    });
609            requires.extend(group_requires);
610        }
611        Ok(requires)
612    }
613
614    /// Returns any requirements that are exclusive to the workspace root, i.e., not included in
615    /// any of the workspace members.
616    ///
617    /// For now, there are no such requirements.
618    pub fn requirements(&self) -> Vec<uv_pep508::Requirement<VerbatimParsedUrl>> {
619        Vec::new()
620    }
621
622    /// Returns any dependency groups that are exclusive to the workspace root, i.e., not included
623    /// in any of the workspace members.
624    ///
625    /// For workspaces with non-`[project]` roots, returns the dependency groups defined in the
626    /// corresponding `pyproject.toml`.
627    ///
628    /// Otherwise, returns an empty list.
629    pub fn workspace_dependency_groups(
630        &self,
631    ) -> Result<BTreeMap<GroupName, FlatDependencyGroup>, DependencyGroupError> {
632        if self
633            .packages
634            .values()
635            .any(|member| *member.root() == self.install_path)
636        {
637            // If the workspace has an explicit root, the root is a member, so we don't need to
638            // include any root-only requirements.
639            Ok(BTreeMap::default())
640        } else {
641            // Otherwise, return the dependency groups in the non-project workspace root.
642            let dependency_groups = FlatDependencyGroups::from_pyproject_toml(
643                &self.install_path,
644                &self.pyproject_toml,
645            )?;
646            Ok(dependency_groups.into_inner())
647        }
648    }
649
650    /// Returns the set of overrides for the workspace.
651    pub fn overrides(&self) -> Vec<uv_pep508::Requirement<VerbatimParsedUrl>> {
652        let Some(overrides) = self
653            .pyproject_toml
654            .tool
655            .as_ref()
656            .and_then(|tool| tool.uv.as_ref())
657            .and_then(|uv| uv.override_dependencies.as_ref())
658        else {
659            return vec![];
660        };
661        overrides.clone()
662    }
663
664    /// Returns the set of dependency exclusions for the workspace.
665    pub fn exclude_dependencies(&self) -> Vec<uv_normalize::PackageName> {
666        let Some(excludes) = self
667            .pyproject_toml
668            .tool
669            .as_ref()
670            .and_then(|tool| tool.uv.as_ref())
671            .and_then(|uv| uv.exclude_dependencies.as_ref())
672        else {
673            return vec![];
674        };
675        excludes.clone()
676    }
677
678    /// Returns the set of constraints for the workspace.
679    pub fn constraints(&self) -> Vec<uv_pep508::Requirement<VerbatimParsedUrl>> {
680        let Some(constraints) = self
681            .pyproject_toml
682            .tool
683            .as_ref()
684            .and_then(|tool| tool.uv.as_ref())
685            .and_then(|uv| uv.constraint_dependencies.as_ref())
686        else {
687            return vec![];
688        };
689        constraints.clone()
690    }
691
692    /// Returns the set of build constraints for the workspace.
693    pub fn build_constraints(&self) -> Vec<uv_pep508::Requirement<VerbatimParsedUrl>> {
694        let Some(build_constraints) = self
695            .pyproject_toml
696            .tool
697            .as_ref()
698            .and_then(|tool| tool.uv.as_ref())
699            .and_then(|uv| uv.build_constraint_dependencies.as_ref())
700        else {
701            return vec![];
702        };
703        build_constraints.clone()
704    }
705
706    /// The path to the workspace root, the directory containing the top level `pyproject.toml` with
707    /// the `uv.tool.workspace`, or the `pyproject.toml` in an implicit single workspace project.
708    pub fn install_path(&self) -> &PathBuf {
709        &self.install_path
710    }
711
712    /// The path to the workspace virtual environment.
713    ///
714    /// Uses `.venv` in the install path directory by default.
715    ///
716    /// If `UV_PROJECT_ENVIRONMENT` is set, it will take precedence. If a relative path is provided,
717    /// it is resolved relative to the install path.
718    ///
719    /// If `active` is `true`, the `VIRTUAL_ENV` variable will be preferred. If it is `false`, any
720    /// warnings about mismatch between the active environment and the project environment will be
721    /// silenced.
722    pub fn venv(&self, active: Option<bool>) -> PathBuf {
723        /// Resolve the `UV_PROJECT_ENVIRONMENT` value, if any.
724        fn from_project_environment_variable(workspace: &Workspace) -> Option<PathBuf> {
725            let value = std::env::var_os(EnvVars::UV_PROJECT_ENVIRONMENT)?;
726
727            if value.is_empty() {
728                return None;
729            }
730
731            let path = PathBuf::from(value);
732            if path.is_absolute() {
733                return Some(path);
734            }
735
736            // Resolve the path relative to the install path.
737            Some(workspace.install_path.join(path))
738        }
739
740        /// Resolve the `VIRTUAL_ENV` variable, if any.
741        fn from_virtual_env_variable() -> Option<PathBuf> {
742            let value = std::env::var_os(EnvVars::VIRTUAL_ENV)?;
743
744            if value.is_empty() {
745                return None;
746            }
747
748            let path = PathBuf::from(value);
749            if path.is_absolute() {
750                return Some(path);
751            }
752
753            // Resolve the path relative to current directory.
754            // Note this differs from `UV_PROJECT_ENVIRONMENT`
755            Some(CWD.join(path))
756        }
757
758        // Determine the default value
759        let project_env = from_project_environment_variable(self)
760            .unwrap_or_else(|| self.install_path.join(".venv"));
761
762        // Warn if it conflicts with `VIRTUAL_ENV`
763        if let Some(from_virtual_env) = from_virtual_env_variable() {
764            if !uv_fs::is_same_file_allow_missing(&from_virtual_env, &project_env).unwrap_or(false)
765            {
766                match active {
767                    Some(true) => {
768                        debug!(
769                            "Using active virtual environment `{}` instead of project environment `{}`",
770                            from_virtual_env.user_display(),
771                            project_env.user_display()
772                        );
773                        return from_virtual_env;
774                    }
775                    Some(false) => {}
776                    None => {
777                        warn_user_once!(
778                            "`VIRTUAL_ENV={}` does not match the project environment path `{}` and will be ignored; use `--active` to target the active environment instead",
779                            from_virtual_env.user_display(),
780                            project_env.user_display()
781                        );
782                    }
783                }
784            }
785        } else {
786            if active.unwrap_or_default() {
787                debug!(
788                    "Use of the active virtual environment was requested, but `VIRTUAL_ENV` is not set"
789                );
790            }
791        }
792
793        project_env
794    }
795
796    /// The members of the workspace.
797    pub fn packages(&self) -> &BTreeMap<PackageName, WorkspaceMember> {
798        &self.packages
799    }
800
801    /// The sources table from the workspace `pyproject.toml`.
802    pub fn sources(&self) -> &BTreeMap<PackageName, Sources> {
803        &self.sources
804    }
805
806    /// The index table from the workspace `pyproject.toml`.
807    pub fn indexes(&self) -> &[Index] {
808        &self.indexes
809    }
810
811    /// The `pyproject.toml` of the workspace.
812    pub fn pyproject_toml(&self) -> &PyProjectToml {
813        &self.pyproject_toml
814    }
815
816    /// Returns `true` if the path is excluded by the workspace.
817    pub fn excludes(&self, project_path: &Path) -> Result<bool, WorkspaceError> {
818        if let Some(workspace) = self
819            .pyproject_toml
820            .tool
821            .as_ref()
822            .and_then(|tool| tool.uv.as_ref())
823            .and_then(|uv| uv.workspace.as_ref())
824        {
825            is_excluded_from_workspace(project_path, &self.install_path, workspace)
826        } else {
827            Ok(false)
828        }
829    }
830
831    /// Returns `true` if the path is included by the workspace.
832    pub fn includes(&self, project_path: &Path) -> Result<bool, WorkspaceError> {
833        if let Some(workspace) = self
834            .pyproject_toml
835            .tool
836            .as_ref()
837            .and_then(|tool| tool.uv.as_ref())
838            .and_then(|uv| uv.workspace.as_ref())
839        {
840            is_included_in_workspace(project_path, &self.install_path, workspace)
841        } else {
842            Ok(false)
843        }
844    }
845
846    /// Collect the workspace member projects from the `members` and `excludes` entries.
847    async fn collect_members(
848        workspace_root: PathBuf,
849        workspace_definition: ToolUvWorkspace,
850        workspace_pyproject_toml: PyProjectToml,
851        current_project: Option<WorkspaceMember>,
852        options: &DiscoveryOptions,
853        cache: &WorkspaceCache,
854    ) -> Result<Self, WorkspaceError> {
855        let cache_key = WorkspaceCacheKey {
856            workspace_root: workspace_root.clone(),
857            discovery_options: options.clone(),
858        };
859        let cache_entry = {
860            // Acquire the lock for the minimal required region
861            let cache = cache.0.lock().expect("there was a panic in another thread");
862            cache.get(&cache_key).cloned()
863        };
864        let mut workspace_members = if let Some(workspace_members) = cache_entry {
865            trace!(
866                "Cached workspace members for: `{}`",
867                &workspace_root.simplified_display()
868            );
869            workspace_members
870        } else {
871            trace!(
872                "Discovering workspace members for: `{}`",
873                &workspace_root.simplified_display()
874            );
875            let workspace_members = Self::collect_members_only(
876                &workspace_root,
877                &workspace_definition,
878                &workspace_pyproject_toml,
879                options,
880            )
881            .await?;
882            {
883                // Acquire the lock for the minimal required region
884                let mut cache = cache.0.lock().expect("there was a panic in another thread");
885                cache.insert(cache_key, Arc::new(workspace_members.clone()));
886            }
887            Arc::new(workspace_members)
888        };
889
890        // For the cases such as `MemberDiscovery::None`, add the current project if missing.
891        if let Some(root_member) = current_project {
892            if !workspace_members.contains_key(&root_member.project.name) {
893                debug!(
894                    "Adding current workspace member: `{}`",
895                    root_member.root.simplified_display()
896                );
897
898                Arc::make_mut(&mut workspace_members)
899                    .insert(root_member.project.name.clone(), root_member);
900            }
901        }
902
903        let workspace_sources = workspace_pyproject_toml
904            .tool
905            .clone()
906            .and_then(|tool| tool.uv)
907            .and_then(|uv| uv.sources)
908            .map(ToolUvSources::into_inner)
909            .unwrap_or_default();
910
911        let workspace_indexes = workspace_pyproject_toml
912            .tool
913            .clone()
914            .and_then(|tool| tool.uv)
915            .and_then(|uv| uv.index)
916            .unwrap_or_default();
917
918        let required_members = Self::collect_required_members(
919            &workspace_members,
920            &workspace_sources,
921            &workspace_pyproject_toml,
922        )?;
923
924        let dev_dependencies_members = workspace_members
925            .iter()
926            .filter_map(|(_, member)| {
927                member
928                    .pyproject_toml
929                    .tool
930                    .as_ref()
931                    .and_then(|tool| tool.uv.as_ref())
932                    .and_then(|uv| uv.dev_dependencies.as_ref())
933                    .map(|_| format!("`{}`", member.root().join("pyproject.toml").user_display()))
934            })
935            .join(", ");
936        if !dev_dependencies_members.is_empty() {
937            warn_user_once!(
938                "The `tool.uv.dev-dependencies` field (used in {}) is deprecated and will be removed in a future release; use `dependency-groups.dev` instead",
939                dev_dependencies_members
940            );
941        }
942
943        Ok(Self {
944            install_path: workspace_root,
945            packages: workspace_members,
946            required_members,
947            sources: workspace_sources,
948            indexes: workspace_indexes,
949            pyproject_toml: workspace_pyproject_toml,
950        })
951    }
952
953    async fn collect_members_only(
954        workspace_root: &PathBuf,
955        workspace_definition: &ToolUvWorkspace,
956        workspace_pyproject_toml: &PyProjectToml,
957        options: &DiscoveryOptions,
958    ) -> Result<BTreeMap<PackageName, WorkspaceMember>, WorkspaceError> {
959        let mut workspace_members = BTreeMap::new();
960        // Avoid reading a `pyproject.toml` more than once.
961        let mut seen = FxHashSet::default();
962
963        // Add the project at the workspace root, if it exists and if it's distinct from the current
964        // project. If it is the current project, it is added as such in the next step.
965        if let Some(project) = &workspace_pyproject_toml.project {
966            let pyproject_path = workspace_root.join("pyproject.toml");
967            let contents = fs_err::read_to_string(&pyproject_path)?;
968            let pyproject_toml = PyProjectToml::from_string(contents, &pyproject_path)
969                .map_err(|err| WorkspaceError::Toml(pyproject_path.clone(), Box::new(err)))?;
970
971            debug!(
972                "Adding root workspace member: `{}`",
973                workspace_root.simplified_display()
974            );
975
976            seen.insert(workspace_root.clone());
977            workspace_members.insert(
978                project.name.clone(),
979                WorkspaceMember {
980                    root: workspace_root.clone(),
981                    project: project.clone(),
982                    pyproject_toml,
983                },
984            );
985        }
986
987        // Add all other workspace members.
988        for member_glob in workspace_definition.clone().members.unwrap_or_default() {
989            // Normalize the member glob to remove leading `./` and other relative path components
990            let normalized_glob = uv_fs::normalize_path(Path::new(member_glob.as_str()));
991            let absolute_glob = PathBuf::from(glob::Pattern::escape(
992                workspace_root.simplified().to_string_lossy().as_ref(),
993            ))
994            .join(normalized_glob.as_ref())
995            .to_string_lossy()
996            .to_string();
997            for member_root in glob(&absolute_glob)
998                .map_err(|err| WorkspaceError::Pattern(absolute_glob.clone(), err))?
999            {
1000                let member_root = member_root
1001                    .map_err(|err| WorkspaceError::GlobWalk(absolute_glob.clone(), err))?;
1002                if !seen.insert(member_root.clone()) {
1003                    continue;
1004                }
1005                let member_root = std::path::absolute(&member_root)
1006                    .map_err(WorkspaceError::Normalize)?
1007                    .clone();
1008
1009                // If the directory is explicitly ignored, skip it.
1010                let skip = match &options.members {
1011                    MemberDiscovery::All => false,
1012                    MemberDiscovery::None => true,
1013                    MemberDiscovery::Ignore(ignore) => ignore.contains(member_root.as_path()),
1014                };
1015                if skip {
1016                    debug!(
1017                        "Ignoring workspace member: `{}`",
1018                        member_root.simplified_display()
1019                    );
1020                    continue;
1021                }
1022
1023                // If the member is excluded, ignore it.
1024                if is_excluded_from_workspace(&member_root, workspace_root, workspace_definition)? {
1025                    debug!(
1026                        "Ignoring workspace member: `{}`",
1027                        member_root.simplified_display()
1028                    );
1029                    continue;
1030                }
1031
1032                trace!(
1033                    "Processing workspace member: `{}`",
1034                    member_root.user_display()
1035                );
1036
1037                // Read the member `pyproject.toml`.
1038                let pyproject_path = member_root.join("pyproject.toml");
1039                let contents = match fs_err::tokio::read_to_string(&pyproject_path).await {
1040                    Ok(contents) => contents,
1041                    Err(err) => {
1042                        if !fs_err::metadata(&member_root)?.is_dir() {
1043                            warn!(
1044                                "Ignoring non-directory workspace member: `{}`",
1045                                member_root.simplified_display()
1046                            );
1047                            continue;
1048                        }
1049
1050                        // A directory exists, but it doesn't contain a `pyproject.toml`.
1051                        if err.kind() == std::io::ErrorKind::NotFound {
1052                            // If the directory is hidden, skip it.
1053                            if member_root
1054                                .file_name()
1055                                .map(|name| name.as_encoded_bytes().starts_with(b"."))
1056                                .unwrap_or(false)
1057                            {
1058                                debug!(
1059                                    "Ignoring hidden workspace member: `{}`",
1060                                    member_root.simplified_display()
1061                                );
1062                                continue;
1063                            }
1064
1065                            // If the directory only contains gitignored files
1066                            // (e.g., `__pycache__`), skip it.
1067                            if has_only_gitignored_files(&member_root) {
1068                                debug!(
1069                                    "Ignoring workspace member with only gitignored files: `{}`",
1070                                    member_root.simplified_display()
1071                                );
1072                                continue;
1073                            }
1074
1075                            return Err(WorkspaceError::MissingPyprojectTomlMember(
1076                                member_root,
1077                                member_glob.to_string(),
1078                            ));
1079                        }
1080
1081                        return Err(err.into());
1082                    }
1083                };
1084                let pyproject_toml = PyProjectToml::from_string(contents, &pyproject_path)
1085                    .map_err(|err| WorkspaceError::Toml(pyproject_path.clone(), Box::new(err)))?;
1086
1087                // Check if the current project is explicitly marked as unmanaged.
1088                if pyproject_toml
1089                    .tool
1090                    .as_ref()
1091                    .and_then(|tool| tool.uv.as_ref())
1092                    .and_then(|uv| uv.managed)
1093                    == Some(false)
1094                {
1095                    if let Some(project) = pyproject_toml.project.as_ref() {
1096                        debug!(
1097                            "Project `{}` is marked as unmanaged; omitting from workspace members",
1098                            project.name
1099                        );
1100                    } else {
1101                        debug!(
1102                            "Workspace member at `{}` is marked as unmanaged; omitting from workspace members",
1103                            member_root.simplified_display()
1104                        );
1105                    }
1106                    continue;
1107                }
1108
1109                // Extract the package name.
1110                let Some(project) = pyproject_toml.project.clone() else {
1111                    return Err(WorkspaceError::MissingProject(pyproject_path));
1112                };
1113
1114                debug!(
1115                    "Adding discovered workspace member: `{}`",
1116                    member_root.simplified_display()
1117                );
1118
1119                if let Some(existing) = workspace_members.insert(
1120                    project.name.clone(),
1121                    WorkspaceMember {
1122                        root: member_root.clone(),
1123                        project,
1124                        pyproject_toml,
1125                    },
1126                ) {
1127                    return Err(WorkspaceError::DuplicatePackage {
1128                        name: existing.project.name,
1129                        first: existing.root.clone(),
1130                        second: member_root,
1131                    });
1132                }
1133            }
1134        }
1135
1136        // Test for nested workspaces.
1137        for member in workspace_members.values() {
1138            if member.root() != workspace_root
1139                && member
1140                    .pyproject_toml
1141                    .tool
1142                    .as_ref()
1143                    .and_then(|tool| tool.uv.as_ref())
1144                    .and_then(|uv| uv.workspace.as_ref())
1145                    .is_some()
1146            {
1147                return Err(WorkspaceError::NestedWorkspace(member.root.clone()));
1148            }
1149        }
1150        Ok(workspace_members)
1151    }
1152}
1153
1154/// A project in a workspace.
1155#[derive(Debug, Clone, PartialEq)]
1156#[cfg_attr(test, derive(serde::Serialize))]
1157pub struct WorkspaceMember {
1158    /// The path to the project root.
1159    root: PathBuf,
1160    /// The `[project]` table, from the `pyproject.toml` of the project found at
1161    /// `<root>/pyproject.toml`.
1162    project: Project,
1163    /// The `pyproject.toml` of the project, found at `<root>/pyproject.toml`.
1164    pyproject_toml: PyProjectToml,
1165}
1166
1167impl WorkspaceMember {
1168    /// The path to the project root.
1169    pub fn root(&self) -> &PathBuf {
1170        &self.root
1171    }
1172
1173    /// The `[project]` table, from the `pyproject.toml` of the project found at
1174    /// `<root>/pyproject.toml`.
1175    pub fn project(&self) -> &Project {
1176        &self.project
1177    }
1178
1179    /// The `pyproject.toml` of the project, found at `<root>/pyproject.toml`.
1180    pub fn pyproject_toml(&self) -> &PyProjectToml {
1181        &self.pyproject_toml
1182    }
1183}
1184
1185/// The current project and the workspace it is part of, with all of the workspace members.
1186///
1187/// # Structure
1188///
1189/// The workspace root is a directory with a `pyproject.toml`, all members need to be below that
1190/// directory. The workspace root defines members and exclusions. All packages below it must either
1191/// be a member or excluded. The workspace root can be a package itself or a virtual manifest.
1192///
1193/// For a simple single package project, the workspace root is implicitly the current project root
1194/// and the workspace has only this single member. Otherwise, a workspace root is declared through
1195/// a `tool.uv.workspace` section.
1196///
1197/// A workspace itself does not declare dependencies, instead one member is the current project used
1198/// as main requirement.
1199///
1200/// Each member is a directory with a `pyproject.toml` that contains a `[project]` section. Each
1201/// member is a Python package, with a name, a version and dependencies. Workspace members can
1202/// depend on other workspace members (`foo = { workspace = true }`). You can consider the
1203/// workspace another package source or index, similar to `--find-links`.
1204///
1205/// # Usage
1206///
1207/// There a two main usage patterns: A root package and helpers, and the flat workspace.
1208///
1209/// Root package and helpers:
1210///
1211/// ```text
1212/// albatross
1213/// ├── packages
1214/// │   ├── provider_a
1215/// │   │   ├── pyproject.toml
1216/// │   │   └── src
1217/// │   │       └── provider_a
1218/// │   │           ├── __init__.py
1219/// │   │           └── foo.py
1220/// │   └── provider_b
1221/// │       ├── pyproject.toml
1222/// │       └── src
1223/// │           └── provider_b
1224/// │               ├── __init__.py
1225/// │               └── bar.py
1226/// ├── pyproject.toml
1227/// ├── Readme.md
1228/// ├── uv.lock
1229/// └── src
1230///     └── albatross
1231///         ├── __init__.py
1232///         └── main.py
1233/// ```
1234///
1235/// Flat workspace:
1236///
1237/// ```text
1238/// albatross
1239/// ├── packages
1240/// │   ├── albatross
1241/// │   │   ├── pyproject.toml
1242/// │   │   └── src
1243/// │   │       └── albatross
1244/// │   │           ├── __init__.py
1245/// │   │           └── main.py
1246/// │   ├── provider_a
1247/// │   │   ├── pyproject.toml
1248/// │   │   └── src
1249/// │   │       └── provider_a
1250/// │   │           ├── __init__.py
1251/// │   │           └── foo.py
1252/// │   └── provider_b
1253/// │       ├── pyproject.toml
1254/// │       └── src
1255/// │           └── provider_b
1256/// │               ├── __init__.py
1257/// │               └── bar.py
1258/// ├── pyproject.toml
1259/// ├── Readme.md
1260/// └── uv.lock
1261/// ```
1262#[derive(Debug, Clone)]
1263#[cfg_attr(test, derive(serde::Serialize))]
1264pub struct ProjectWorkspace {
1265    /// The path to the project root.
1266    project_root: PathBuf,
1267    /// The name of the package.
1268    project_name: PackageName,
1269    /// The workspace the project is part of.
1270    workspace: Workspace,
1271}
1272
1273impl ProjectWorkspace {
1274    /// Find the current project and workspace, given the current directory.
1275    ///
1276    /// `stop_discovery_at` must be either `None` or an ancestor of the current directory. If set,
1277    /// only directories between the current path and `stop_discovery_at` are considered.
1278    pub async fn discover(
1279        path: &Path,
1280        options: &DiscoveryOptions,
1281        cache: &WorkspaceCache,
1282    ) -> Result<Self, WorkspaceError> {
1283        let project_root = path
1284            .ancestors()
1285            .take_while(|path| {
1286                // Only walk up the given directory, if any.
1287                options
1288                    .stop_discovery_at
1289                    .as_deref()
1290                    .and_then(Path::parent)
1291                    .map(|stop_discovery_at| stop_discovery_at != *path)
1292                    .unwrap_or(true)
1293            })
1294            .find(|path| path.join("pyproject.toml").is_file())
1295            .ok_or(WorkspaceError::MissingPyprojectToml)?;
1296
1297        debug!(
1298            "Found project root: `{}`",
1299            project_root.simplified_display()
1300        );
1301
1302        Self::from_project_root(project_root, options, cache).await
1303    }
1304
1305    /// Discover the workspace starting from the directory containing the `pyproject.toml`.
1306    async fn from_project_root(
1307        project_root: &Path,
1308        options: &DiscoveryOptions,
1309        cache: &WorkspaceCache,
1310    ) -> Result<Self, WorkspaceError> {
1311        // Read the current `pyproject.toml`.
1312        let pyproject_path = project_root.join("pyproject.toml");
1313        let contents = fs_err::tokio::read_to_string(&pyproject_path).await?;
1314        let pyproject_toml = PyProjectToml::from_string(contents, &pyproject_path)
1315            .map_err(|err| WorkspaceError::Toml(pyproject_path.clone(), Box::new(err)))?;
1316
1317        // It must have a `[project]` table.
1318        let project = pyproject_toml
1319            .project
1320            .clone()
1321            .ok_or(WorkspaceError::MissingProject(pyproject_path))?;
1322
1323        Self::from_project(project_root, &project, &pyproject_toml, options, cache).await
1324    }
1325
1326    /// If the current directory contains a `pyproject.toml` with a `project` table, discover the
1327    /// workspace and return it, otherwise it is a dynamic path dependency and we return `Ok(None)`.
1328    pub async fn from_maybe_project_root(
1329        install_path: &Path,
1330        options: &DiscoveryOptions,
1331        cache: &WorkspaceCache,
1332    ) -> Result<Option<Self>, WorkspaceError> {
1333        // Read the `pyproject.toml`.
1334        let pyproject_path = install_path.join("pyproject.toml");
1335        let Ok(contents) = fs_err::tokio::read_to_string(&pyproject_path).await else {
1336            // No `pyproject.toml`, but there may still be a `setup.py` or `setup.cfg`.
1337            return Ok(None);
1338        };
1339        let pyproject_toml = PyProjectToml::from_string(contents, &pyproject_path)
1340            .map_err(|err| WorkspaceError::Toml(pyproject_path.clone(), Box::new(err)))?;
1341
1342        // Extract the `[project]` metadata.
1343        let Some(project) = pyproject_toml.project.clone() else {
1344            // We have to build to get the metadata.
1345            return Ok(None);
1346        };
1347
1348        match Self::from_project(install_path, &project, &pyproject_toml, options, cache).await {
1349            Ok(workspace) => Ok(Some(workspace)),
1350            Err(WorkspaceError::NonWorkspace(_)) => Ok(None),
1351            Err(err) => Err(err),
1352        }
1353    }
1354
1355    /// Returns the directory containing the closest `pyproject.toml` that defines the current
1356    /// project.
1357    pub fn project_root(&self) -> &Path {
1358        &self.project_root
1359    }
1360
1361    /// Returns the [`PackageName`] of the current project.
1362    pub fn project_name(&self) -> &PackageName {
1363        &self.project_name
1364    }
1365
1366    /// Returns the [`Workspace`] containing the current project.
1367    pub fn workspace(&self) -> &Workspace {
1368        &self.workspace
1369    }
1370
1371    /// Returns the current project as a [`WorkspaceMember`].
1372    pub fn current_project(&self) -> &WorkspaceMember {
1373        &self.workspace().packages[&self.project_name]
1374    }
1375
1376    /// Set the `pyproject.toml` for the current project.
1377    ///
1378    /// Assumes that the project name is unchanged in the updated [`PyProjectToml`].
1379    pub fn update_member(
1380        self,
1381        pyproject_toml: PyProjectToml,
1382    ) -> Result<Option<Self>, WorkspaceError> {
1383        let Some(workspace) = self
1384            .workspace
1385            .update_member(&self.project_name, pyproject_toml)?
1386        else {
1387            return Ok(None);
1388        };
1389        Ok(Some(Self { workspace, ..self }))
1390    }
1391
1392    /// Find the workspace for a project.
1393    async fn from_project(
1394        install_path: &Path,
1395        project: &Project,
1396        project_pyproject_toml: &PyProjectToml,
1397        options: &DiscoveryOptions,
1398        cache: &WorkspaceCache,
1399    ) -> Result<Self, WorkspaceError> {
1400        let project_path = std::path::absolute(install_path)
1401            .map_err(WorkspaceError::Normalize)?
1402            .clone();
1403        // Remove `.` and `..`
1404        let project_path = uv_fs::normalize_path(&project_path);
1405        // Trim trailing slashes.
1406        let project_path = project_path.components().collect::<PathBuf>();
1407
1408        // Check if workspaces are explicitly disabled for the project.
1409        if project_pyproject_toml
1410            .tool
1411            .as_ref()
1412            .and_then(|tool| tool.uv.as_ref())
1413            .and_then(|uv| uv.managed)
1414            == Some(false)
1415        {
1416            debug!("Project `{}` is marked as unmanaged", project.name);
1417            return Err(WorkspaceError::NonWorkspace(project_path));
1418        }
1419
1420        // Check if the current project is also an explicit workspace root.
1421        let mut workspace = project_pyproject_toml
1422            .tool
1423            .as_ref()
1424            .and_then(|tool| tool.uv.as_ref())
1425            .and_then(|uv| uv.workspace.as_ref())
1426            .map(|workspace| {
1427                (
1428                    project_path.clone(),
1429                    workspace.clone(),
1430                    project_pyproject_toml.clone(),
1431                )
1432            });
1433
1434        if workspace.is_none() {
1435            // The project isn't an explicit workspace root, check if we're a regular workspace
1436            // member by looking for an explicit workspace root above.
1437            workspace = find_workspace(&project_path, options).await?;
1438        }
1439
1440        let current_project = WorkspaceMember {
1441            root: project_path.clone(),
1442            project: project.clone(),
1443            pyproject_toml: project_pyproject_toml.clone(),
1444        };
1445
1446        let Some((workspace_root, workspace_definition, workspace_pyproject_toml)) = workspace
1447        else {
1448            // The project isn't an explicit workspace root, but there's also no workspace root
1449            // above it, so the project is an implicit workspace root identical to the project root.
1450            debug!("No workspace root found, using project root");
1451
1452            let current_project_as_members = Arc::new(BTreeMap::from_iter([(
1453                project.name.clone(),
1454                current_project,
1455            )]));
1456            let workspace_sources = BTreeMap::default();
1457            let required_members = Workspace::collect_required_members(
1458                &current_project_as_members,
1459                &workspace_sources,
1460                project_pyproject_toml,
1461            )?;
1462
1463            return Ok(Self {
1464                project_root: project_path.clone(),
1465                project_name: project.name.clone(),
1466                workspace: Workspace {
1467                    install_path: project_path.clone(),
1468                    packages: current_project_as_members,
1469                    required_members,
1470                    // There may be package sources, but we don't need to duplicate them into the
1471                    // workspace sources.
1472                    sources: workspace_sources,
1473                    indexes: Vec::default(),
1474                    pyproject_toml: project_pyproject_toml.clone(),
1475                },
1476            });
1477        };
1478
1479        debug!(
1480            "Found workspace root: `{}`",
1481            workspace_root.simplified_display()
1482        );
1483
1484        let workspace = Workspace::collect_members(
1485            workspace_root,
1486            workspace_definition,
1487            workspace_pyproject_toml,
1488            Some(current_project),
1489            options,
1490            cache,
1491        )
1492        .await?;
1493
1494        Ok(Self {
1495            project_root: project_path,
1496            project_name: project.name.clone(),
1497            workspace,
1498        })
1499    }
1500}
1501
1502/// Find the workspace root above the current project, if any.
1503async fn find_workspace(
1504    project_root: &Path,
1505    options: &DiscoveryOptions,
1506) -> Result<Option<(PathBuf, ToolUvWorkspace, PyProjectToml)>, WorkspaceError> {
1507    // Skip 1 to ignore the current project itself.
1508    for workspace_root in project_root
1509        .ancestors()
1510        .take_while(|path| {
1511            // Only walk up the given directory, if any.
1512            options
1513                .stop_discovery_at
1514                .as_deref()
1515                .and_then(Path::parent)
1516                .map(|stop_discovery_at| stop_discovery_at != *path)
1517                .unwrap_or(true)
1518        })
1519        .skip(1)
1520    {
1521        let pyproject_path = workspace_root.join("pyproject.toml");
1522        if !pyproject_path.is_file() {
1523            continue;
1524        }
1525        trace!(
1526            "Found `pyproject.toml` at: `{}`",
1527            pyproject_path.simplified_display()
1528        );
1529
1530        // Read the `pyproject.toml`.
1531        let contents = fs_err::tokio::read_to_string(&pyproject_path).await?;
1532        let pyproject_toml = PyProjectToml::from_string(contents, &pyproject_path)
1533            .map_err(|err| WorkspaceError::Toml(pyproject_path.clone(), Box::new(err)))?;
1534
1535        return if let Some(workspace) = pyproject_toml
1536            .tool
1537            .as_ref()
1538            .and_then(|tool| tool.uv.as_ref())
1539            .and_then(|uv| uv.workspace.as_ref())
1540        {
1541            if !is_included_in_workspace(project_root, workspace_root, workspace)? {
1542                debug!(
1543                    "Found workspace root `{}`, but project is not included",
1544                    workspace_root.simplified_display()
1545                );
1546                return Ok(None);
1547            }
1548
1549            if is_excluded_from_workspace(project_root, workspace_root, workspace)? {
1550                debug!(
1551                    "Found workspace root `{}`, but project is excluded",
1552                    workspace_root.simplified_display()
1553                );
1554                return Ok(None);
1555            }
1556
1557            // We found a workspace root.
1558            Ok(Some((
1559                workspace_root.to_path_buf(),
1560                workspace.clone(),
1561                pyproject_toml,
1562            )))
1563        } else if pyproject_toml.project.is_some() {
1564            // We're in a directory of another project, e.g. tests or examples.
1565            // Example:
1566            // ```
1567            // albatross
1568            // ├── examples
1569            // │   └── bird-feeder [CURRENT DIRECTORY]
1570            // │       ├── pyproject.toml
1571            // │       └── src
1572            // │           └── bird_feeder
1573            // │               └── __init__.py
1574            // ├── pyproject.toml
1575            // └── src
1576            //     └── albatross
1577            //         └── __init__.py
1578            // ```
1579            // The current project is the example (non-workspace) `bird-feeder` in `albatross`,
1580            // we ignore all `albatross` is doing and any potential workspace it might be
1581            // contained in.
1582            debug!(
1583                "Project is contained in non-workspace project: `{}`",
1584                workspace_root.simplified_display()
1585            );
1586            Ok(None)
1587        } else {
1588            // We require that a `project.toml` file either declares a workspace or a project.
1589            warn!(
1590                "`pyproject.toml` does not contain a `project` table: `{}`",
1591                pyproject_path.simplified_display()
1592            );
1593            Ok(None)
1594        };
1595    }
1596
1597    Ok(None)
1598}
1599
1600/// Check if a directory only contains files that are ignored.
1601///
1602/// Returns `true` if walking the directory while respecting `.gitignore` and `.ignore` rules
1603/// yields no files, indicating that any files present (e.g., `__pycache__`) are all ignored.
1604fn has_only_gitignored_files(path: &Path) -> bool {
1605    let walker = ignore::WalkBuilder::new(path)
1606        .hidden(false)
1607        .parents(true)
1608        .ignore(true)
1609        .git_ignore(true)
1610        .git_global(true)
1611        .git_exclude(true)
1612        .build();
1613
1614    for entry in walker {
1615        let Ok(entry) = entry else {
1616            // If we can't read an entry, assume non-ignored content exists.
1617            return false;
1618        };
1619
1620        // Skip directories.
1621        if entry.path().is_dir() {
1622            continue;
1623        }
1624
1625        // A non-ignored entry exists.
1626        return false;
1627    }
1628
1629    true
1630}
1631
1632/// Check if we're in the `tool.uv.workspace.excluded` of a workspace.
1633fn is_excluded_from_workspace(
1634    project_path: &Path,
1635    workspace_root: &Path,
1636    workspace: &ToolUvWorkspace,
1637) -> Result<bool, WorkspaceError> {
1638    for exclude_glob in workspace.exclude.iter().flatten() {
1639        // Normalize the exclude glob to remove leading `./` and other relative path components
1640        let normalized_glob = uv_fs::normalize_path(Path::new(exclude_glob.as_str()));
1641        let absolute_glob = PathBuf::from(glob::Pattern::escape(
1642            workspace_root.simplified().to_string_lossy().as_ref(),
1643        ))
1644        .join(normalized_glob.as_ref());
1645        let absolute_glob = absolute_glob.to_string_lossy();
1646        let exclude_pattern = glob::Pattern::new(&absolute_glob)
1647            .map_err(|err| WorkspaceError::Pattern(absolute_glob.to_string(), err))?;
1648        if exclude_pattern.matches_path(project_path) {
1649            return Ok(true);
1650        }
1651    }
1652    Ok(false)
1653}
1654
1655/// Check if we're in the `tool.uv.workspace.members` of a workspace.
1656fn is_included_in_workspace(
1657    project_path: &Path,
1658    workspace_root: &Path,
1659    workspace: &ToolUvWorkspace,
1660) -> Result<bool, WorkspaceError> {
1661    for member_glob in workspace.members.iter().flatten() {
1662        // Normalize the member glob to remove leading `./` and other relative path components
1663        let normalized_glob = uv_fs::normalize_path(Path::new(member_glob.as_str()));
1664        let absolute_glob = PathBuf::from(glob::Pattern::escape(
1665            workspace_root.simplified().to_string_lossy().as_ref(),
1666        ))
1667        .join(normalized_glob.as_ref());
1668        let absolute_glob = absolute_glob.to_string_lossy();
1669        let include_pattern = glob::Pattern::new(&absolute_glob)
1670            .map_err(|err| WorkspaceError::Pattern(absolute_glob.to_string(), err))?;
1671        if include_pattern.matches_path(project_path) {
1672            return Ok(true);
1673        }
1674    }
1675    Ok(false)
1676}
1677
1678/// A project that can be discovered.
1679///
1680/// The project could be a package within a workspace, a real workspace root, or a non-project
1681/// workspace root, which can define its own dev dependencies.
1682#[derive(Debug, Clone)]
1683pub enum VirtualProject {
1684    /// A project (which could be a workspace root or member).
1685    Project(ProjectWorkspace),
1686    /// A non-project workspace root.
1687    NonProject(Workspace),
1688}
1689
1690impl VirtualProject {
1691    /// Find the current project or virtual workspace root, given the current directory.
1692    ///
1693    /// Similar to calling [`ProjectWorkspace::discover`] with a fallback to [`Workspace::discover`],
1694    /// but avoids rereading the `pyproject.toml` (and relying on error-handling as control flow).
1695    ///
1696    /// This method requires an absolute path and panics otherwise, i.e. this method only supports
1697    /// discovering the main workspace.
1698    pub async fn discover(
1699        path: &Path,
1700        options: &DiscoveryOptions,
1701        cache: &WorkspaceCache,
1702    ) -> Result<Self, WorkspaceError> {
1703        assert!(
1704            path.is_absolute(),
1705            "virtual project discovery with relative path"
1706        );
1707        let project_root = path
1708            .ancestors()
1709            .take_while(|path| {
1710                // Only walk up the given directory, if any.
1711                options
1712                    .stop_discovery_at
1713                    .as_deref()
1714                    .and_then(Path::parent)
1715                    .map(|stop_discovery_at| stop_discovery_at != *path)
1716                    .unwrap_or(true)
1717            })
1718            .find(|path| path.join("pyproject.toml").is_file())
1719            .ok_or(WorkspaceError::MissingPyprojectToml)?;
1720
1721        debug!(
1722            "Found project root: `{}`",
1723            project_root.simplified_display()
1724        );
1725
1726        // Read the current `pyproject.toml`.
1727        let pyproject_path = project_root.join("pyproject.toml");
1728        let contents = fs_err::tokio::read_to_string(&pyproject_path).await?;
1729        let pyproject_toml = PyProjectToml::from_string(contents, &pyproject_path)
1730            .map_err(|err| WorkspaceError::Toml(pyproject_path.clone(), Box::new(err)))?;
1731
1732        if let Some(project) = pyproject_toml.project.as_ref() {
1733            // If the `pyproject.toml` contains a `[project]` table, it's a project.
1734            let project = ProjectWorkspace::from_project(
1735                project_root,
1736                project,
1737                &pyproject_toml,
1738                options,
1739                cache,
1740            )
1741            .await?;
1742            Ok(Self::Project(project))
1743        } else if let Some(workspace) = pyproject_toml
1744            .tool
1745            .as_ref()
1746            .and_then(|tool| tool.uv.as_ref())
1747            .and_then(|uv| uv.workspace.as_ref())
1748            .filter(|_| options.project.allows_non_project_workspace())
1749        {
1750            // Otherwise, if it contains a `tool.uv.workspace` table, it's a non-project workspace
1751            // root.
1752            let project_path = std::path::absolute(project_root)
1753                .map_err(WorkspaceError::Normalize)?
1754                .clone();
1755
1756            let workspace = Workspace::collect_members(
1757                project_path,
1758                workspace.clone(),
1759                pyproject_toml,
1760                None,
1761                options,
1762                cache,
1763            )
1764            .await?;
1765
1766            Ok(Self::NonProject(workspace))
1767        } else if options.project.allows_implicit_workspace() {
1768            // Otherwise it's a pyproject.toml that maybe contains dependency-groups
1769            // that we want to treat like a project/workspace to handle those uniformly
1770            let project_path = std::path::absolute(project_root)
1771                .map_err(WorkspaceError::Normalize)?
1772                .clone();
1773
1774            let workspace = Workspace::collect_members(
1775                project_path,
1776                ToolUvWorkspace::default(),
1777                pyproject_toml,
1778                None,
1779                options,
1780                cache,
1781            )
1782            .await?;
1783
1784            Ok(Self::NonProject(workspace))
1785        } else {
1786            Err(WorkspaceError::MissingProject(pyproject_path))
1787        }
1788    }
1789
1790    /// Discover a project workspace with the member package.
1791    pub async fn discover_with_package(
1792        path: &Path,
1793        options: &DiscoveryOptions,
1794        cache: &WorkspaceCache,
1795        package: PackageName,
1796    ) -> Result<Self, WorkspaceError> {
1797        let workspace = Workspace::discover(path, options, cache).await?;
1798        let project_workspace = Workspace::with_current_project(workspace.clone(), package.clone());
1799        Ok(Self::Project(project_workspace.ok_or_else(|| {
1800            WorkspaceError::NoSuchMember(package.clone(), workspace.install_path)
1801        })?))
1802    }
1803
1804    /// Update the `pyproject.toml` for the current project.
1805    ///
1806    /// Assumes that the project name is unchanged in the updated [`PyProjectToml`].
1807    pub fn update_member(
1808        self,
1809        pyproject_toml: PyProjectToml,
1810    ) -> Result<Option<Self>, WorkspaceError> {
1811        Ok(match self {
1812            Self::Project(project) => {
1813                let Some(project) = project.update_member(pyproject_toml)? else {
1814                    return Ok(None);
1815                };
1816                Some(Self::Project(project))
1817            }
1818            Self::NonProject(workspace) => {
1819                // If this is a non-project workspace root, then by definition the root isn't a
1820                // member, so we can just update the top-level `pyproject.toml`.
1821                Some(Self::NonProject(Workspace {
1822                    pyproject_toml,
1823                    ..workspace.clone()
1824                }))
1825            }
1826        })
1827    }
1828
1829    /// Return the root of the project.
1830    pub fn root(&self) -> &Path {
1831        match self {
1832            Self::Project(project) => project.project_root(),
1833            Self::NonProject(workspace) => workspace.install_path(),
1834        }
1835    }
1836
1837    /// Return the [`PyProjectToml`] of the project.
1838    pub fn pyproject_toml(&self) -> &PyProjectToml {
1839        match self {
1840            Self::Project(project) => project.current_project().pyproject_toml(),
1841            Self::NonProject(workspace) => &workspace.pyproject_toml,
1842        }
1843    }
1844
1845    /// Return the [`Workspace`] of the project.
1846    pub fn workspace(&self) -> &Workspace {
1847        match self {
1848            Self::Project(project) => project.workspace(),
1849            Self::NonProject(workspace) => workspace,
1850        }
1851    }
1852
1853    /// Return the [`PackageName`] of the project, if available.
1854    pub fn project_name(&self) -> Option<&PackageName> {
1855        match self {
1856            Self::Project(project) => Some(project.project_name()),
1857            Self::NonProject(_) => None,
1858        }
1859    }
1860
1861    /// Returns `true` if the project is a virtual workspace root.
1862    pub fn is_non_project(&self) -> bool {
1863        matches!(self, Self::NonProject(_))
1864    }
1865}
1866
1867#[cfg(test)]
1868#[cfg(unix)] // Avoid path escaping for the unit tests
1869mod tests {
1870    use std::env;
1871    use std::path::Path;
1872    use std::str::FromStr;
1873
1874    use anyhow::Result;
1875    use assert_fs::fixture::ChildPath;
1876    use assert_fs::prelude::*;
1877    use insta::{assert_json_snapshot, assert_snapshot};
1878
1879    use uv_normalize::GroupName;
1880    use uv_pypi_types::DependencyGroupSpecifier;
1881
1882    use crate::pyproject::PyProjectToml;
1883    use crate::workspace::{DiscoveryOptions, ProjectWorkspace};
1884    use crate::{WorkspaceCache, WorkspaceError};
1885
1886    async fn workspace_test(folder: &str) -> (ProjectWorkspace, String) {
1887        let root_dir = env::current_dir()
1888            .unwrap()
1889            .parent()
1890            .unwrap()
1891            .parent()
1892            .unwrap()
1893            .join("test")
1894            .join("workspaces");
1895        let project = ProjectWorkspace::discover(
1896            &root_dir.join(folder),
1897            &DiscoveryOptions::default(),
1898            &WorkspaceCache::default(),
1899        )
1900        .await
1901        .unwrap();
1902        let root_escaped = regex::escape(root_dir.to_string_lossy().as_ref());
1903        (project, root_escaped)
1904    }
1905
1906    async fn temporary_test(
1907        folder: &Path,
1908    ) -> Result<(ProjectWorkspace, String), (WorkspaceError, String)> {
1909        let root_escaped = regex::escape(folder.to_string_lossy().as_ref());
1910        let project = ProjectWorkspace::discover(
1911            folder,
1912            &DiscoveryOptions::default(),
1913            &WorkspaceCache::default(),
1914        )
1915        .await
1916        .map_err(|error| (error, root_escaped.clone()))?;
1917
1918        Ok((project, root_escaped))
1919    }
1920
1921    #[tokio::test]
1922    async fn albatross_in_example() {
1923        let (project, root_escaped) =
1924            workspace_test("albatross-in-example/examples/bird-feeder").await;
1925        let filters = vec![(root_escaped.as_str(), "[ROOT]")];
1926        insta::with_settings!({filters => filters}, {
1927        assert_json_snapshot!(
1928            project,
1929            {
1930                ".workspace.packages.*.pyproject_toml" => "[PYPROJECT_TOML]"
1931            },
1932            @r#"
1933        {
1934          "project_root": "[ROOT]/albatross-in-example/examples/bird-feeder",
1935          "project_name": "bird-feeder",
1936          "workspace": {
1937            "install_path": "[ROOT]/albatross-in-example/examples/bird-feeder",
1938            "packages": {
1939              "bird-feeder": {
1940                "root": "[ROOT]/albatross-in-example/examples/bird-feeder",
1941                "project": {
1942                  "name": "bird-feeder",
1943                  "version": "1.0.0",
1944                  "requires-python": ">=3.12",
1945                  "dependencies": [
1946                    "iniconfig>=2,<3"
1947                  ],
1948                  "optional-dependencies": null
1949                },
1950                "pyproject_toml": "[PYPROJECT_TOML]"
1951              }
1952            },
1953            "required_members": {},
1954            "sources": {},
1955            "indexes": [],
1956            "pyproject_toml": {
1957              "project": {
1958                "name": "bird-feeder",
1959                "version": "1.0.0",
1960                "requires-python": ">=3.12",
1961                "dependencies": [
1962                  "iniconfig>=2,<3"
1963                ],
1964                "optional-dependencies": null
1965              },
1966              "tool": null,
1967              "dependency-groups": null
1968            }
1969          }
1970        }
1971        "#);
1972        });
1973    }
1974
1975    #[tokio::test]
1976    async fn albatross_project_in_excluded() {
1977        let (project, root_escaped) =
1978            workspace_test("albatross-project-in-excluded/excluded/bird-feeder").await;
1979        let filters = vec![(root_escaped.as_str(), "[ROOT]")];
1980        insta::with_settings!({filters => filters}, {
1981            assert_json_snapshot!(
1982            project,
1983            {
1984                ".workspace.packages.*.pyproject_toml" => "[PYPROJECT_TOML]"
1985            },
1986            @r#"
1987            {
1988              "project_root": "[ROOT]/albatross-project-in-excluded/excluded/bird-feeder",
1989              "project_name": "bird-feeder",
1990              "workspace": {
1991                "install_path": "[ROOT]/albatross-project-in-excluded/excluded/bird-feeder",
1992                "packages": {
1993                  "bird-feeder": {
1994                    "root": "[ROOT]/albatross-project-in-excluded/excluded/bird-feeder",
1995                    "project": {
1996                      "name": "bird-feeder",
1997                      "version": "1.0.0",
1998                      "requires-python": ">=3.12",
1999                      "dependencies": [
2000                        "iniconfig>=2,<3"
2001                      ],
2002                      "optional-dependencies": null
2003                    },
2004                    "pyproject_toml": "[PYPROJECT_TOML]"
2005                  }
2006                },
2007                "required_members": {},
2008                "sources": {},
2009                "indexes": [],
2010                "pyproject_toml": {
2011                  "project": {
2012                    "name": "bird-feeder",
2013                    "version": "1.0.0",
2014                    "requires-python": ">=3.12",
2015                    "dependencies": [
2016                      "iniconfig>=2,<3"
2017                    ],
2018                    "optional-dependencies": null
2019                  },
2020                  "tool": null,
2021                  "dependency-groups": null
2022                }
2023              }
2024            }
2025            "#);
2026        });
2027    }
2028
2029    #[tokio::test]
2030    async fn albatross_root_workspace() {
2031        let (project, root_escaped) = workspace_test("albatross-root-workspace").await;
2032        let filters = vec![(root_escaped.as_str(), "[ROOT]")];
2033        insta::with_settings!({filters => filters}, {
2034            assert_json_snapshot!(
2035            project,
2036            {
2037                ".workspace.packages.*.pyproject_toml" => "[PYPROJECT_TOML]"
2038            },
2039            @r#"
2040            {
2041              "project_root": "[ROOT]/albatross-root-workspace",
2042              "project_name": "albatross",
2043              "workspace": {
2044                "install_path": "[ROOT]/albatross-root-workspace",
2045                "packages": {
2046                  "albatross": {
2047                    "root": "[ROOT]/albatross-root-workspace",
2048                    "project": {
2049                      "name": "albatross",
2050                      "version": "0.1.0",
2051                      "requires-python": ">=3.12",
2052                      "dependencies": [
2053                        "bird-feeder",
2054                        "iniconfig>=2,<3"
2055                      ],
2056                      "optional-dependencies": null
2057                    },
2058                    "pyproject_toml": "[PYPROJECT_TOML]"
2059                  },
2060                  "bird-feeder": {
2061                    "root": "[ROOT]/albatross-root-workspace/packages/bird-feeder",
2062                    "project": {
2063                      "name": "bird-feeder",
2064                      "version": "1.0.0",
2065                      "requires-python": ">=3.8",
2066                      "dependencies": [
2067                        "iniconfig>=2,<3",
2068                        "seeds"
2069                      ],
2070                      "optional-dependencies": null
2071                    },
2072                    "pyproject_toml": "[PYPROJECT_TOML]"
2073                  },
2074                  "seeds": {
2075                    "root": "[ROOT]/albatross-root-workspace/packages/seeds",
2076                    "project": {
2077                      "name": "seeds",
2078                      "version": "1.0.0",
2079                      "requires-python": ">=3.12",
2080                      "dependencies": [
2081                        "idna==3.6"
2082                      ],
2083                      "optional-dependencies": null
2084                    },
2085                    "pyproject_toml": "[PYPROJECT_TOML]"
2086                  }
2087                },
2088                "required_members": {
2089                  "bird-feeder": null,
2090                  "seeds": null
2091                },
2092                "sources": {
2093                  "bird-feeder": [
2094                    {
2095                      "workspace": true,
2096                      "editable": null,
2097                      "extra": null,
2098                      "group": null
2099                    }
2100                  ]
2101                },
2102                "indexes": [],
2103                "pyproject_toml": {
2104                  "project": {
2105                    "name": "albatross",
2106                    "version": "0.1.0",
2107                    "requires-python": ">=3.12",
2108                    "dependencies": [
2109                      "bird-feeder",
2110                      "iniconfig>=2,<3"
2111                    ],
2112                    "optional-dependencies": null
2113                  },
2114                  "tool": {
2115                    "uv": {
2116                      "sources": {
2117                        "bird-feeder": [
2118                          {
2119                            "workspace": true,
2120                            "editable": null,
2121                            "extra": null,
2122                            "group": null
2123                          }
2124                        ]
2125                      },
2126                      "index": null,
2127                      "workspace": {
2128                        "members": [
2129                          "packages/*"
2130                        ],
2131                        "exclude": null
2132                      },
2133                      "managed": null,
2134                      "package": null,
2135                      "default-groups": null,
2136                      "dependency-groups": null,
2137                      "dev-dependencies": null,
2138                      "override-dependencies": null,
2139                      "exclude-dependencies": null,
2140                      "constraint-dependencies": null,
2141                      "build-constraint-dependencies": null,
2142                      "environments": null,
2143                      "required-environments": null,
2144                      "conflicts": null,
2145                      "build-backend": null
2146                    }
2147                  },
2148                  "dependency-groups": null
2149                }
2150              }
2151            }
2152            "#);
2153        });
2154    }
2155
2156    #[tokio::test]
2157    async fn albatross_virtual_workspace() {
2158        let (project, root_escaped) =
2159            workspace_test("albatross-virtual-workspace/packages/albatross").await;
2160        let filters = vec![(root_escaped.as_str(), "[ROOT]")];
2161        insta::with_settings!({filters => filters}, {
2162            assert_json_snapshot!(
2163            project,
2164            {
2165                ".workspace.packages.*.pyproject_toml" => "[PYPROJECT_TOML]"
2166            },
2167            @r#"
2168            {
2169              "project_root": "[ROOT]/albatross-virtual-workspace/packages/albatross",
2170              "project_name": "albatross",
2171              "workspace": {
2172                "install_path": "[ROOT]/albatross-virtual-workspace",
2173                "packages": {
2174                  "albatross": {
2175                    "root": "[ROOT]/albatross-virtual-workspace/packages/albatross",
2176                    "project": {
2177                      "name": "albatross",
2178                      "version": "0.1.0",
2179                      "requires-python": ">=3.12",
2180                      "dependencies": [
2181                        "bird-feeder",
2182                        "iniconfig>=2,<3"
2183                      ],
2184                      "optional-dependencies": null
2185                    },
2186                    "pyproject_toml": "[PYPROJECT_TOML]"
2187                  },
2188                  "bird-feeder": {
2189                    "root": "[ROOT]/albatross-virtual-workspace/packages/bird-feeder",
2190                    "project": {
2191                      "name": "bird-feeder",
2192                      "version": "1.0.0",
2193                      "requires-python": ">=3.12",
2194                      "dependencies": [
2195                        "anyio>=4.3.0,<5",
2196                        "seeds"
2197                      ],
2198                      "optional-dependencies": null
2199                    },
2200                    "pyproject_toml": "[PYPROJECT_TOML]"
2201                  },
2202                  "seeds": {
2203                    "root": "[ROOT]/albatross-virtual-workspace/packages/seeds",
2204                    "project": {
2205                      "name": "seeds",
2206                      "version": "1.0.0",
2207                      "requires-python": ">=3.12",
2208                      "dependencies": [
2209                        "idna==3.6"
2210                      ],
2211                      "optional-dependencies": null
2212                    },
2213                    "pyproject_toml": "[PYPROJECT_TOML]"
2214                  }
2215                },
2216                "required_members": {
2217                  "bird-feeder": null,
2218                  "seeds": null
2219                },
2220                "sources": {},
2221                "indexes": [],
2222                "pyproject_toml": {
2223                  "project": null,
2224                  "tool": {
2225                    "uv": {
2226                      "sources": null,
2227                      "index": null,
2228                      "workspace": {
2229                        "members": [
2230                          "packages/*"
2231                        ],
2232                        "exclude": null
2233                      },
2234                      "managed": null,
2235                      "package": null,
2236                      "default-groups": null,
2237                      "dependency-groups": null,
2238                      "dev-dependencies": null,
2239                      "override-dependencies": null,
2240                      "exclude-dependencies": null,
2241                      "constraint-dependencies": null,
2242                      "build-constraint-dependencies": null,
2243                      "environments": null,
2244                      "required-environments": null,
2245                      "conflicts": null,
2246                      "build-backend": null
2247                    }
2248                  },
2249                  "dependency-groups": null
2250                }
2251              }
2252            }
2253            "#);
2254        });
2255    }
2256
2257    #[tokio::test]
2258    async fn albatross_just_project() {
2259        let (project, root_escaped) = workspace_test("albatross-just-project").await;
2260        let filters = vec![(root_escaped.as_str(), "[ROOT]")];
2261        insta::with_settings!({filters => filters}, {
2262            assert_json_snapshot!(
2263            project,
2264            {
2265                ".workspace.packages.*.pyproject_toml" => "[PYPROJECT_TOML]"
2266            },
2267            @r#"
2268            {
2269              "project_root": "[ROOT]/albatross-just-project",
2270              "project_name": "albatross",
2271              "workspace": {
2272                "install_path": "[ROOT]/albatross-just-project",
2273                "packages": {
2274                  "albatross": {
2275                    "root": "[ROOT]/albatross-just-project",
2276                    "project": {
2277                      "name": "albatross",
2278                      "version": "0.1.0",
2279                      "requires-python": ">=3.12",
2280                      "dependencies": [
2281                        "iniconfig>=2,<3"
2282                      ],
2283                      "optional-dependencies": null
2284                    },
2285                    "pyproject_toml": "[PYPROJECT_TOML]"
2286                  }
2287                },
2288                "required_members": {},
2289                "sources": {},
2290                "indexes": [],
2291                "pyproject_toml": {
2292                  "project": {
2293                    "name": "albatross",
2294                    "version": "0.1.0",
2295                    "requires-python": ">=3.12",
2296                    "dependencies": [
2297                      "iniconfig>=2,<3"
2298                    ],
2299                    "optional-dependencies": null
2300                  },
2301                  "tool": null,
2302                  "dependency-groups": null
2303                }
2304              }
2305            }
2306            "#);
2307        });
2308    }
2309
2310    #[tokio::test]
2311    async fn exclude_package() -> Result<()> {
2312        let root = tempfile::TempDir::new()?;
2313        let root = ChildPath::new(root.path());
2314
2315        // Create the root.
2316        root.child("pyproject.toml").write_str(
2317            r#"
2318            [project]
2319            name = "albatross"
2320            version = "0.1.0"
2321            requires-python = ">=3.12"
2322            dependencies = ["tqdm>=4,<5"]
2323
2324            [tool.uv.workspace]
2325            members = ["packages/*"]
2326            exclude = ["packages/bird-feeder"]
2327
2328            [build-system]
2329            requires = ["hatchling"]
2330            build-backend = "hatchling.build"
2331            "#,
2332        )?;
2333        root.child("albatross").child("__init__.py").touch()?;
2334
2335        // Create an included package (`seeds`).
2336        root.child("packages")
2337            .child("seeds")
2338            .child("pyproject.toml")
2339            .write_str(
2340                r#"
2341            [project]
2342            name = "seeds"
2343            version = "1.0.0"
2344            requires-python = ">=3.12"
2345            dependencies = ["idna==3.6"]
2346
2347            [build-system]
2348            requires = ["hatchling"]
2349            build-backend = "hatchling.build"
2350            "#,
2351            )?;
2352        root.child("packages")
2353            .child("seeds")
2354            .child("seeds")
2355            .child("__init__.py")
2356            .touch()?;
2357
2358        // Create an excluded package (`bird-feeder`).
2359        root.child("packages")
2360            .child("bird-feeder")
2361            .child("pyproject.toml")
2362            .write_str(
2363                r#"
2364            [project]
2365            name = "bird-feeder"
2366            version = "1.0.0"
2367            requires-python = ">=3.12"
2368            dependencies = ["anyio>=4.3.0,<5"]
2369
2370            [build-system]
2371            requires = ["hatchling"]
2372            build-backend = "hatchling.build"
2373            "#,
2374            )?;
2375        root.child("packages")
2376            .child("bird-feeder")
2377            .child("bird_feeder")
2378            .child("__init__.py")
2379            .touch()?;
2380
2381        let (project, root_escaped) = temporary_test(root.as_ref()).await.unwrap();
2382        let filters = vec![(root_escaped.as_str(), "[ROOT]")];
2383        insta::with_settings!({filters => filters}, {
2384            assert_json_snapshot!(
2385            project,
2386            {
2387                ".workspace.packages.*.pyproject_toml" => "[PYPROJECT_TOML]"
2388            },
2389            @r#"
2390            {
2391              "project_root": "[ROOT]",
2392              "project_name": "albatross",
2393              "workspace": {
2394                "install_path": "[ROOT]",
2395                "packages": {
2396                  "albatross": {
2397                    "root": "[ROOT]",
2398                    "project": {
2399                      "name": "albatross",
2400                      "version": "0.1.0",
2401                      "requires-python": ">=3.12",
2402                      "dependencies": [
2403                        "tqdm>=4,<5"
2404                      ],
2405                      "optional-dependencies": null
2406                    },
2407                    "pyproject_toml": "[PYPROJECT_TOML]"
2408                  },
2409                  "seeds": {
2410                    "root": "[ROOT]/packages/seeds",
2411                    "project": {
2412                      "name": "seeds",
2413                      "version": "1.0.0",
2414                      "requires-python": ">=3.12",
2415                      "dependencies": [
2416                        "idna==3.6"
2417                      ],
2418                      "optional-dependencies": null
2419                    },
2420                    "pyproject_toml": "[PYPROJECT_TOML]"
2421                  }
2422                },
2423                "required_members": {},
2424                "sources": {},
2425                "indexes": [],
2426                "pyproject_toml": {
2427                  "project": {
2428                    "name": "albatross",
2429                    "version": "0.1.0",
2430                    "requires-python": ">=3.12",
2431                    "dependencies": [
2432                      "tqdm>=4,<5"
2433                    ],
2434                    "optional-dependencies": null
2435                  },
2436                  "tool": {
2437                    "uv": {
2438                      "sources": null,
2439                      "index": null,
2440                      "workspace": {
2441                        "members": [
2442                          "packages/*"
2443                        ],
2444                        "exclude": [
2445                          "packages/bird-feeder"
2446                        ]
2447                      },
2448                      "managed": null,
2449                      "package": null,
2450                      "default-groups": null,
2451                      "dependency-groups": null,
2452                      "dev-dependencies": null,
2453                      "override-dependencies": null,
2454                      "exclude-dependencies": null,
2455                      "constraint-dependencies": null,
2456                      "build-constraint-dependencies": null,
2457                      "environments": null,
2458                      "required-environments": null,
2459                      "conflicts": null,
2460                      "build-backend": null
2461                    }
2462                  },
2463                  "dependency-groups": null
2464                }
2465              }
2466            }
2467            "#);
2468        });
2469
2470        // Rewrite the members to both include and exclude `bird-feeder` by name.
2471        root.child("pyproject.toml").write_str(
2472            r#"
2473            [project]
2474            name = "albatross"
2475            version = "0.1.0"
2476            requires-python = ">=3.12"
2477            dependencies = ["tqdm>=4,<5"]
2478
2479            [tool.uv.workspace]
2480            members = ["packages/seeds", "packages/bird-feeder"]
2481            exclude = ["packages/bird-feeder"]
2482
2483            [build-system]
2484            requires = ["hatchling"]
2485            build-backend = "hatchling.build"
2486            "#,
2487        )?;
2488
2489        // `bird-feeder` should still be excluded.
2490        let (project, root_escaped) = temporary_test(root.as_ref()).await.unwrap();
2491        let filters = vec![(root_escaped.as_str(), "[ROOT]")];
2492        insta::with_settings!({filters => filters}, {
2493            assert_json_snapshot!(
2494            project,
2495            {
2496                ".workspace.packages.*.pyproject_toml" => "[PYPROJECT_TOML]"
2497            },
2498            @r#"
2499            {
2500              "project_root": "[ROOT]",
2501              "project_name": "albatross",
2502              "workspace": {
2503                "install_path": "[ROOT]",
2504                "packages": {
2505                  "albatross": {
2506                    "root": "[ROOT]",
2507                    "project": {
2508                      "name": "albatross",
2509                      "version": "0.1.0",
2510                      "requires-python": ">=3.12",
2511                      "dependencies": [
2512                        "tqdm>=4,<5"
2513                      ],
2514                      "optional-dependencies": null
2515                    },
2516                    "pyproject_toml": "[PYPROJECT_TOML]"
2517                  },
2518                  "seeds": {
2519                    "root": "[ROOT]/packages/seeds",
2520                    "project": {
2521                      "name": "seeds",
2522                      "version": "1.0.0",
2523                      "requires-python": ">=3.12",
2524                      "dependencies": [
2525                        "idna==3.6"
2526                      ],
2527                      "optional-dependencies": null
2528                    },
2529                    "pyproject_toml": "[PYPROJECT_TOML]"
2530                  }
2531                },
2532                "required_members": {},
2533                "sources": {},
2534                "indexes": [],
2535                "pyproject_toml": {
2536                  "project": {
2537                    "name": "albatross",
2538                    "version": "0.1.0",
2539                    "requires-python": ">=3.12",
2540                    "dependencies": [
2541                      "tqdm>=4,<5"
2542                    ],
2543                    "optional-dependencies": null
2544                  },
2545                  "tool": {
2546                    "uv": {
2547                      "sources": null,
2548                      "index": null,
2549                      "workspace": {
2550                        "members": [
2551                          "packages/seeds",
2552                          "packages/bird-feeder"
2553                        ],
2554                        "exclude": [
2555                          "packages/bird-feeder"
2556                        ]
2557                      },
2558                      "managed": null,
2559                      "package": null,
2560                      "default-groups": null,
2561                      "dependency-groups": null,
2562                      "dev-dependencies": null,
2563                      "override-dependencies": null,
2564                      "exclude-dependencies": null,
2565                      "constraint-dependencies": null,
2566                      "build-constraint-dependencies": null,
2567                      "environments": null,
2568                      "required-environments": null,
2569                      "conflicts": null,
2570                      "build-backend": null
2571                    }
2572                  },
2573                  "dependency-groups": null
2574                }
2575              }
2576            }
2577            "#);
2578        });
2579
2580        // Rewrite the exclusion to use the top-level directory (`packages`).
2581        root.child("pyproject.toml").write_str(
2582            r#"
2583            [project]
2584            name = "albatross"
2585            version = "0.1.0"
2586            requires-python = ">=3.12"
2587            dependencies = ["tqdm>=4,<5"]
2588
2589            [tool.uv.workspace]
2590            members = ["packages/seeds", "packages/bird-feeder"]
2591            exclude = ["packages"]
2592
2593            [build-system]
2594            requires = ["hatchling"]
2595            build-backend = "hatchling.build"
2596            "#,
2597        )?;
2598
2599        // `bird-feeder` should now be included.
2600        let (project, root_escaped) = temporary_test(root.as_ref()).await.unwrap();
2601        let filters = vec![(root_escaped.as_str(), "[ROOT]")];
2602        insta::with_settings!({filters => filters}, {
2603            assert_json_snapshot!(
2604            project,
2605            {
2606                ".workspace.packages.*.pyproject_toml" => "[PYPROJECT_TOML]"
2607            },
2608            @r#"
2609            {
2610              "project_root": "[ROOT]",
2611              "project_name": "albatross",
2612              "workspace": {
2613                "install_path": "[ROOT]",
2614                "packages": {
2615                  "albatross": {
2616                    "root": "[ROOT]",
2617                    "project": {
2618                      "name": "albatross",
2619                      "version": "0.1.0",
2620                      "requires-python": ">=3.12",
2621                      "dependencies": [
2622                        "tqdm>=4,<5"
2623                      ],
2624                      "optional-dependencies": null
2625                    },
2626                    "pyproject_toml": "[PYPROJECT_TOML]"
2627                  },
2628                  "bird-feeder": {
2629                    "root": "[ROOT]/packages/bird-feeder",
2630                    "project": {
2631                      "name": "bird-feeder",
2632                      "version": "1.0.0",
2633                      "requires-python": ">=3.12",
2634                      "dependencies": [
2635                        "anyio>=4.3.0,<5"
2636                      ],
2637                      "optional-dependencies": null
2638                    },
2639                    "pyproject_toml": "[PYPROJECT_TOML]"
2640                  },
2641                  "seeds": {
2642                    "root": "[ROOT]/packages/seeds",
2643                    "project": {
2644                      "name": "seeds",
2645                      "version": "1.0.0",
2646                      "requires-python": ">=3.12",
2647                      "dependencies": [
2648                        "idna==3.6"
2649                      ],
2650                      "optional-dependencies": null
2651                    },
2652                    "pyproject_toml": "[PYPROJECT_TOML]"
2653                  }
2654                },
2655                "required_members": {},
2656                "sources": {},
2657                "indexes": [],
2658                "pyproject_toml": {
2659                  "project": {
2660                    "name": "albatross",
2661                    "version": "0.1.0",
2662                    "requires-python": ">=3.12",
2663                    "dependencies": [
2664                      "tqdm>=4,<5"
2665                    ],
2666                    "optional-dependencies": null
2667                  },
2668                  "tool": {
2669                    "uv": {
2670                      "sources": null,
2671                      "index": null,
2672                      "workspace": {
2673                        "members": [
2674                          "packages/seeds",
2675                          "packages/bird-feeder"
2676                        ],
2677                        "exclude": [
2678                          "packages"
2679                        ]
2680                      },
2681                      "managed": null,
2682                      "package": null,
2683                      "default-groups": null,
2684                      "dependency-groups": null,
2685                      "dev-dependencies": null,
2686                      "override-dependencies": null,
2687                      "exclude-dependencies": null,
2688                      "constraint-dependencies": null,
2689                      "build-constraint-dependencies": null,
2690                      "environments": null,
2691                      "required-environments": null,
2692                      "conflicts": null,
2693                      "build-backend": null
2694                    }
2695                  },
2696                  "dependency-groups": null
2697                }
2698              }
2699            }
2700            "#);
2701        });
2702
2703        // Rewrite the exclusion to use the top-level directory with a glob (`packages/*`).
2704        root.child("pyproject.toml").write_str(
2705            r#"
2706            [project]
2707            name = "albatross"
2708            version = "0.1.0"
2709            requires-python = ">=3.12"
2710            dependencies = ["tqdm>=4,<5"]
2711
2712            [tool.uv.workspace]
2713            members = ["packages/seeds", "packages/bird-feeder"]
2714            exclude = ["packages/*"]
2715
2716            [build-system]
2717            requires = ["hatchling"]
2718            build-backend = "hatchling.build"
2719            "#,
2720        )?;
2721
2722        // `bird-feeder` and `seeds` should now be excluded.
2723        let (project, root_escaped) = temporary_test(root.as_ref()).await.unwrap();
2724        let filters = vec![(root_escaped.as_str(), "[ROOT]")];
2725        insta::with_settings!({filters => filters}, {
2726            assert_json_snapshot!(
2727            project,
2728            {
2729                ".workspace.packages.*.pyproject_toml" => "[PYPROJECT_TOML]"
2730            },
2731            @r#"
2732            {
2733              "project_root": "[ROOT]",
2734              "project_name": "albatross",
2735              "workspace": {
2736                "install_path": "[ROOT]",
2737                "packages": {
2738                  "albatross": {
2739                    "root": "[ROOT]",
2740                    "project": {
2741                      "name": "albatross",
2742                      "version": "0.1.0",
2743                      "requires-python": ">=3.12",
2744                      "dependencies": [
2745                        "tqdm>=4,<5"
2746                      ],
2747                      "optional-dependencies": null
2748                    },
2749                    "pyproject_toml": "[PYPROJECT_TOML]"
2750                  }
2751                },
2752                "required_members": {},
2753                "sources": {},
2754                "indexes": [],
2755                "pyproject_toml": {
2756                  "project": {
2757                    "name": "albatross",
2758                    "version": "0.1.0",
2759                    "requires-python": ">=3.12",
2760                    "dependencies": [
2761                      "tqdm>=4,<5"
2762                    ],
2763                    "optional-dependencies": null
2764                  },
2765                  "tool": {
2766                    "uv": {
2767                      "sources": null,
2768                      "index": null,
2769                      "workspace": {
2770                        "members": [
2771                          "packages/seeds",
2772                          "packages/bird-feeder"
2773                        ],
2774                        "exclude": [
2775                          "packages/*"
2776                        ]
2777                      },
2778                      "managed": null,
2779                      "package": null,
2780                      "default-groups": null,
2781                      "dependency-groups": null,
2782                      "dev-dependencies": null,
2783                      "override-dependencies": null,
2784                      "exclude-dependencies": null,
2785                      "constraint-dependencies": null,
2786                      "build-constraint-dependencies": null,
2787                      "environments": null,
2788                      "required-environments": null,
2789                      "conflicts": null,
2790                      "build-backend": null
2791                    }
2792                  },
2793                  "dependency-groups": null
2794                }
2795              }
2796            }
2797            "#);
2798        });
2799
2800        Ok(())
2801    }
2802
2803    #[test]
2804    fn read_dependency_groups() {
2805        let toml = r#"
2806[dependency-groups]
2807foo = ["a", {include-group = "bar"}]
2808bar = ["b"]
2809"#;
2810
2811        let result = PyProjectToml::from_string(toml.to_string(), "pyproject.toml")
2812            .expect("Deserialization should succeed");
2813
2814        let groups = result
2815            .dependency_groups
2816            .expect("`dependency-groups` should be present");
2817        let foo = groups
2818            .get(&GroupName::from_str("foo").unwrap())
2819            .expect("Group `foo` should be present");
2820        assert_eq!(
2821            foo,
2822            &[
2823                DependencyGroupSpecifier::Requirement("a".to_string()),
2824                DependencyGroupSpecifier::IncludeGroup {
2825                    include_group: GroupName::from_str("bar").unwrap(),
2826                }
2827            ]
2828        );
2829
2830        let bar = groups
2831            .get(&GroupName::from_str("bar").unwrap())
2832            .expect("Group `bar` should be present");
2833        assert_eq!(
2834            bar,
2835            &[DependencyGroupSpecifier::Requirement("b".to_string())]
2836        );
2837    }
2838
2839    #[tokio::test]
2840    async fn nested_workspace() -> Result<()> {
2841        let root = tempfile::TempDir::new()?;
2842        let root = ChildPath::new(root.path());
2843
2844        // Create the root.
2845        root.child("pyproject.toml").write_str(
2846            r#"
2847            [project]
2848            name = "albatross"
2849            version = "0.1.0"
2850            requires-python = ">=3.12"
2851            dependencies = ["tqdm>=4,<5"]
2852
2853            [tool.uv.workspace]
2854            members = ["packages/*"]
2855            "#,
2856        )?;
2857
2858        // Create an included package (`seeds`).
2859        root.child("packages")
2860            .child("seeds")
2861            .child("pyproject.toml")
2862            .write_str(
2863                r#"
2864            [project]
2865            name = "seeds"
2866            version = "1.0.0"
2867            requires-python = ">=3.12"
2868            dependencies = ["idna==3.6"]
2869
2870            [tool.uv.workspace]
2871            members = ["nested_packages/*"]
2872            "#,
2873            )?;
2874
2875        let (error, root_escaped) = temporary_test(root.as_ref()).await.unwrap_err();
2876        let filters = vec![(root_escaped.as_str(), "[ROOT]")];
2877        insta::with_settings!({filters => filters}, {
2878            assert_snapshot!(
2879                error,
2880            @"Nested workspaces are not supported, but workspace member has a `tool.uv.workspace` table: [ROOT]/packages/seeds");
2881        });
2882
2883        Ok(())
2884    }
2885
2886    #[tokio::test]
2887    async fn duplicate_names() -> Result<()> {
2888        let root = tempfile::TempDir::new()?;
2889        let root = ChildPath::new(root.path());
2890
2891        // Create the root.
2892        root.child("pyproject.toml").write_str(
2893            r#"
2894            [project]
2895            name = "albatross"
2896            version = "0.1.0"
2897            requires-python = ">=3.12"
2898            dependencies = ["tqdm>=4,<5"]
2899
2900            [tool.uv.workspace]
2901            members = ["packages/*"]
2902            "#,
2903        )?;
2904
2905        // Create an included package (`seeds`).
2906        root.child("packages")
2907            .child("seeds")
2908            .child("pyproject.toml")
2909            .write_str(
2910                r#"
2911            [project]
2912            name = "seeds"
2913            version = "1.0.0"
2914            requires-python = ">=3.12"
2915            dependencies = ["idna==3.6"]
2916
2917            [tool.uv.workspace]
2918            members = ["nested_packages/*"]
2919            "#,
2920            )?;
2921
2922        // Create an included package (`seeds2`).
2923        root.child("packages")
2924            .child("seeds2")
2925            .child("pyproject.toml")
2926            .write_str(
2927                r#"
2928            [project]
2929            name = "seeds"
2930            version = "1.0.0"
2931            requires-python = ">=3.12"
2932            dependencies = ["idna==3.6"]
2933
2934            [tool.uv.workspace]
2935            members = ["nested_packages/*"]
2936            "#,
2937            )?;
2938
2939        let (error, root_escaped) = temporary_test(root.as_ref()).await.unwrap_err();
2940        let filters = vec![(root_escaped.as_str(), "[ROOT]")];
2941        insta::with_settings!({filters => filters}, {
2942            assert_snapshot!(
2943                error,
2944            @"Two workspace members are both named `seeds`: `[ROOT]/packages/seeds` and `[ROOT]/packages/seeds2`");
2945        });
2946
2947        Ok(())
2948    }
2949}