Skip to main content

uv_workspace/
workspace.rs

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