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                    if let Some(project) = pyproject_toml.project.as_ref() {
1096                        debug!(
1097                            "Project `{}` is marked as unmanaged; omitting from workspace members",
1098                            project.name
1099                        );
1100                    } else {
1101                        debug!(
1102                            "Workspace member at `{}` is marked as unmanaged; omitting from workspace members",
1103                            member_root.simplified_display()
1104                        );
1105                    }
1106                    continue;
1107                }
1108
1109                // Extract the package name.
1110                let Some(project) = pyproject_toml.project.clone() else {
1111                    return Err(WorkspaceError::MissingProject(pyproject_path));
1112                };
1113
1114                debug!(
1115                    "Adding discovered workspace member: `{}`",
1116                    member_root.simplified_display()
1117                );
1118
1119                if let Some(existing) = workspace_members.insert(
1120                    project.name.clone(),
1121                    WorkspaceMember {
1122                        root: member_root.clone(),
1123                        project,
1124                        pyproject_toml,
1125                    },
1126                ) {
1127                    return Err(WorkspaceError::DuplicatePackage {
1128                        name: existing.project.name,
1129                        first: existing.root.clone(),
1130                        second: member_root,
1131                    });
1132                }
1133            }
1134        }
1135
1136        // Test for nested workspaces.
1137        for member in workspace_members.values() {
1138            if member.root() != workspace_root
1139                && member
1140                    .pyproject_toml
1141                    .tool
1142                    .as_ref()
1143                    .and_then(|tool| tool.uv.as_ref())
1144                    .and_then(|uv| uv.workspace.as_ref())
1145                    .is_some()
1146            {
1147                return Err(WorkspaceError::NestedWorkspace(member.root.clone()));
1148            }
1149        }
1150        Ok(workspace_members)
1151    }
1152}
1153
1154/// A project in a workspace.
1155#[derive(Debug, Clone, PartialEq)]
1156#[cfg_attr(test, derive(serde::Serialize))]
1157pub struct WorkspaceMember {
1158    /// The path to the project root.
1159    root: PathBuf,
1160    /// The `[project]` table, from the `pyproject.toml` of the project found at
1161    /// `<root>/pyproject.toml`.
1162    project: Project,
1163    /// The `pyproject.toml` of the project, found at `<root>/pyproject.toml`.
1164    pyproject_toml: PyProjectToml,
1165}
1166
1167impl WorkspaceMember {
1168    /// The path to the project root.
1169    pub fn root(&self) -> &PathBuf {
1170        &self.root
1171    }
1172
1173    /// The `[project]` table, from the `pyproject.toml` of the project found at
1174    /// `<root>/pyproject.toml`.
1175    pub fn project(&self) -> &Project {
1176        &self.project
1177    }
1178
1179    /// The `pyproject.toml` of the project, found at `<root>/pyproject.toml`.
1180    pub fn pyproject_toml(&self) -> &PyProjectToml {
1181        &self.pyproject_toml
1182    }
1183}
1184
1185/// The current project and the workspace it is part of, with all of the workspace members.
1186///
1187/// # Structure
1188///
1189/// The workspace root is a directory with a `pyproject.toml`, all members need to be below that
1190/// directory. The workspace root defines members and exclusions. All packages below it must either
1191/// be a member or excluded. The workspace root can be a package itself or a virtual manifest.
1192///
1193/// For a simple single package project, the workspace root is implicitly the current project root
1194/// and the workspace has only this single member. Otherwise, a workspace root is declared through
1195/// a `tool.uv.workspace` section.
1196///
1197/// A workspace itself does not declare dependencies, instead one member is the current project used
1198/// as main requirement.
1199///
1200/// Each member is a directory with a `pyproject.toml` that contains a `[project]` section. Each
1201/// member is a Python package, with a name, a version and dependencies. Workspace members can
1202/// depend on other workspace members (`foo = { workspace = true }`). You can consider the
1203/// workspace another package source or index, similar to `--find-links`.
1204///
1205/// # Usage
1206///
1207/// There a two main usage patterns: A root package and helpers, and the flat workspace.
1208///
1209/// Root package and helpers:
1210///
1211/// ```text
1212/// albatross
1213/// ├── packages
1214/// │   ├── provider_a
1215/// │   │   ├── pyproject.toml
1216/// │   │   └── src
1217/// │   │       └── provider_a
1218/// │   │           ├── __init__.py
1219/// │   │           └── foo.py
1220/// │   └── provider_b
1221/// │       ├── pyproject.toml
1222/// │       └── src
1223/// │           └── provider_b
1224/// │               ├── __init__.py
1225/// │               └── bar.py
1226/// ├── pyproject.toml
1227/// ├── Readme.md
1228/// ├── uv.lock
1229/// └── src
1230///     └── albatross
1231///         ├── __init__.py
1232///         └── main.py
1233/// ```
1234///
1235/// Flat workspace:
1236///
1237/// ```text
1238/// albatross
1239/// ├── packages
1240/// │   ├── albatross
1241/// │   │   ├── pyproject.toml
1242/// │   │   └── src
1243/// │   │       └── albatross
1244/// │   │           ├── __init__.py
1245/// │   │           └── main.py
1246/// │   ├── provider_a
1247/// │   │   ├── pyproject.toml
1248/// │   │   └── src
1249/// │   │       └── provider_a
1250/// │   │           ├── __init__.py
1251/// │   │           └── foo.py
1252/// │   └── provider_b
1253/// │       ├── pyproject.toml
1254/// │       └── src
1255/// │           └── provider_b
1256/// │               ├── __init__.py
1257/// │               └── bar.py
1258/// ├── pyproject.toml
1259/// ├── Readme.md
1260/// └── uv.lock
1261/// ```
1262#[derive(Debug, Clone)]
1263#[cfg_attr(test, derive(serde::Serialize))]
1264pub struct ProjectWorkspace {
1265    /// The path to the project root.
1266    project_root: PathBuf,
1267    /// The name of the package.
1268    project_name: PackageName,
1269    /// The workspace the project is part of.
1270    workspace: Workspace,
1271}
1272
1273impl ProjectWorkspace {
1274    /// Find the current project and workspace, given the current directory.
1275    ///
1276    /// `stop_discovery_at` must be either `None` or an ancestor of the current directory. If set,
1277    /// only directories between the current path and `stop_discovery_at` are considered.
1278    pub async fn discover(
1279        path: &Path,
1280        options: &DiscoveryOptions,
1281        cache: &WorkspaceCache,
1282    ) -> Result<Self, WorkspaceError> {
1283        let project_root = path
1284            .ancestors()
1285            .take_while(|path| {
1286                // Only walk up the given directory, if any.
1287                options
1288                    .stop_discovery_at
1289                    .as_deref()
1290                    .and_then(Path::parent)
1291                    .map(|stop_discovery_at| stop_discovery_at != *path)
1292                    .unwrap_or(true)
1293            })
1294            .find(|path| path.join("pyproject.toml").is_file())
1295            .ok_or(WorkspaceError::MissingPyprojectToml)?;
1296
1297        debug!(
1298            "Found project root: `{}`",
1299            project_root.simplified_display()
1300        );
1301
1302        Self::from_project_root(project_root, options, cache).await
1303    }
1304
1305    /// Discover the workspace starting from the directory containing the `pyproject.toml`.
1306    async fn from_project_root(
1307        project_root: &Path,
1308        options: &DiscoveryOptions,
1309        cache: &WorkspaceCache,
1310    ) -> Result<Self, WorkspaceError> {
1311        // Read the current `pyproject.toml`.
1312        let pyproject_path = project_root.join("pyproject.toml");
1313        let contents = fs_err::tokio::read_to_string(&pyproject_path).await?;
1314        let pyproject_toml = PyProjectToml::from_string(contents)
1315            .map_err(|err| WorkspaceError::Toml(pyproject_path.clone(), Box::new(err)))?;
1316
1317        // It must have a `[project]` table.
1318        let project = pyproject_toml
1319            .project
1320            .clone()
1321            .ok_or(WorkspaceError::MissingProject(pyproject_path))?;
1322
1323        Self::from_project(project_root, &project, &pyproject_toml, options, cache).await
1324    }
1325
1326    /// If the current directory contains a `pyproject.toml` with a `project` table, discover the
1327    /// workspace and return it, otherwise it is a dynamic path dependency and we return `Ok(None)`.
1328    pub async fn from_maybe_project_root(
1329        install_path: &Path,
1330        options: &DiscoveryOptions,
1331        cache: &WorkspaceCache,
1332    ) -> Result<Option<Self>, WorkspaceError> {
1333        // Read the `pyproject.toml`.
1334        let pyproject_path = install_path.join("pyproject.toml");
1335        let Ok(contents) = fs_err::tokio::read_to_string(&pyproject_path).await else {
1336            // No `pyproject.toml`, but there may still be a `setup.py` or `setup.cfg`.
1337            return Ok(None);
1338        };
1339        let pyproject_toml = PyProjectToml::from_string(contents)
1340            .map_err(|err| WorkspaceError::Toml(pyproject_path.clone(), Box::new(err)))?;
1341
1342        // Extract the `[project]` metadata.
1343        let Some(project) = pyproject_toml.project.clone() else {
1344            // We have to build to get the metadata.
1345            return Ok(None);
1346        };
1347
1348        match Self::from_project(install_path, &project, &pyproject_toml, options, cache).await {
1349            Ok(workspace) => Ok(Some(workspace)),
1350            Err(WorkspaceError::NonWorkspace(_)) => Ok(None),
1351            Err(err) => Err(err),
1352        }
1353    }
1354
1355    /// Returns the directory containing the closest `pyproject.toml` that defines the current
1356    /// project.
1357    pub fn project_root(&self) -> &Path {
1358        &self.project_root
1359    }
1360
1361    /// Returns the [`PackageName`] of the current project.
1362    pub fn project_name(&self) -> &PackageName {
1363        &self.project_name
1364    }
1365
1366    /// Returns the [`Workspace`] containing the current project.
1367    pub fn workspace(&self) -> &Workspace {
1368        &self.workspace
1369    }
1370
1371    /// Returns the current project as a [`WorkspaceMember`].
1372    pub fn current_project(&self) -> &WorkspaceMember {
1373        &self.workspace().packages[&self.project_name]
1374    }
1375
1376    /// Set the `pyproject.toml` for the current project.
1377    ///
1378    /// Assumes that the project name is unchanged in the updated [`PyProjectToml`].
1379    pub fn with_pyproject_toml(
1380        self,
1381        pyproject_toml: PyProjectToml,
1382    ) -> Result<Option<Self>, WorkspaceError> {
1383        let Some(workspace) = self
1384            .workspace
1385            .with_pyproject_toml(&self.project_name, pyproject_toml)?
1386        else {
1387            return Ok(None);
1388        };
1389        Ok(Some(Self { workspace, ..self }))
1390    }
1391
1392    /// Find the workspace for a project.
1393    pub async fn from_project(
1394        install_path: &Path,
1395        project: &Project,
1396        project_pyproject_toml: &PyProjectToml,
1397        options: &DiscoveryOptions,
1398        cache: &WorkspaceCache,
1399    ) -> Result<Self, WorkspaceError> {
1400        let project_path = std::path::absolute(install_path)
1401            .map_err(WorkspaceError::Normalize)?
1402            .clone();
1403        // Remove `.` and `..`
1404        let project_path = uv_fs::normalize_path(&project_path);
1405        // Trim trailing slashes.
1406        let project_path = project_path.components().collect::<PathBuf>();
1407
1408        // Check if workspaces are explicitly disabled for the project.
1409        if project_pyproject_toml
1410            .tool
1411            .as_ref()
1412            .and_then(|tool| tool.uv.as_ref())
1413            .and_then(|uv| uv.managed)
1414            == Some(false)
1415        {
1416            debug!("Project `{}` is marked as unmanaged", project.name);
1417            return Err(WorkspaceError::NonWorkspace(project_path));
1418        }
1419
1420        // Check if the current project is also an explicit workspace root.
1421        let mut workspace = project_pyproject_toml
1422            .tool
1423            .as_ref()
1424            .and_then(|tool| tool.uv.as_ref())
1425            .and_then(|uv| uv.workspace.as_ref())
1426            .map(|workspace| {
1427                (
1428                    project_path.clone(),
1429                    workspace.clone(),
1430                    project_pyproject_toml.clone(),
1431                )
1432            });
1433
1434        if workspace.is_none() {
1435            // The project isn't an explicit workspace root, check if we're a regular workspace
1436            // member by looking for an explicit workspace root above.
1437            workspace = find_workspace(&project_path, options).await?;
1438        }
1439
1440        let current_project = WorkspaceMember {
1441            root: project_path.clone(),
1442            project: project.clone(),
1443            pyproject_toml: project_pyproject_toml.clone(),
1444        };
1445
1446        let Some((workspace_root, workspace_definition, workspace_pyproject_toml)) = workspace
1447        else {
1448            // The project isn't an explicit workspace root, but there's also no workspace root
1449            // above it, so the project is an implicit workspace root identical to the project root.
1450            debug!("No workspace root found, using project root");
1451
1452            let current_project_as_members = Arc::new(BTreeMap::from_iter([(
1453                project.name.clone(),
1454                current_project,
1455            )]));
1456            let workspace_sources = BTreeMap::default();
1457            let required_members = Workspace::collect_required_members(
1458                &current_project_as_members,
1459                &workspace_sources,
1460                project_pyproject_toml,
1461            )?;
1462
1463            return Ok(Self {
1464                project_root: project_path.clone(),
1465                project_name: project.name.clone(),
1466                workspace: Workspace {
1467                    install_path: project_path.clone(),
1468                    packages: current_project_as_members,
1469                    required_members,
1470                    // There may be package sources, but we don't need to duplicate them into the
1471                    // workspace sources.
1472                    sources: workspace_sources,
1473                    indexes: Vec::default(),
1474                    pyproject_toml: project_pyproject_toml.clone(),
1475                },
1476            });
1477        };
1478
1479        debug!(
1480            "Found workspace root: `{}`",
1481            workspace_root.simplified_display()
1482        );
1483
1484        let workspace = Workspace::collect_members(
1485            workspace_root,
1486            workspace_definition,
1487            workspace_pyproject_toml,
1488            Some(current_project),
1489            options,
1490            cache,
1491        )
1492        .await?;
1493
1494        Ok(Self {
1495            project_root: project_path,
1496            project_name: project.name.clone(),
1497            workspace,
1498        })
1499    }
1500}
1501
1502/// Find the workspace root above the current project, if any.
1503async fn find_workspace(
1504    project_root: &Path,
1505    options: &DiscoveryOptions,
1506) -> Result<Option<(PathBuf, ToolUvWorkspace, PyProjectToml)>, WorkspaceError> {
1507    // Skip 1 to ignore the current project itself.
1508    for workspace_root in project_root
1509        .ancestors()
1510        .take_while(|path| {
1511            // Only walk up the given directory, if any.
1512            options
1513                .stop_discovery_at
1514                .as_deref()
1515                .and_then(Path::parent)
1516                .map(|stop_discovery_at| stop_discovery_at != *path)
1517                .unwrap_or(true)
1518        })
1519        .skip(1)
1520    {
1521        let pyproject_path = workspace_root.join("pyproject.toml");
1522        if !pyproject_path.is_file() {
1523            continue;
1524        }
1525        trace!(
1526            "Found `pyproject.toml` at: `{}`",
1527            pyproject_path.simplified_display()
1528        );
1529
1530        // Read the `pyproject.toml`.
1531        let contents = fs_err::tokio::read_to_string(&pyproject_path).await?;
1532        let pyproject_toml = PyProjectToml::from_string(contents)
1533            .map_err(|err| WorkspaceError::Toml(pyproject_path.clone(), Box::new(err)))?;
1534
1535        return if let Some(workspace) = pyproject_toml
1536            .tool
1537            .as_ref()
1538            .and_then(|tool| tool.uv.as_ref())
1539            .and_then(|uv| uv.workspace.as_ref())
1540        {
1541            if !is_included_in_workspace(project_root, workspace_root, workspace)? {
1542                debug!(
1543                    "Found workspace root `{}`, but project is not included",
1544                    workspace_root.simplified_display()
1545                );
1546                return Ok(None);
1547            }
1548
1549            if is_excluded_from_workspace(project_root, workspace_root, workspace)? {
1550                debug!(
1551                    "Found workspace root `{}`, but project is excluded",
1552                    workspace_root.simplified_display()
1553                );
1554                return Ok(None);
1555            }
1556
1557            // We found a workspace root.
1558            Ok(Some((
1559                workspace_root.to_path_buf(),
1560                workspace.clone(),
1561                pyproject_toml,
1562            )))
1563        } else if pyproject_toml.project.is_some() {
1564            // We're in a directory of another project, e.g. tests or examples.
1565            // Example:
1566            // ```
1567            // albatross
1568            // ├── examples
1569            // │   └── bird-feeder [CURRENT DIRECTORY]
1570            // │       ├── pyproject.toml
1571            // │       └── src
1572            // │           └── bird_feeder
1573            // │               └── __init__.py
1574            // ├── pyproject.toml
1575            // └── src
1576            //     └── albatross
1577            //         └── __init__.py
1578            // ```
1579            // The current project is the example (non-workspace) `bird-feeder` in `albatross`,
1580            // we ignore all `albatross` is doing and any potential workspace it might be
1581            // contained in.
1582            debug!(
1583                "Project is contained in non-workspace project: `{}`",
1584                workspace_root.simplified_display()
1585            );
1586            Ok(None)
1587        } else {
1588            // We require that a `project.toml` file either declares a workspace or a project.
1589            warn!(
1590                "`pyproject.toml` does not contain a `project` table: `{}`",
1591                pyproject_path.simplified_display()
1592            );
1593            Ok(None)
1594        };
1595    }
1596
1597    Ok(None)
1598}
1599
1600/// Check if a directory only contains files that are ignored.
1601///
1602/// Returns `true` if walking the directory while respecting `.gitignore` and `.ignore` rules
1603/// yields no files, indicating that any files present (e.g., `__pycache__`) are all ignored.
1604fn has_only_gitignored_files(path: &Path) -> bool {
1605    let walker = ignore::WalkBuilder::new(path)
1606        .hidden(false)
1607        .parents(true)
1608        .ignore(true)
1609        .git_ignore(true)
1610        .git_global(true)
1611        .git_exclude(true)
1612        .build();
1613
1614    for entry in walker {
1615        let Ok(entry) = entry else {
1616            // If we can't read an entry, assume non-ignored content exists.
1617            return false;
1618        };
1619
1620        // Skip the root directory itself.
1621        if entry.path() == path {
1622            continue;
1623        }
1624
1625        // A non-ignored entry exists.
1626        return false;
1627    }
1628
1629    true
1630}
1631
1632/// Check if we're in the `tool.uv.workspace.excluded` of a workspace.
1633fn is_excluded_from_workspace(
1634    project_path: &Path,
1635    workspace_root: &Path,
1636    workspace: &ToolUvWorkspace,
1637) -> Result<bool, WorkspaceError> {
1638    for exclude_glob in workspace.exclude.iter().flatten() {
1639        // Normalize the exclude glob to remove leading `./` and other relative path components
1640        let normalized_glob = uv_fs::normalize_path(Path::new(exclude_glob.as_str()));
1641        let absolute_glob = PathBuf::from(glob::Pattern::escape(
1642            workspace_root.simplified().to_string_lossy().as_ref(),
1643        ))
1644        .join(normalized_glob.as_ref());
1645        let absolute_glob = absolute_glob.to_string_lossy();
1646        let exclude_pattern = glob::Pattern::new(&absolute_glob)
1647            .map_err(|err| WorkspaceError::Pattern(absolute_glob.to_string(), err))?;
1648        if exclude_pattern.matches_path(project_path) {
1649            return Ok(true);
1650        }
1651    }
1652    Ok(false)
1653}
1654
1655/// Check if we're in the `tool.uv.workspace.members` of a workspace.
1656fn is_included_in_workspace(
1657    project_path: &Path,
1658    workspace_root: &Path,
1659    workspace: &ToolUvWorkspace,
1660) -> Result<bool, WorkspaceError> {
1661    for member_glob in workspace.members.iter().flatten() {
1662        // Normalize the member glob to remove leading `./` and other relative path components
1663        let normalized_glob = uv_fs::normalize_path(Path::new(member_glob.as_str()));
1664        let absolute_glob = PathBuf::from(glob::Pattern::escape(
1665            workspace_root.simplified().to_string_lossy().as_ref(),
1666        ))
1667        .join(normalized_glob.as_ref());
1668        let absolute_glob = absolute_glob.to_string_lossy();
1669        let include_pattern = glob::Pattern::new(&absolute_glob)
1670            .map_err(|err| WorkspaceError::Pattern(absolute_glob.to_string(), err))?;
1671        if include_pattern.matches_path(project_path) {
1672            return Ok(true);
1673        }
1674    }
1675    Ok(false)
1676}
1677
1678/// A project that can be discovered.
1679///
1680/// The project could be a package within a workspace, a real workspace root, or a non-project
1681/// workspace root, which can define its own dev dependencies.
1682#[derive(Debug, Clone)]
1683pub enum VirtualProject {
1684    /// A project (which could be a workspace root or member).
1685    Project(ProjectWorkspace),
1686    /// A non-project workspace root.
1687    NonProject(Workspace),
1688}
1689
1690impl VirtualProject {
1691    /// Find the current project or virtual workspace root, given the current directory.
1692    ///
1693    /// Similar to calling [`ProjectWorkspace::discover`] with a fallback to [`Workspace::discover`],
1694    /// but avoids rereading the `pyproject.toml` (and relying on error-handling as control flow).
1695    ///
1696    /// This method requires an absolute path and panics otherwise, i.e. this method only supports
1697    /// discovering the main workspace.
1698    pub async fn discover(
1699        path: &Path,
1700        options: &DiscoveryOptions,
1701        cache: &WorkspaceCache,
1702    ) -> Result<Self, WorkspaceError> {
1703        assert!(
1704            path.is_absolute(),
1705            "virtual project discovery with relative path"
1706        );
1707        let project_root = path
1708            .ancestors()
1709            .take_while(|path| {
1710                // Only walk up the given directory, if any.
1711                options
1712                    .stop_discovery_at
1713                    .as_deref()
1714                    .and_then(Path::parent)
1715                    .map(|stop_discovery_at| stop_discovery_at != *path)
1716                    .unwrap_or(true)
1717            })
1718            .find(|path| path.join("pyproject.toml").is_file())
1719            .ok_or(WorkspaceError::MissingPyprojectToml)?;
1720
1721        debug!(
1722            "Found project root: `{}`",
1723            project_root.simplified_display()
1724        );
1725
1726        // Read the current `pyproject.toml`.
1727        let pyproject_path = project_root.join("pyproject.toml");
1728        let contents = fs_err::tokio::read_to_string(&pyproject_path).await?;
1729        let pyproject_toml = PyProjectToml::from_string(contents)
1730            .map_err(|err| WorkspaceError::Toml(pyproject_path.clone(), Box::new(err)))?;
1731
1732        if let Some(project) = pyproject_toml.project.as_ref() {
1733            // If the `pyproject.toml` contains a `[project]` table, it's a project.
1734            let project = ProjectWorkspace::from_project(
1735                project_root,
1736                project,
1737                &pyproject_toml,
1738                options,
1739                cache,
1740            )
1741            .await?;
1742            Ok(Self::Project(project))
1743        } else if let Some(workspace) = pyproject_toml
1744            .tool
1745            .as_ref()
1746            .and_then(|tool| tool.uv.as_ref())
1747            .and_then(|uv| uv.workspace.as_ref())
1748            .filter(|_| options.project.allows_legacy_workspace())
1749        {
1750            // Otherwise, if it contains a `tool.uv.workspace` table, it's a non-project workspace
1751            // root.
1752            let project_path = std::path::absolute(project_root)
1753                .map_err(WorkspaceError::Normalize)?
1754                .clone();
1755
1756            let workspace = Workspace::collect_members(
1757                project_path,
1758                workspace.clone(),
1759                pyproject_toml,
1760                None,
1761                options,
1762                cache,
1763            )
1764            .await?;
1765
1766            Ok(Self::NonProject(workspace))
1767        } else if options.project.allows_implicit_workspace() {
1768            // Otherwise it's a pyproject.toml that maybe contains dependency-groups
1769            // that we want to treat like a project/workspace to handle those uniformly
1770            let project_path = std::path::absolute(project_root)
1771                .map_err(WorkspaceError::Normalize)?
1772                .clone();
1773
1774            let workspace = Workspace::collect_members(
1775                project_path,
1776                ToolUvWorkspace::default(),
1777                pyproject_toml,
1778                None,
1779                options,
1780                cache,
1781            )
1782            .await?;
1783
1784            Ok(Self::NonProject(workspace))
1785        } else {
1786            Err(WorkspaceError::MissingProject(pyproject_path))
1787        }
1788    }
1789
1790    /// Set the `pyproject.toml` for the current project.
1791    ///
1792    /// Assumes that the project name is unchanged in the updated [`PyProjectToml`].
1793    pub fn with_pyproject_toml(
1794        self,
1795        pyproject_toml: PyProjectToml,
1796    ) -> Result<Option<Self>, WorkspaceError> {
1797        Ok(match self {
1798            Self::Project(project) => {
1799                let Some(project) = project.with_pyproject_toml(pyproject_toml)? else {
1800                    return Ok(None);
1801                };
1802                Some(Self::Project(project))
1803            }
1804            Self::NonProject(workspace) => {
1805                // If this is a non-project workspace root, then by definition the root isn't a
1806                // member, so we can just update the top-level `pyproject.toml`.
1807                Some(Self::NonProject(Workspace {
1808                    pyproject_toml,
1809                    ..workspace.clone()
1810                }))
1811            }
1812        })
1813    }
1814
1815    /// Return the root of the project.
1816    pub fn root(&self) -> &Path {
1817        match self {
1818            Self::Project(project) => project.project_root(),
1819            Self::NonProject(workspace) => workspace.install_path(),
1820        }
1821    }
1822
1823    /// Return the [`PyProjectToml`] of the project.
1824    pub fn pyproject_toml(&self) -> &PyProjectToml {
1825        match self {
1826            Self::Project(project) => project.current_project().pyproject_toml(),
1827            Self::NonProject(workspace) => &workspace.pyproject_toml,
1828        }
1829    }
1830
1831    /// Return the [`Workspace`] of the project.
1832    pub fn workspace(&self) -> &Workspace {
1833        match self {
1834            Self::Project(project) => project.workspace(),
1835            Self::NonProject(workspace) => workspace,
1836        }
1837    }
1838
1839    /// Return the [`PackageName`] of the project, if available.
1840    pub fn project_name(&self) -> Option<&PackageName> {
1841        match self {
1842            Self::Project(project) => Some(project.project_name()),
1843            Self::NonProject(_) => None,
1844        }
1845    }
1846
1847    /// Returns `true` if the project is a virtual workspace root.
1848    pub fn is_non_project(&self) -> bool {
1849        matches!(self, Self::NonProject(_))
1850    }
1851}
1852
1853#[cfg(test)]
1854#[cfg(unix)] // Avoid path escaping for the unit tests
1855mod tests {
1856    use std::env;
1857    use std::path::Path;
1858    use std::str::FromStr;
1859
1860    use anyhow::Result;
1861    use assert_fs::fixture::ChildPath;
1862    use assert_fs::prelude::*;
1863    use insta::{assert_json_snapshot, assert_snapshot};
1864
1865    use uv_normalize::GroupName;
1866    use uv_pypi_types::DependencyGroupSpecifier;
1867
1868    use crate::pyproject::PyProjectToml;
1869    use crate::workspace::{DiscoveryOptions, ProjectWorkspace};
1870    use crate::{WorkspaceCache, WorkspaceError};
1871
1872    async fn workspace_test(folder: &str) -> (ProjectWorkspace, String) {
1873        let root_dir = env::current_dir()
1874            .unwrap()
1875            .parent()
1876            .unwrap()
1877            .parent()
1878            .unwrap()
1879            .join("test")
1880            .join("workspaces");
1881        let project = ProjectWorkspace::discover(
1882            &root_dir.join(folder),
1883            &DiscoveryOptions::default(),
1884            &WorkspaceCache::default(),
1885        )
1886        .await
1887        .unwrap();
1888        let root_escaped = regex::escape(root_dir.to_string_lossy().as_ref());
1889        (project, root_escaped)
1890    }
1891
1892    async fn temporary_test(
1893        folder: &Path,
1894    ) -> Result<(ProjectWorkspace, String), (WorkspaceError, String)> {
1895        let root_escaped = regex::escape(folder.to_string_lossy().as_ref());
1896        let project = ProjectWorkspace::discover(
1897            folder,
1898            &DiscoveryOptions::default(),
1899            &WorkspaceCache::default(),
1900        )
1901        .await
1902        .map_err(|error| (error, root_escaped.clone()))?;
1903
1904        Ok((project, root_escaped))
1905    }
1906
1907    #[tokio::test]
1908    async fn albatross_in_example() {
1909        let (project, root_escaped) =
1910            workspace_test("albatross-in-example/examples/bird-feeder").await;
1911        let filters = vec![(root_escaped.as_str(), "[ROOT]")];
1912        insta::with_settings!({filters => filters}, {
1913        assert_json_snapshot!(
1914            project,
1915            {
1916                ".workspace.packages.*.pyproject_toml" => "[PYPROJECT_TOML]"
1917            },
1918            @r#"
1919        {
1920          "project_root": "[ROOT]/albatross-in-example/examples/bird-feeder",
1921          "project_name": "bird-feeder",
1922          "workspace": {
1923            "install_path": "[ROOT]/albatross-in-example/examples/bird-feeder",
1924            "packages": {
1925              "bird-feeder": {
1926                "root": "[ROOT]/albatross-in-example/examples/bird-feeder",
1927                "project": {
1928                  "name": "bird-feeder",
1929                  "version": "1.0.0",
1930                  "requires-python": ">=3.12",
1931                  "dependencies": [
1932                    "iniconfig>=2,<3"
1933                  ],
1934                  "optional-dependencies": null
1935                },
1936                "pyproject_toml": "[PYPROJECT_TOML]"
1937              }
1938            },
1939            "required_members": {},
1940            "sources": {},
1941            "indexes": [],
1942            "pyproject_toml": {
1943              "project": {
1944                "name": "bird-feeder",
1945                "version": "1.0.0",
1946                "requires-python": ">=3.12",
1947                "dependencies": [
1948                  "iniconfig>=2,<3"
1949                ],
1950                "optional-dependencies": null
1951              },
1952              "tool": null,
1953              "dependency-groups": null
1954            }
1955          }
1956        }
1957        "#);
1958        });
1959    }
1960
1961    #[tokio::test]
1962    async fn albatross_project_in_excluded() {
1963        let (project, root_escaped) =
1964            workspace_test("albatross-project-in-excluded/excluded/bird-feeder").await;
1965        let filters = vec![(root_escaped.as_str(), "[ROOT]")];
1966        insta::with_settings!({filters => filters}, {
1967            assert_json_snapshot!(
1968            project,
1969            {
1970                ".workspace.packages.*.pyproject_toml" => "[PYPROJECT_TOML]"
1971            },
1972            @r#"
1973            {
1974              "project_root": "[ROOT]/albatross-project-in-excluded/excluded/bird-feeder",
1975              "project_name": "bird-feeder",
1976              "workspace": {
1977                "install_path": "[ROOT]/albatross-project-in-excluded/excluded/bird-feeder",
1978                "packages": {
1979                  "bird-feeder": {
1980                    "root": "[ROOT]/albatross-project-in-excluded/excluded/bird-feeder",
1981                    "project": {
1982                      "name": "bird-feeder",
1983                      "version": "1.0.0",
1984                      "requires-python": ">=3.12",
1985                      "dependencies": [
1986                        "iniconfig>=2,<3"
1987                      ],
1988                      "optional-dependencies": null
1989                    },
1990                    "pyproject_toml": "[PYPROJECT_TOML]"
1991                  }
1992                },
1993                "required_members": {},
1994                "sources": {},
1995                "indexes": [],
1996                "pyproject_toml": {
1997                  "project": {
1998                    "name": "bird-feeder",
1999                    "version": "1.0.0",
2000                    "requires-python": ">=3.12",
2001                    "dependencies": [
2002                      "iniconfig>=2,<3"
2003                    ],
2004                    "optional-dependencies": null
2005                  },
2006                  "tool": null,
2007                  "dependency-groups": null
2008                }
2009              }
2010            }
2011            "#);
2012        });
2013    }
2014
2015    #[tokio::test]
2016    async fn albatross_root_workspace() {
2017        let (project, root_escaped) = workspace_test("albatross-root-workspace").await;
2018        let filters = vec![(root_escaped.as_str(), "[ROOT]")];
2019        insta::with_settings!({filters => filters}, {
2020            assert_json_snapshot!(
2021            project,
2022            {
2023                ".workspace.packages.*.pyproject_toml" => "[PYPROJECT_TOML]"
2024            },
2025            @r#"
2026            {
2027              "project_root": "[ROOT]/albatross-root-workspace",
2028              "project_name": "albatross",
2029              "workspace": {
2030                "install_path": "[ROOT]/albatross-root-workspace",
2031                "packages": {
2032                  "albatross": {
2033                    "root": "[ROOT]/albatross-root-workspace",
2034                    "project": {
2035                      "name": "albatross",
2036                      "version": "0.1.0",
2037                      "requires-python": ">=3.12",
2038                      "dependencies": [
2039                        "bird-feeder",
2040                        "iniconfig>=2,<3"
2041                      ],
2042                      "optional-dependencies": null
2043                    },
2044                    "pyproject_toml": "[PYPROJECT_TOML]"
2045                  },
2046                  "bird-feeder": {
2047                    "root": "[ROOT]/albatross-root-workspace/packages/bird-feeder",
2048                    "project": {
2049                      "name": "bird-feeder",
2050                      "version": "1.0.0",
2051                      "requires-python": ">=3.8",
2052                      "dependencies": [
2053                        "iniconfig>=2,<3",
2054                        "seeds"
2055                      ],
2056                      "optional-dependencies": null
2057                    },
2058                    "pyproject_toml": "[PYPROJECT_TOML]"
2059                  },
2060                  "seeds": {
2061                    "root": "[ROOT]/albatross-root-workspace/packages/seeds",
2062                    "project": {
2063                      "name": "seeds",
2064                      "version": "1.0.0",
2065                      "requires-python": ">=3.12",
2066                      "dependencies": [
2067                        "idna==3.6"
2068                      ],
2069                      "optional-dependencies": null
2070                    },
2071                    "pyproject_toml": "[PYPROJECT_TOML]"
2072                  }
2073                },
2074                "required_members": {
2075                  "bird-feeder": null,
2076                  "seeds": null
2077                },
2078                "sources": {
2079                  "bird-feeder": [
2080                    {
2081                      "workspace": true,
2082                      "editable": null,
2083                      "extra": null,
2084                      "group": null
2085                    }
2086                  ]
2087                },
2088                "indexes": [],
2089                "pyproject_toml": {
2090                  "project": {
2091                    "name": "albatross",
2092                    "version": "0.1.0",
2093                    "requires-python": ">=3.12",
2094                    "dependencies": [
2095                      "bird-feeder",
2096                      "iniconfig>=2,<3"
2097                    ],
2098                    "optional-dependencies": null
2099                  },
2100                  "tool": {
2101                    "uv": {
2102                      "sources": {
2103                        "bird-feeder": [
2104                          {
2105                            "workspace": true,
2106                            "editable": null,
2107                            "extra": null,
2108                            "group": null
2109                          }
2110                        ]
2111                      },
2112                      "index": null,
2113                      "workspace": {
2114                        "members": [
2115                          "packages/*"
2116                        ],
2117                        "exclude": null
2118                      },
2119                      "managed": null,
2120                      "package": null,
2121                      "default-groups": null,
2122                      "dependency-groups": null,
2123                      "dev-dependencies": null,
2124                      "override-dependencies": null,
2125                      "exclude-dependencies": null,
2126                      "constraint-dependencies": null,
2127                      "build-constraint-dependencies": null,
2128                      "environments": null,
2129                      "required-environments": null,
2130                      "conflicts": null,
2131                      "build-backend": null
2132                    }
2133                  },
2134                  "dependency-groups": null
2135                }
2136              }
2137            }
2138            "#);
2139        });
2140    }
2141
2142    #[tokio::test]
2143    async fn albatross_virtual_workspace() {
2144        let (project, root_escaped) =
2145            workspace_test("albatross-virtual-workspace/packages/albatross").await;
2146        let filters = vec![(root_escaped.as_str(), "[ROOT]")];
2147        insta::with_settings!({filters => filters}, {
2148            assert_json_snapshot!(
2149            project,
2150            {
2151                ".workspace.packages.*.pyproject_toml" => "[PYPROJECT_TOML]"
2152            },
2153            @r#"
2154            {
2155              "project_root": "[ROOT]/albatross-virtual-workspace/packages/albatross",
2156              "project_name": "albatross",
2157              "workspace": {
2158                "install_path": "[ROOT]/albatross-virtual-workspace",
2159                "packages": {
2160                  "albatross": {
2161                    "root": "[ROOT]/albatross-virtual-workspace/packages/albatross",
2162                    "project": {
2163                      "name": "albatross",
2164                      "version": "0.1.0",
2165                      "requires-python": ">=3.12",
2166                      "dependencies": [
2167                        "bird-feeder",
2168                        "iniconfig>=2,<3"
2169                      ],
2170                      "optional-dependencies": null
2171                    },
2172                    "pyproject_toml": "[PYPROJECT_TOML]"
2173                  },
2174                  "bird-feeder": {
2175                    "root": "[ROOT]/albatross-virtual-workspace/packages/bird-feeder",
2176                    "project": {
2177                      "name": "bird-feeder",
2178                      "version": "1.0.0",
2179                      "requires-python": ">=3.12",
2180                      "dependencies": [
2181                        "anyio>=4.3.0,<5",
2182                        "seeds"
2183                      ],
2184                      "optional-dependencies": null
2185                    },
2186                    "pyproject_toml": "[PYPROJECT_TOML]"
2187                  },
2188                  "seeds": {
2189                    "root": "[ROOT]/albatross-virtual-workspace/packages/seeds",
2190                    "project": {
2191                      "name": "seeds",
2192                      "version": "1.0.0",
2193                      "requires-python": ">=3.12",
2194                      "dependencies": [
2195                        "idna==3.6"
2196                      ],
2197                      "optional-dependencies": null
2198                    },
2199                    "pyproject_toml": "[PYPROJECT_TOML]"
2200                  }
2201                },
2202                "required_members": {
2203                  "bird-feeder": null,
2204                  "seeds": null
2205                },
2206                "sources": {},
2207                "indexes": [],
2208                "pyproject_toml": {
2209                  "project": null,
2210                  "tool": {
2211                    "uv": {
2212                      "sources": null,
2213                      "index": null,
2214                      "workspace": {
2215                        "members": [
2216                          "packages/*"
2217                        ],
2218                        "exclude": null
2219                      },
2220                      "managed": null,
2221                      "package": null,
2222                      "default-groups": null,
2223                      "dependency-groups": null,
2224                      "dev-dependencies": null,
2225                      "override-dependencies": null,
2226                      "exclude-dependencies": null,
2227                      "constraint-dependencies": null,
2228                      "build-constraint-dependencies": null,
2229                      "environments": null,
2230                      "required-environments": null,
2231                      "conflicts": null,
2232                      "build-backend": null
2233                    }
2234                  },
2235                  "dependency-groups": null
2236                }
2237              }
2238            }
2239            "#);
2240        });
2241    }
2242
2243    #[tokio::test]
2244    async fn albatross_just_project() {
2245        let (project, root_escaped) = workspace_test("albatross-just-project").await;
2246        let filters = vec![(root_escaped.as_str(), "[ROOT]")];
2247        insta::with_settings!({filters => filters}, {
2248            assert_json_snapshot!(
2249            project,
2250            {
2251                ".workspace.packages.*.pyproject_toml" => "[PYPROJECT_TOML]"
2252            },
2253            @r#"
2254            {
2255              "project_root": "[ROOT]/albatross-just-project",
2256              "project_name": "albatross",
2257              "workspace": {
2258                "install_path": "[ROOT]/albatross-just-project",
2259                "packages": {
2260                  "albatross": {
2261                    "root": "[ROOT]/albatross-just-project",
2262                    "project": {
2263                      "name": "albatross",
2264                      "version": "0.1.0",
2265                      "requires-python": ">=3.12",
2266                      "dependencies": [
2267                        "iniconfig>=2,<3"
2268                      ],
2269                      "optional-dependencies": null
2270                    },
2271                    "pyproject_toml": "[PYPROJECT_TOML]"
2272                  }
2273                },
2274                "required_members": {},
2275                "sources": {},
2276                "indexes": [],
2277                "pyproject_toml": {
2278                  "project": {
2279                    "name": "albatross",
2280                    "version": "0.1.0",
2281                    "requires-python": ">=3.12",
2282                    "dependencies": [
2283                      "iniconfig>=2,<3"
2284                    ],
2285                    "optional-dependencies": null
2286                  },
2287                  "tool": null,
2288                  "dependency-groups": null
2289                }
2290              }
2291            }
2292            "#);
2293        });
2294    }
2295
2296    #[tokio::test]
2297    async fn exclude_package() -> Result<()> {
2298        let root = tempfile::TempDir::new()?;
2299        let root = ChildPath::new(root.path());
2300
2301        // Create the root.
2302        root.child("pyproject.toml").write_str(
2303            r#"
2304            [project]
2305            name = "albatross"
2306            version = "0.1.0"
2307            requires-python = ">=3.12"
2308            dependencies = ["tqdm>=4,<5"]
2309
2310            [tool.uv.workspace]
2311            members = ["packages/*"]
2312            exclude = ["packages/bird-feeder"]
2313
2314            [build-system]
2315            requires = ["hatchling"]
2316            build-backend = "hatchling.build"
2317            "#,
2318        )?;
2319        root.child("albatross").child("__init__.py").touch()?;
2320
2321        // Create an included package (`seeds`).
2322        root.child("packages")
2323            .child("seeds")
2324            .child("pyproject.toml")
2325            .write_str(
2326                r#"
2327            [project]
2328            name = "seeds"
2329            version = "1.0.0"
2330            requires-python = ">=3.12"
2331            dependencies = ["idna==3.6"]
2332
2333            [build-system]
2334            requires = ["hatchling"]
2335            build-backend = "hatchling.build"
2336            "#,
2337            )?;
2338        root.child("packages")
2339            .child("seeds")
2340            .child("seeds")
2341            .child("__init__.py")
2342            .touch()?;
2343
2344        // Create an excluded package (`bird-feeder`).
2345        root.child("packages")
2346            .child("bird-feeder")
2347            .child("pyproject.toml")
2348            .write_str(
2349                r#"
2350            [project]
2351            name = "bird-feeder"
2352            version = "1.0.0"
2353            requires-python = ">=3.12"
2354            dependencies = ["anyio>=4.3.0,<5"]
2355
2356            [build-system]
2357            requires = ["hatchling"]
2358            build-backend = "hatchling.build"
2359            "#,
2360            )?;
2361        root.child("packages")
2362            .child("bird-feeder")
2363            .child("bird_feeder")
2364            .child("__init__.py")
2365            .touch()?;
2366
2367        let (project, root_escaped) = temporary_test(root.as_ref()).await.unwrap();
2368        let filters = vec![(root_escaped.as_str(), "[ROOT]")];
2369        insta::with_settings!({filters => filters}, {
2370            assert_json_snapshot!(
2371            project,
2372            {
2373                ".workspace.packages.*.pyproject_toml" => "[PYPROJECT_TOML]"
2374            },
2375            @r#"
2376            {
2377              "project_root": "[ROOT]",
2378              "project_name": "albatross",
2379              "workspace": {
2380                "install_path": "[ROOT]",
2381                "packages": {
2382                  "albatross": {
2383                    "root": "[ROOT]",
2384                    "project": {
2385                      "name": "albatross",
2386                      "version": "0.1.0",
2387                      "requires-python": ">=3.12",
2388                      "dependencies": [
2389                        "tqdm>=4,<5"
2390                      ],
2391                      "optional-dependencies": null
2392                    },
2393                    "pyproject_toml": "[PYPROJECT_TOML]"
2394                  },
2395                  "seeds": {
2396                    "root": "[ROOT]/packages/seeds",
2397                    "project": {
2398                      "name": "seeds",
2399                      "version": "1.0.0",
2400                      "requires-python": ">=3.12",
2401                      "dependencies": [
2402                        "idna==3.6"
2403                      ],
2404                      "optional-dependencies": null
2405                    },
2406                    "pyproject_toml": "[PYPROJECT_TOML]"
2407                  }
2408                },
2409                "required_members": {},
2410                "sources": {},
2411                "indexes": [],
2412                "pyproject_toml": {
2413                  "project": {
2414                    "name": "albatross",
2415                    "version": "0.1.0",
2416                    "requires-python": ">=3.12",
2417                    "dependencies": [
2418                      "tqdm>=4,<5"
2419                    ],
2420                    "optional-dependencies": null
2421                  },
2422                  "tool": {
2423                    "uv": {
2424                      "sources": null,
2425                      "index": null,
2426                      "workspace": {
2427                        "members": [
2428                          "packages/*"
2429                        ],
2430                        "exclude": [
2431                          "packages/bird-feeder"
2432                        ]
2433                      },
2434                      "managed": null,
2435                      "package": null,
2436                      "default-groups": null,
2437                      "dependency-groups": null,
2438                      "dev-dependencies": null,
2439                      "override-dependencies": null,
2440                      "exclude-dependencies": null,
2441                      "constraint-dependencies": null,
2442                      "build-constraint-dependencies": null,
2443                      "environments": null,
2444                      "required-environments": null,
2445                      "conflicts": null,
2446                      "build-backend": null
2447                    }
2448                  },
2449                  "dependency-groups": null
2450                }
2451              }
2452            }
2453            "#);
2454        });
2455
2456        // Rewrite the members to both include and exclude `bird-feeder` by name.
2457        root.child("pyproject.toml").write_str(
2458            r#"
2459            [project]
2460            name = "albatross"
2461            version = "0.1.0"
2462            requires-python = ">=3.12"
2463            dependencies = ["tqdm>=4,<5"]
2464
2465            [tool.uv.workspace]
2466            members = ["packages/seeds", "packages/bird-feeder"]
2467            exclude = ["packages/bird-feeder"]
2468
2469            [build-system]
2470            requires = ["hatchling"]
2471            build-backend = "hatchling.build"
2472            "#,
2473        )?;
2474
2475        // `bird-feeder` should still be excluded.
2476        let (project, root_escaped) = temporary_test(root.as_ref()).await.unwrap();
2477        let filters = vec![(root_escaped.as_str(), "[ROOT]")];
2478        insta::with_settings!({filters => filters}, {
2479            assert_json_snapshot!(
2480            project,
2481            {
2482                ".workspace.packages.*.pyproject_toml" => "[PYPROJECT_TOML]"
2483            },
2484            @r#"
2485            {
2486              "project_root": "[ROOT]",
2487              "project_name": "albatross",
2488              "workspace": {
2489                "install_path": "[ROOT]",
2490                "packages": {
2491                  "albatross": {
2492                    "root": "[ROOT]",
2493                    "project": {
2494                      "name": "albatross",
2495                      "version": "0.1.0",
2496                      "requires-python": ">=3.12",
2497                      "dependencies": [
2498                        "tqdm>=4,<5"
2499                      ],
2500                      "optional-dependencies": null
2501                    },
2502                    "pyproject_toml": "[PYPROJECT_TOML]"
2503                  },
2504                  "seeds": {
2505                    "root": "[ROOT]/packages/seeds",
2506                    "project": {
2507                      "name": "seeds",
2508                      "version": "1.0.0",
2509                      "requires-python": ">=3.12",
2510                      "dependencies": [
2511                        "idna==3.6"
2512                      ],
2513                      "optional-dependencies": null
2514                    },
2515                    "pyproject_toml": "[PYPROJECT_TOML]"
2516                  }
2517                },
2518                "required_members": {},
2519                "sources": {},
2520                "indexes": [],
2521                "pyproject_toml": {
2522                  "project": {
2523                    "name": "albatross",
2524                    "version": "0.1.0",
2525                    "requires-python": ">=3.12",
2526                    "dependencies": [
2527                      "tqdm>=4,<5"
2528                    ],
2529                    "optional-dependencies": null
2530                  },
2531                  "tool": {
2532                    "uv": {
2533                      "sources": null,
2534                      "index": null,
2535                      "workspace": {
2536                        "members": [
2537                          "packages/seeds",
2538                          "packages/bird-feeder"
2539                        ],
2540                        "exclude": [
2541                          "packages/bird-feeder"
2542                        ]
2543                      },
2544                      "managed": null,
2545                      "package": null,
2546                      "default-groups": null,
2547                      "dependency-groups": null,
2548                      "dev-dependencies": null,
2549                      "override-dependencies": null,
2550                      "exclude-dependencies": null,
2551                      "constraint-dependencies": null,
2552                      "build-constraint-dependencies": null,
2553                      "environments": null,
2554                      "required-environments": null,
2555                      "conflicts": null,
2556                      "build-backend": null
2557                    }
2558                  },
2559                  "dependency-groups": null
2560                }
2561              }
2562            }
2563            "#);
2564        });
2565
2566        // Rewrite the exclusion to use the top-level directory (`packages`).
2567        root.child("pyproject.toml").write_str(
2568            r#"
2569            [project]
2570            name = "albatross"
2571            version = "0.1.0"
2572            requires-python = ">=3.12"
2573            dependencies = ["tqdm>=4,<5"]
2574
2575            [tool.uv.workspace]
2576            members = ["packages/seeds", "packages/bird-feeder"]
2577            exclude = ["packages"]
2578
2579            [build-system]
2580            requires = ["hatchling"]
2581            build-backend = "hatchling.build"
2582            "#,
2583        )?;
2584
2585        // `bird-feeder` should now be included.
2586        let (project, root_escaped) = temporary_test(root.as_ref()).await.unwrap();
2587        let filters = vec![(root_escaped.as_str(), "[ROOT]")];
2588        insta::with_settings!({filters => filters}, {
2589            assert_json_snapshot!(
2590            project,
2591            {
2592                ".workspace.packages.*.pyproject_toml" => "[PYPROJECT_TOML]"
2593            },
2594            @r#"
2595            {
2596              "project_root": "[ROOT]",
2597              "project_name": "albatross",
2598              "workspace": {
2599                "install_path": "[ROOT]",
2600                "packages": {
2601                  "albatross": {
2602                    "root": "[ROOT]",
2603                    "project": {
2604                      "name": "albatross",
2605                      "version": "0.1.0",
2606                      "requires-python": ">=3.12",
2607                      "dependencies": [
2608                        "tqdm>=4,<5"
2609                      ],
2610                      "optional-dependencies": null
2611                    },
2612                    "pyproject_toml": "[PYPROJECT_TOML]"
2613                  },
2614                  "bird-feeder": {
2615                    "root": "[ROOT]/packages/bird-feeder",
2616                    "project": {
2617                      "name": "bird-feeder",
2618                      "version": "1.0.0",
2619                      "requires-python": ">=3.12",
2620                      "dependencies": [
2621                        "anyio>=4.3.0,<5"
2622                      ],
2623                      "optional-dependencies": null
2624                    },
2625                    "pyproject_toml": "[PYPROJECT_TOML]"
2626                  },
2627                  "seeds": {
2628                    "root": "[ROOT]/packages/seeds",
2629                    "project": {
2630                      "name": "seeds",
2631                      "version": "1.0.0",
2632                      "requires-python": ">=3.12",
2633                      "dependencies": [
2634                        "idna==3.6"
2635                      ],
2636                      "optional-dependencies": null
2637                    },
2638                    "pyproject_toml": "[PYPROJECT_TOML]"
2639                  }
2640                },
2641                "required_members": {},
2642                "sources": {},
2643                "indexes": [],
2644                "pyproject_toml": {
2645                  "project": {
2646                    "name": "albatross",
2647                    "version": "0.1.0",
2648                    "requires-python": ">=3.12",
2649                    "dependencies": [
2650                      "tqdm>=4,<5"
2651                    ],
2652                    "optional-dependencies": null
2653                  },
2654                  "tool": {
2655                    "uv": {
2656                      "sources": null,
2657                      "index": null,
2658                      "workspace": {
2659                        "members": [
2660                          "packages/seeds",
2661                          "packages/bird-feeder"
2662                        ],
2663                        "exclude": [
2664                          "packages"
2665                        ]
2666                      },
2667                      "managed": null,
2668                      "package": null,
2669                      "default-groups": null,
2670                      "dependency-groups": null,
2671                      "dev-dependencies": null,
2672                      "override-dependencies": null,
2673                      "exclude-dependencies": null,
2674                      "constraint-dependencies": null,
2675                      "build-constraint-dependencies": null,
2676                      "environments": null,
2677                      "required-environments": null,
2678                      "conflicts": null,
2679                      "build-backend": null
2680                    }
2681                  },
2682                  "dependency-groups": null
2683                }
2684              }
2685            }
2686            "#);
2687        });
2688
2689        // Rewrite the exclusion to use the top-level directory with a glob (`packages/*`).
2690        root.child("pyproject.toml").write_str(
2691            r#"
2692            [project]
2693            name = "albatross"
2694            version = "0.1.0"
2695            requires-python = ">=3.12"
2696            dependencies = ["tqdm>=4,<5"]
2697
2698            [tool.uv.workspace]
2699            members = ["packages/seeds", "packages/bird-feeder"]
2700            exclude = ["packages/*"]
2701
2702            [build-system]
2703            requires = ["hatchling"]
2704            build-backend = "hatchling.build"
2705            "#,
2706        )?;
2707
2708        // `bird-feeder` and `seeds` should now be excluded.
2709        let (project, root_escaped) = temporary_test(root.as_ref()).await.unwrap();
2710        let filters = vec![(root_escaped.as_str(), "[ROOT]")];
2711        insta::with_settings!({filters => filters}, {
2712            assert_json_snapshot!(
2713            project,
2714            {
2715                ".workspace.packages.*.pyproject_toml" => "[PYPROJECT_TOML]"
2716            },
2717            @r#"
2718            {
2719              "project_root": "[ROOT]",
2720              "project_name": "albatross",
2721              "workspace": {
2722                "install_path": "[ROOT]",
2723                "packages": {
2724                  "albatross": {
2725                    "root": "[ROOT]",
2726                    "project": {
2727                      "name": "albatross",
2728                      "version": "0.1.0",
2729                      "requires-python": ">=3.12",
2730                      "dependencies": [
2731                        "tqdm>=4,<5"
2732                      ],
2733                      "optional-dependencies": null
2734                    },
2735                    "pyproject_toml": "[PYPROJECT_TOML]"
2736                  }
2737                },
2738                "required_members": {},
2739                "sources": {},
2740                "indexes": [],
2741                "pyproject_toml": {
2742                  "project": {
2743                    "name": "albatross",
2744                    "version": "0.1.0",
2745                    "requires-python": ">=3.12",
2746                    "dependencies": [
2747                      "tqdm>=4,<5"
2748                    ],
2749                    "optional-dependencies": null
2750                  },
2751                  "tool": {
2752                    "uv": {
2753                      "sources": null,
2754                      "index": null,
2755                      "workspace": {
2756                        "members": [
2757                          "packages/seeds",
2758                          "packages/bird-feeder"
2759                        ],
2760                        "exclude": [
2761                          "packages/*"
2762                        ]
2763                      },
2764                      "managed": null,
2765                      "package": null,
2766                      "default-groups": null,
2767                      "dependency-groups": null,
2768                      "dev-dependencies": null,
2769                      "override-dependencies": null,
2770                      "exclude-dependencies": null,
2771                      "constraint-dependencies": null,
2772                      "build-constraint-dependencies": null,
2773                      "environments": null,
2774                      "required-environments": null,
2775                      "conflicts": null,
2776                      "build-backend": null
2777                    }
2778                  },
2779                  "dependency-groups": null
2780                }
2781              }
2782            }
2783            "#);
2784        });
2785
2786        Ok(())
2787    }
2788
2789    #[test]
2790    fn read_dependency_groups() {
2791        let toml = r#"
2792[dependency-groups]
2793foo = ["a", {include-group = "bar"}]
2794bar = ["b"]
2795"#;
2796
2797        let result =
2798            PyProjectToml::from_string(toml.to_string()).expect("Deserialization should succeed");
2799
2800        let groups = result
2801            .dependency_groups
2802            .expect("`dependency-groups` should be present");
2803        let foo = groups
2804            .get(&GroupName::from_str("foo").unwrap())
2805            .expect("Group `foo` should be present");
2806        assert_eq!(
2807            foo,
2808            &[
2809                DependencyGroupSpecifier::Requirement("a".to_string()),
2810                DependencyGroupSpecifier::IncludeGroup {
2811                    include_group: GroupName::from_str("bar").unwrap(),
2812                }
2813            ]
2814        );
2815
2816        let bar = groups
2817            .get(&GroupName::from_str("bar").unwrap())
2818            .expect("Group `bar` should be present");
2819        assert_eq!(
2820            bar,
2821            &[DependencyGroupSpecifier::Requirement("b".to_string())]
2822        );
2823    }
2824
2825    #[tokio::test]
2826    async fn nested_workspace() -> Result<()> {
2827        let root = tempfile::TempDir::new()?;
2828        let root = ChildPath::new(root.path());
2829
2830        // Create the root.
2831        root.child("pyproject.toml").write_str(
2832            r#"
2833            [project]
2834            name = "albatross"
2835            version = "0.1.0"
2836            requires-python = ">=3.12"
2837            dependencies = ["tqdm>=4,<5"]
2838
2839            [tool.uv.workspace]
2840            members = ["packages/*"]
2841            "#,
2842        )?;
2843
2844        // Create an included package (`seeds`).
2845        root.child("packages")
2846            .child("seeds")
2847            .child("pyproject.toml")
2848            .write_str(
2849                r#"
2850            [project]
2851            name = "seeds"
2852            version = "1.0.0"
2853            requires-python = ">=3.12"
2854            dependencies = ["idna==3.6"]
2855
2856            [tool.uv.workspace]
2857            members = ["nested_packages/*"]
2858            "#,
2859            )?;
2860
2861        let (error, root_escaped) = temporary_test(root.as_ref()).await.unwrap_err();
2862        let filters = vec![(root_escaped.as_str(), "[ROOT]")];
2863        insta::with_settings!({filters => filters}, {
2864            assert_snapshot!(
2865                error,
2866            @"Nested workspaces are not supported, but workspace member (`[ROOT]/packages/seeds`) has a `uv.workspace` table");
2867        });
2868
2869        Ok(())
2870    }
2871
2872    #[tokio::test]
2873    async fn duplicate_names() -> Result<()> {
2874        let root = tempfile::TempDir::new()?;
2875        let root = ChildPath::new(root.path());
2876
2877        // Create the root.
2878        root.child("pyproject.toml").write_str(
2879            r#"
2880            [project]
2881            name = "albatross"
2882            version = "0.1.0"
2883            requires-python = ">=3.12"
2884            dependencies = ["tqdm>=4,<5"]
2885
2886            [tool.uv.workspace]
2887            members = ["packages/*"]
2888            "#,
2889        )?;
2890
2891        // Create an included package (`seeds`).
2892        root.child("packages")
2893            .child("seeds")
2894            .child("pyproject.toml")
2895            .write_str(
2896                r#"
2897            [project]
2898            name = "seeds"
2899            version = "1.0.0"
2900            requires-python = ">=3.12"
2901            dependencies = ["idna==3.6"]
2902
2903            [tool.uv.workspace]
2904            members = ["nested_packages/*"]
2905            "#,
2906            )?;
2907
2908        // Create an included package (`seeds2`).
2909        root.child("packages")
2910            .child("seeds2")
2911            .child("pyproject.toml")
2912            .write_str(
2913                r#"
2914            [project]
2915            name = "seeds"
2916            version = "1.0.0"
2917            requires-python = ">=3.12"
2918            dependencies = ["idna==3.6"]
2919
2920            [tool.uv.workspace]
2921            members = ["nested_packages/*"]
2922            "#,
2923            )?;
2924
2925        let (error, root_escaped) = temporary_test(root.as_ref()).await.unwrap_err();
2926        let filters = vec![(root_escaped.as_str(), "[ROOT]")];
2927        insta::with_settings!({filters => filters}, {
2928            assert_snapshot!(
2929                error,
2930            @"Two workspace members are both named `seeds`: `[ROOT]/packages/seeds` and `[ROOT]/packages/seeds2`");
2931        });
2932
2933        Ok(())
2934    }
2935}