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