Skip to main content

uv_workspace/
workspace.rs

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