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