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