Skip to main content

uv_workspace/
workspace.rs

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