Skip to main content

uv_workspace/
workspace.rs

1//! Resolve the current [`ProjectWorkspace`] or [`Workspace`].
2
3use std::assert_matches;
4use std::borrow::Cow;
5use std::collections::{BTreeMap, BTreeSet};
6use std::error::Error;
7use std::fmt;
8use std::fmt::Display;
9use std::hash::BuildHasherDefault;
10use std::path::{Path, PathBuf};
11use std::sync::Arc;
12
13use glob::{GlobError, Pattern, PatternError, glob};
14use itertools::Itertools;
15use rustc_hash::{FxHashSet, FxHasher};
16use tracing::{debug, trace, warn};
17
18use uv_cache::Cache;
19use uv_configuration::{DependencyGroupsWithDefaults, ExcludeDependency};
20use uv_distribution_types::{Index, Requirement, RequirementSource};
21use uv_fs::{CWD, Simplified, normalize_path};
22use uv_normalize::{DEV_DEPENDENCIES, GroupName, PackageName};
23use uv_once_map::OnceMap;
24use uv_pep440::VersionSpecifiers;
25use uv_pep508::{MarkerTree, VerbatimUrl};
26use uv_pypi_types::{ConflictError, Conflicts, SupportedEnvironments, VerbatimParsedUrl};
27use uv_static::EnvVars;
28use uv_warnings::warn_user_once;
29
30use crate::dependency_groups::{DependencyGroupError, FlatDependencyGroup, FlatDependencyGroups};
31use crate::pyproject::{
32    OverrideDependency, Project, PyProjectToml, PyprojectTomlError, Source, Sources, ToolUvSources,
33    ToolUvWorkspace, WorkspaceReference,
34};
35
36/// The workspace project environment selected by configuration and command-line options.
37#[derive(Debug)]
38pub enum ProjectEnvironmentSelection {
39    /// Use the workspace's default project environment.
40    Default,
41    /// A path selected by `UV_PROJECT_ENVIRONMENT`.
42    Override(PathBuf),
43    /// The active virtual environment selected by `VIRTUAL_ENV` and `--active`.
44    Active(PathBuf),
45}
46
47impl ProjectEnvironmentSelection {
48    /// Returns `true` if the workspace's default project environment was selected.
49    pub fn is_default(&self) -> bool {
50        matches!(self, Self::Default)
51    }
52
53    /// Returns the explicitly selected environment path, if any.
54    pub fn explicit_path(&self) -> Option<&Path> {
55        match self {
56            Self::Default => None,
57            Self::Override(path) | Self::Active(path) => Some(path),
58        }
59    }
60}
61
62type WorkspaceMembers = Arc<BTreeMap<PackageName, WorkspaceMember>>;
63type FxOnceMap<K, V> = OnceMap<K, V, BuildHasherDefault<FxHasher>>;
64type CachedWorkspaceResult = Result<Arc<Workspace>, WorkspaceError>;
65
66/// Cache for workspace discovery.
67///
68/// Avoid re-reading the `pyproject.toml` files in a workspace for each member by caching the
69/// workspace members by their workspace root.
70///
71/// The cache is indexed both by the workspace root and by the path of each workspace member.
72///
73/// The cache makes assumptions about [`DiscoveryOptions`]:
74/// * `stop_discovery_at` is only used for isolation workspaces in the cache. Otherwise, we avoid
75///   traversing into an external cache if `cache` is accidentally included in the workspace member
76///   glob.
77/// * Only [`MemberDiscovery::All`] results are stored. Successful results can be reused for
78///   [`MemberDiscovery::Existing`], which discovers the same members when none are missing.
79#[derive(Debug, Default, Clone)]
80pub struct WorkspaceCache {
81    workspaces: Arc<FxOnceMap<PathBuf, CachedWorkspaceResult>>,
82}
83
84impl WorkspaceCache {
85    /// Insert a workspace discovery into the cache that may have succeeded or failed.
86    ///
87    /// Once an error is inserted, it will be returned to all future callers that query the failed
88    /// query path.
89    fn insert(&self, result: CachedWorkspaceResult, install_path: &Path) {
90        match result {
91            Ok(workspace) => {
92                for package in workspace.packages.values() {
93                    // Historically, upward workspace discovery stopped at an intermediate
94                    // `pyproject.toml`, so don't map this member to the outer workspace in that
95                    // case.
96                    // See: <https://github.com/astral-sh/uv/issues/19916>
97                    if has_intermediate_pyproject(&workspace.install_path, &package.root) {
98                        continue;
99                    }
100                    self.workspaces
101                        .done(package.root.clone(), Ok(workspace.clone()));
102                }
103                self.workspaces
104                    .done(workspace.install_path.clone(), Ok(workspace));
105            }
106            Err(err) => {
107                self.workspaces.done(install_path.to_path_buf(), Err(err));
108            }
109        }
110    }
111
112    /// Register workspace discovery for a root, or wait for an in-flight discovery.
113    ///
114    /// Calling this function ensures that - given a workspace root - the discovery is only done by
115    /// one thread.
116    async fn register_or_wait(&self, workspace_root: &PathBuf) -> Option<CachedWorkspaceResult> {
117        self.workspaces.register_or_wait(workspace_root).await
118    }
119
120    /// Get the cached workspace, if any, from the path to the workspace root or to a member root.
121    ///
122    /// A successful complete discovery can satisfy [`MemberDiscovery::Existing`]. Cached errors
123    /// cannot, since `Existing` intentionally tolerates missing workspace members.
124    fn get(
125        &self,
126        path: &Path,
127        member_discovery: &MemberDiscovery,
128    ) -> Option<CachedWorkspaceResult> {
129        match member_discovery {
130            MemberDiscovery::All => self.workspaces.get(path),
131            MemberDiscovery::Existing => match self.workspaces.get(path) {
132                Some(Ok(workspace)) => Some(Ok(workspace)),
133                Some(Err(_)) | None => None,
134            },
135            MemberDiscovery::None | MemberDiscovery::Ignore(_) => None,
136        }
137    }
138
139    /// Remove all cached workspace entries for the given workspace root. Used before modifying the
140    /// workspace.
141    ///
142    /// Contract: There are no parallel workspace operations, this is the only thread operating on
143    /// workspaces.
144    fn invalidate_workspace(&self, workspace: &Workspace) {
145        if let Some(Ok(workspace)) = self.workspaces.remove(workspace.install_path()) {
146            for member in workspace.packages.values() {
147                self.workspaces.remove(&member.root);
148            }
149        }
150    }
151}
152
153/// Returns `true` when a `pyproject.toml` sits between the member project directory and the
154/// workspace root.
155fn has_intermediate_pyproject(workspace_root: &Path, project_dir: &Path) -> bool {
156    if project_dir == workspace_root {
157        return false;
158    }
159
160    let Ok(_) = project_dir.strip_prefix(workspace_root) else {
161        return false;
162    };
163
164    project_dir
165        .ancestors()
166        .skip(1)
167        .take_while(|ancestor| *ancestor != workspace_root)
168        .any(|ancestor| ancestor.join("pyproject.toml").is_file())
169}
170
171#[derive(Debug, Clone)]
172pub struct WorkspaceError(Arc<WorkspaceErrorKind>);
173
174impl AsRef<WorkspaceErrorKind> for WorkspaceError {
175    fn as_ref(&self) -> &WorkspaceErrorKind {
176        &self.0
177    }
178}
179
180impl<T> From<T> for WorkspaceError
181where
182    T: Into<WorkspaceErrorKind>,
183{
184    fn from(error: T) -> Self {
185        Self(Arc::new(error.into()))
186    }
187}
188
189impl Display for WorkspaceError {
190    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
191        Display::fmt(&self.0, f)
192    }
193}
194
195impl Error for WorkspaceError {
196    fn source(&self) -> Option<&(dyn Error + 'static)> {
197        self.0.source()
198    }
199}
200
201#[derive(thiserror::Error, Debug)]
202pub enum WorkspaceErrorKind {
203    // Workspace structure errors.
204    #[error("No `pyproject.toml` found in current directory or any parent directory")]
205    MissingPyprojectToml,
206    #[error("Workspace member `{}` is missing a `pyproject.toml` (matches: `{}`)", _0.simplified_display(), _1)]
207    MissingPyprojectTomlMember(PathBuf, String),
208    #[error("No `project` table found in: {}", _0.simplified_display())]
209    MissingProject(PathBuf),
210    #[error("No workspace found for: {}", _0.simplified_display())]
211    MissingWorkspace(PathBuf),
212    #[error("The project is marked as unmanaged: {}", _0.simplified_display())]
213    NonWorkspace(PathBuf),
214    #[error("Nested workspaces are not supported, but workspace member has a `tool.uv.workspace` table: {}", _0.simplified_display())]
215    NestedWorkspace(PathBuf),
216    #[error("The workspace does not have a member {}: {}", _0, _1.simplified_display())]
217    NoSuchMember(PackageName, PathBuf),
218    #[error("Two workspace members are both named `{name}`: `{}` and `{}`", first.simplified_display(), second.simplified_display())]
219    DuplicatePackage {
220        name: PackageName,
221        first: PathBuf,
222        second: PathBuf,
223    },
224    #[error("pyproject.toml section is declared as dynamic, but must be static: `{0}`")]
225    DynamicNotAllowed(&'static str),
226    #[error(
227        "Workspace member `{}` was requested as both `editable = true` and `editable = false`",
228        _0
229    )]
230    EditableConflict(PackageName),
231    #[error("Failed to find directories for glob: `{0}`")]
232    Pattern(String, #[source] PatternError),
233    // Syntax and other errors.
234    #[error("Directory walking failed for `tool.uv.workspace.members` glob: `{0}`")]
235    GlobWalk(String, #[source] GlobError),
236    #[error(transparent)]
237    Io(#[from] std::io::Error),
238    #[error("Failed to parse: `{}`", _0.user_display())]
239    Toml(PathBuf, #[source] Box<PyprojectTomlError>),
240    #[error(transparent)]
241    Conflicts(#[from] ConflictError),
242    // On Windows and Unix, this is not a regular IO failure, but requires e.g. `current_dir` to
243    // fail.
244    #[error("Failed to normalize workspace member path")]
245    Normalize(#[source] std::io::Error),
246}
247
248#[derive(Debug, Default, Clone, Hash, PartialEq, Eq)]
249pub enum MemberDiscovery {
250    /// Discover all workspace members.
251    #[default]
252    All,
253    /// Discover workspace members that are present, but ignore missing members.
254    Existing,
255    /// Don't discover any workspace members.
256    None,
257    /// Discover workspace members, but ignore the given paths.
258    Ignore(BTreeSet<PathBuf>),
259}
260
261#[derive(Debug, Default, Clone, Hash, PartialEq, Eq)]
262pub struct DiscoveryOptions {
263    /// The path to stop discovery at.
264    ///
265    /// Assumption: This is only used for directories in the cache to avoid them escaping the cache.
266    /// If you want to use it for other cases too, you need to also update the cache handling in
267    /// the workspace discovery glob walking.
268    pub stop_discovery_at: Option<PathBuf>,
269    /// The strategy to use when discovering workspace members.
270    pub members: MemberDiscovery,
271}
272
273pub type RequiresPythonSources = BTreeMap<(PackageName, Option<GroupName>), VersionSpecifiers>;
274
275pub type Editability = Option<bool>;
276
277/// A workspace, consisting of a root directory and members. See [`ProjectWorkspace`].
278#[derive(Debug, Clone)]
279#[cfg_attr(test, derive(serde::Serialize))]
280pub struct Workspace {
281    /// The path to the workspace root.
282    ///
283    /// The workspace root is the directory containing the top level `pyproject.toml` with
284    /// the `uv.tool.workspace`, or the `pyproject.toml` in an implicit single workspace project.
285    install_path: PathBuf,
286    /// The members of the workspace.
287    packages: WorkspaceMembers,
288    /// The workspace members that are required by other members, and whether they were requested
289    /// as editable.
290    required_members: BTreeMap<PackageName, Editability>,
291    /// The sources table from the workspace `pyproject.toml`.
292    ///
293    /// This table is overridden by the project sources.
294    sources: BTreeMap<PackageName, Sources>,
295    /// The index table from the workspace `pyproject.toml`.
296    ///
297    /// This table is overridden by the project indexes.
298    indexes: Vec<Index>,
299    /// The `pyproject.toml` of the workspace root.
300    pyproject_toml: PyProjectToml,
301}
302
303impl Workspace {
304    /// Find the workspace containing the given path.
305    ///
306    /// Unlike the [`ProjectWorkspace`] discovery, this does not require a current project. It also
307    /// always uses absolute path, i.e., this method only supports discovering the main workspace.
308    ///
309    /// Steps of workspace discovery: Start by looking at the closest `pyproject.toml`:
310    /// * If it's an explicit workspace root: Collect workspace from this root, we're done.
311    /// * If it's also not a project: Error, must be either a workspace root or a project.
312    /// * Otherwise, try to find an explicit workspace root above:
313    ///   * If an explicit workspace root exists: Collect workspace from this root, we're done.
314    ///   * If there is no explicit workspace: We have a single project workspace, we're done.
315    ///
316    /// Note that there are two kinds of workspace roots: projects, and non-project roots.
317    /// The non-project roots lack a `[project]` table, and so are not themselves projects, as in:
318    /// ```toml
319    /// [tool.uv.workspace]
320    /// members = ["packages/*"]
321    ///
322    /// [tool.uv]
323    /// dev-dependencies = ["ruff"]
324    /// ```
325    pub async fn discover(
326        path: &Path,
327        options: &DiscoveryOptions,
328        cache: &Cache,
329        workspace_cache: &WorkspaceCache,
330    ) -> Result<Arc<Self>, WorkspaceError> {
331        let path = std::path::absolute(path)
332            .map_err(WorkspaceErrorKind::Normalize)?
333            .clone();
334        let path = normalize_path(&path);
335
336        let project_path = path
337            .ancestors()
338            .find(|path| path.join("pyproject.toml").is_file())
339            .ok_or(WorkspaceErrorKind::MissingPyprojectToml)?
340            .to_path_buf();
341
342        // Fast path: The workspace was already fully discovered.
343        // It's possible that there are two separate discoveries for the same workspace going on
344        // at the same time from different roots, both failing this check. These cases are fine, we
345        // synchronize them after finding the workspace root and allow only one of them to perform
346        // the full discovery.
347        if let Some(workspace) = workspace_cache.get(&project_path, &options.members) {
348            return workspace;
349        }
350
351        let pyproject_path = project_path.join("pyproject.toml");
352        let contents = fs_err::tokio::read_to_string(&pyproject_path).await?;
353        let pyproject_toml = PyProjectToml::from_string(contents, &pyproject_path)
354            .map_err(|err| WorkspaceErrorKind::Toml(pyproject_path.clone(), Box::new(err)))?;
355
356        // Check if the project is explicitly marked as unmanaged.
357        if pyproject_toml
358            .tool
359            .as_ref()
360            .and_then(|tool| tool.uv.as_ref())
361            .and_then(|uv| uv.managed)
362            == Some(false)
363        {
364            debug!(
365                "Project `{}` is marked as unmanaged",
366                project_path.simplified_display()
367            );
368            return Err(WorkspaceError::from(WorkspaceErrorKind::NonWorkspace(
369                project_path,
370            )));
371        }
372
373        // Check if the current project is also an explicit workspace root.
374        let explicit_root = pyproject_toml
375            .tool
376            .as_ref()
377            .and_then(|tool| tool.uv.as_ref())
378            .and_then(|uv| uv.workspace.as_ref())
379            .map(|workspace| {
380                (
381                    project_path.clone(),
382                    workspace.clone(),
383                    pyproject_toml.clone(),
384                )
385            });
386
387        let (workspace_root, workspace_definition, workspace_pyproject_toml) =
388            if let Some(workspace) = explicit_root {
389                // We have found the explicit root immediately.
390                workspace
391            } else if pyproject_toml.project.is_none() {
392                // Without a project, it can't be an implicit root
393                return Err(WorkspaceError::from(WorkspaceErrorKind::MissingProject(
394                    pyproject_path,
395                )));
396            } else if let Some(workspace) = find_workspace(&project_path, options, cache).await? {
397                // We have found an explicit root above.
398                workspace
399            } else {
400                // Support implicit single project workspaces.
401                (
402                    project_path.clone(),
403                    ToolUvWorkspace::default(),
404                    pyproject_toml.clone(),
405                )
406            };
407
408        if options.members == MemberDiscovery::All {
409            // Ensure that workspace discovery runs only once for any given workspace root.
410            // If two threads start at different packages at the same time, they only read their
411            // package `pyproject.toml` and the workspace root `pyproject.toml` before arriving
412            // here. At this point, only one thread can continue and the other waits, then uses the
413            // cached workspace.
414            if let Some(workspace) = workspace_cache.register_or_wait(&workspace_root).await {
415                return workspace;
416            }
417        }
418
419        debug!(
420            "Found workspace root: `{}`",
421            workspace_root.simplified_display()
422        );
423
424        // Unlike in `ProjectWorkspace` discovery, we might be in a non-project root without
425        // being in any specific project.
426        let current_project = pyproject_toml
427            .project
428            .clone()
429            .map(|project| WorkspaceMember {
430                root: project_path,
431                project,
432                pyproject_toml,
433            });
434
435        let result = Self::build(
436            workspace_root.clone(),
437            workspace_definition,
438            workspace_pyproject_toml,
439            current_project,
440            options,
441            cache,
442        )
443        .await;
444        if options.members == MemberDiscovery::All {
445            workspace_cache.insert(result.clone(), &workspace_root);
446        }
447        result
448    }
449
450    /// Set the current project to the given workspace member.
451    ///
452    /// Returns `None` if the package is not part of the workspace.
453    fn with_current_project(
454        self: Arc<Self>,
455        package_name: PackageName,
456    ) -> Option<ProjectWorkspace> {
457        let member = self.packages.get(&package_name)?;
458        Some(ProjectWorkspace {
459            project_root: member.root().clone(),
460            project_name: package_name,
461            workspace: self,
462        })
463    }
464
465    /// Set the [`ProjectWorkspace`] for a given workspace member.
466    ///
467    /// Assumes that the project name is unchanged in the updated [`PyProjectToml`], and that the
468    /// caller holds the only reference to this workspace (to avoid a situation where another part
469    /// of uv still holds a reference to the old workspace structure).
470    fn update_member(
471        self: Arc<Self>,
472        package_name: &PackageName,
473        pyproject_toml: PyProjectToml,
474    ) -> Result<Option<Arc<Self>>, WorkspaceError> {
475        debug_assert_eq!(
476            Arc::strong_count(&self),
477            1,
478            "cannot modify workspace still in use",
479        );
480
481        let slf = Arc::unwrap_or_clone(self);
482        let mut packages = slf.packages;
483
484        let Some(member) = Arc::make_mut(&mut packages).get_mut(package_name) else {
485            return Ok(None);
486        };
487
488        if member.root == slf.install_path {
489            // If the member is also the workspace root, update _both_ the member entry and the
490            // root `pyproject.toml`.
491            let workspace_pyproject_toml = pyproject_toml.clone();
492
493            // Refresh the workspace sources.
494            let workspace_sources = workspace_pyproject_toml
495                .tool
496                .clone()
497                .and_then(|tool| tool.uv)
498                .and_then(|uv| uv.sources)
499                .map(ToolUvSources::into_inner)
500                .unwrap_or_default();
501
502            // Set the `pyproject.toml` for the member.
503            member.pyproject_toml = pyproject_toml;
504
505            // Recompute required_members with the updated data
506            let required_members = Self::collect_required_members(
507                &packages,
508                &workspace_sources,
509                &workspace_pyproject_toml,
510            )?;
511
512            let workspace = Self {
513                pyproject_toml: workspace_pyproject_toml,
514                sources: workspace_sources,
515                packages,
516                required_members,
517                ..slf
518            };
519            Ok(Some(Arc::new(workspace)))
520        } else {
521            // Set the `pyproject.toml` for the member.
522            member.pyproject_toml = pyproject_toml;
523
524            // Recompute required_members with the updated member data
525            let required_members =
526                Self::collect_required_members(&packages, &slf.sources, &slf.pyproject_toml)?;
527
528            let workspace = Self {
529                packages,
530                required_members,
531                ..slf
532            };
533            Ok(Some(Arc::new(workspace)))
534        }
535    }
536
537    /// Returns `true` if the workspace has a non-project root.
538    pub fn is_non_project(&self) -> bool {
539        !self
540            .packages
541            .values()
542            .any(|member| *member.root() == self.install_path)
543    }
544
545    /// Returns the set of all workspace members.
546    pub fn members_requirements(&self) -> impl Iterator<Item = Requirement> + '_ {
547        self.packages.iter().filter_map(|(name, member)| {
548            let url = VerbatimUrl::from_absolute_path(&member.root).expect("path is valid URL");
549            Some(Requirement {
550                name: member.pyproject_toml.project.as_ref()?.name.clone(),
551                extras: Box::new([]),
552                groups: Box::new([]),
553                marker: MarkerTree::TRUE,
554                source: if member
555                    .pyproject_toml()
556                    .is_package(!self.is_required_member(name))
557                {
558                    RequirementSource::Directory {
559                        install_path: member.root.clone().into_boxed_path(),
560                        editable: Some(
561                            self.required_members
562                                .get(name)
563                                .copied()
564                                .flatten()
565                                .unwrap_or(true),
566                        ),
567                        r#virtual: Some(false),
568                        url,
569                    }
570                } else {
571                    RequirementSource::Directory {
572                        install_path: member.root.clone().into_boxed_path(),
573                        editable: Some(false),
574                        r#virtual: Some(true),
575                        url,
576                    }
577                },
578                origin: None,
579            })
580        })
581    }
582
583    /// The workspace members that are required my another member of the workspace.
584    pub fn required_members(&self) -> &BTreeMap<PackageName, Editability> {
585        &self.required_members
586    }
587
588    /// Compute the workspace members that are required by another member of the workspace, and
589    /// determine whether they should be installed as editable or non-editable.
590    ///
591    /// N.B. this checks if a workspace member is required by inspecting `tool.uv.source` entries,
592    /// but does not actually check if the source is _used_, which could result in false positives
593    /// but is easier to compute.
594    fn collect_required_members(
595        packages: &BTreeMap<PackageName, WorkspaceMember>,
596        sources: &BTreeMap<PackageName, Sources>,
597        pyproject_toml: &PyProjectToml,
598    ) -> Result<BTreeMap<PackageName, Editability>, WorkspaceError> {
599        let mut required_members = BTreeMap::new();
600
601        for (package, sources) in sources
602            .iter()
603            .filter(|(name, _)| {
604                pyproject_toml
605                    .project
606                    .as_ref()
607                    .is_none_or(|project| project.name != **name)
608            })
609            .chain(
610                packages
611                    .iter()
612                    .filter_map(|(name, member)| {
613                        member
614                            .pyproject_toml
615                            .tool
616                            .as_ref()
617                            .and_then(|tool| tool.uv.as_ref())
618                            .and_then(|uv| uv.sources.as_ref())
619                            .map(ToolUvSources::inner)
620                            .map(move |sources| {
621                                sources
622                                    .iter()
623                                    .filter(move |(source_name, _)| name != *source_name)
624                            })
625                    })
626                    .flatten(),
627            )
628        {
629            for source in sources.iter() {
630                let Source::Workspace {
631                    workspace: WorkspaceReference::Bool(true),
632                    editable,
633                    ..
634                } = &source
635                else {
636                    continue;
637                };
638                let existing = required_members.insert(package.clone(), *editable);
639                if let Some(Some(existing)) = existing {
640                    if let Some(editable) = editable {
641                        // If there are conflicting `editable` values, raise an error.
642                        if existing != *editable {
643                            return Err(WorkspaceError::from(
644                                WorkspaceErrorKind::EditableConflict(package.clone()),
645                            ));
646                        }
647                    }
648                }
649            }
650        }
651
652        Ok(required_members)
653    }
654
655    /// Whether a given workspace member is required by another member.
656    fn is_required_member(&self, name: &PackageName) -> bool {
657        self.required_members().contains_key(name)
658    }
659
660    /// Returns the set of all workspace member dependency groups.
661    pub fn group_requirements(&self) -> impl Iterator<Item = Requirement> + '_ {
662        self.packages.iter().filter_map(|(name, member)| {
663            let url = VerbatimUrl::from_absolute_path(&member.root).expect("path is valid URL");
664
665            let groups = {
666                let mut groups = member
667                    .pyproject_toml
668                    .dependency_groups
669                    .as_ref()
670                    .map(|groups| groups.keys().cloned().collect::<Vec<_>>())
671                    .unwrap_or_default();
672                if member
673                    .pyproject_toml
674                    .tool
675                    .as_ref()
676                    .and_then(|tool| tool.uv.as_ref())
677                    .and_then(|uv| uv.dev_dependencies.as_ref())
678                    .is_some()
679                {
680                    groups.push(DEV_DEPENDENCIES.clone());
681                    groups.sort_unstable();
682                }
683                groups
684            };
685            if groups.is_empty() {
686                return None;
687            }
688
689            let value = self.required_members.get(name);
690            let is_required_member = value.is_some();
691            let editability = value.copied().flatten();
692
693            Some(Requirement {
694                name: member.pyproject_toml.project.as_ref()?.name.clone(),
695                extras: Box::new([]),
696                groups: groups.into_boxed_slice(),
697                marker: MarkerTree::TRUE,
698                source: if member.pyproject_toml().is_package(!is_required_member) {
699                    RequirementSource::Directory {
700                        install_path: member.root.clone().into_boxed_path(),
701                        editable: Some(editability.unwrap_or(true)),
702                        r#virtual: Some(false),
703                        url,
704                    }
705                } else {
706                    RequirementSource::Directory {
707                        install_path: member.root.clone().into_boxed_path(),
708                        editable: Some(false),
709                        r#virtual: Some(true),
710                        url,
711                    }
712                },
713                origin: None,
714            })
715        })
716    }
717
718    /// Returns the set of supported environments for the workspace.
719    pub fn environments(&self) -> Option<&SupportedEnvironments> {
720        self.pyproject_toml
721            .tool
722            .as_ref()
723            .and_then(|tool| tool.uv.as_ref())
724            .and_then(|uv| uv.environments.as_ref())
725    }
726
727    /// Returns the set of required platforms for the workspace.
728    pub fn required_environments(&self) -> Option<&SupportedEnvironments> {
729        self.pyproject_toml
730            .tool
731            .as_ref()
732            .and_then(|tool| tool.uv.as_ref())
733            .and_then(|uv| uv.required_environments.as_ref())
734    }
735
736    /// Returns the set of conflicts for the workspace.
737    pub fn conflicts(&self) -> Result<Conflicts, WorkspaceError> {
738        let mut conflicting = Conflicts::empty();
739        if self.is_non_project()
740            && let Some(root_conflicts) = self
741                .pyproject_toml
742                .tool
743                .as_ref()
744                .and_then(|tool| tool.uv.as_ref())
745                .and_then(|uv| uv.conflicts.as_ref())
746        {
747            let mut root_conflicts = root_conflicts.to_conflicts()?;
748            conflicting.append(&mut root_conflicts);
749        }
750        for member in self.packages.values() {
751            conflicting.append(&mut member.pyproject_toml.conflicts()?);
752        }
753        Ok(conflicting)
754    }
755
756    /// Returns an iterator over the `requires-python` values for each member of the workspace.
757    pub fn requires_python(
758        &self,
759        groups: &DependencyGroupsWithDefaults,
760    ) -> Result<RequiresPythonSources, DependencyGroupError> {
761        let mut requires = RequiresPythonSources::new();
762        for (name, member) in self.packages() {
763            // Get the top-level requires-python for this package, which is always active
764            //
765            // Arguably we could check groups.prod() to disable this, since, the requires-python
766            // of the project is *technically* not relevant if you're doing `--only-group`, but,
767            // that would be a big surprising change, so let's *not* do that until someone asks!
768            let top_requires = member
769                .pyproject_toml()
770                .project
771                .as_ref()
772                .and_then(|project| project.requires_python.as_ref())
773                .map(|requires_python| ((name.to_owned(), None), requires_python.clone()));
774            requires.extend(top_requires);
775
776            // Get the requires-python for each enabled group on this package
777            // We need to do full flattening here because include-group can transfer requires-python
778            let dependency_groups =
779                FlatDependencyGroups::from_pyproject_toml(member.root(), &member.pyproject_toml)?;
780            let group_requires =
781                dependency_groups
782                    .into_iter()
783                    .filter_map(move |(group_name, flat_group)| {
784                        if groups.contains(&group_name) {
785                            flat_group.requires_python.map(|requires_python| {
786                                ((name.to_owned(), Some(group_name)), requires_python)
787                            })
788                        } else {
789                            None
790                        }
791                    });
792            requires.extend(group_requires);
793        }
794        Ok(requires)
795    }
796
797    /// Returns any requirements that are exclusive to the workspace root, i.e., not included in
798    /// any of the workspace members.
799    ///
800    /// For now, there are no such requirements.
801    pub fn requirements(&self) -> Vec<uv_pep508::Requirement<VerbatimParsedUrl>> {
802        Vec::new()
803    }
804
805    /// Returns any dependency groups that are exclusive to the workspace root, i.e., not included
806    /// in any of the workspace members.
807    ///
808    /// For workspaces with non-`[project]` roots, returns the dependency groups defined in the
809    /// corresponding `pyproject.toml`.
810    ///
811    /// Otherwise, returns an empty list.
812    pub fn workspace_dependency_groups(
813        &self,
814    ) -> Result<BTreeMap<GroupName, FlatDependencyGroup>, DependencyGroupError> {
815        if self
816            .packages
817            .values()
818            .any(|member| *member.root() == self.install_path)
819        {
820            // If the workspace has an explicit root, the root is a member, so we don't need to
821            // include any root-only requirements.
822            Ok(BTreeMap::default())
823        } else {
824            // Otherwise, return the dependency groups in the non-project workspace root.
825            let dependency_groups = FlatDependencyGroups::from_pyproject_toml(
826                &self.install_path,
827                &self.pyproject_toml,
828            )?;
829            Ok(dependency_groups.into_inner())
830        }
831    }
832
833    /// Returns the set of overrides for the workspace.
834    pub fn overrides(&self) -> Vec<OverrideDependency> {
835        let Some(overrides) = self
836            .pyproject_toml
837            .tool
838            .as_ref()
839            .and_then(|tool| tool.uv.as_ref())
840            .and_then(|uv| uv.override_dependencies.as_ref())
841        else {
842            return vec![];
843        };
844        overrides.clone()
845    }
846
847    /// Returns the set of dependency exclusions for the workspace.
848    pub fn exclude_dependencies(&self) -> Vec<ExcludeDependency> {
849        let Some(excludes) = self
850            .pyproject_toml
851            .tool
852            .as_ref()
853            .and_then(|tool| tool.uv.as_ref())
854            .and_then(|uv| uv.exclude_dependencies.as_ref())
855        else {
856            return vec![];
857        };
858        excludes.clone()
859    }
860
861    /// Returns the set of constraints for the workspace.
862    pub fn constraints(&self) -> Vec<uv_pep508::Requirement<VerbatimParsedUrl>> {
863        let Some(constraints) = self
864            .pyproject_toml
865            .tool
866            .as_ref()
867            .and_then(|tool| tool.uv.as_ref())
868            .and_then(|uv| uv.constraint_dependencies.as_ref())
869        else {
870            return vec![];
871        };
872        constraints.clone()
873    }
874
875    /// Returns the set of build constraints for the workspace.
876    pub fn build_constraints(&self) -> Vec<uv_pep508::Requirement<VerbatimParsedUrl>> {
877        let Some(build_constraints) = self
878            .pyproject_toml
879            .tool
880            .as_ref()
881            .and_then(|tool| tool.uv.as_ref())
882            .and_then(|uv| uv.build_constraint_dependencies.as_ref())
883        else {
884            return vec![];
885        };
886        build_constraints.clone()
887    }
888
889    /// The path to the workspace root, the directory containing the top level `pyproject.toml` with
890    /// the `uv.tool.workspace`, or the `pyproject.toml` in an implicit single workspace project.
891    pub fn install_path(&self) -> &PathBuf {
892        &self.install_path
893    }
894
895    /// The workspace project environment selection.
896    ///
897    /// If `UV_PROJECT_ENVIRONMENT` is set, it will take precedence. If a relative path is provided,
898    /// it is resolved relative to the install path.
899    ///
900    /// If `active` is `true`, the `VIRTUAL_ENV` variable will be preferred. If it is `false`, any
901    /// warnings about mismatch between the active environment and the project environment will be
902    /// silenced.
903    pub fn environment_selection(&self, active: Option<bool>) -> ProjectEnvironmentSelection {
904        /// Resolve the `UV_PROJECT_ENVIRONMENT` value, if any.
905        fn from_project_environment_variable(workspace: &Workspace) -> Option<PathBuf> {
906            let value = std::env::var_os(EnvVars::UV_PROJECT_ENVIRONMENT)?;
907
908            if value.is_empty() {
909                return None;
910            }
911
912            let path = PathBuf::from(value);
913            if path.is_absolute() {
914                return Some(path);
915            }
916
917            // Resolve the path relative to the install path.
918            Some(workspace.install_path.join(path))
919        }
920
921        /// Resolve the `VIRTUAL_ENV` variable, if any.
922        fn from_virtual_env_variable() -> Option<PathBuf> {
923            let value = std::env::var_os(EnvVars::VIRTUAL_ENV)?;
924
925            if value.is_empty() {
926                return None;
927            }
928
929            let path = PathBuf::from(value);
930            if path.is_absolute() {
931                return Some(path);
932            }
933
934            // Resolve the path relative to current directory.
935            // Note this differs from `UV_PROJECT_ENVIRONMENT`
936            Some(CWD.join(path))
937        }
938
939        let selection = from_project_environment_variable(self)
940            .map(ProjectEnvironmentSelection::Override)
941            .unwrap_or(ProjectEnvironmentSelection::Default);
942        let project_environment_path = selection
943            .explicit_path()
944            .map_or_else(|| self.install_path.join(".venv"), Path::to_path_buf);
945
946        // Warn if it conflicts with `VIRTUAL_ENV`
947        if let Some(from_virtual_env) = from_virtual_env_variable() {
948            let matches_project =
949                uv_fs::is_same_file_allow_missing(&from_virtual_env, &project_environment_path)
950                    .unwrap_or(false);
951            match active {
952                Some(true) => {
953                    if !matches_project {
954                        debug!(
955                            "Using active virtual environment `{}` instead of project environment `{}`",
956                            from_virtual_env.user_display(),
957                            project_environment_path.user_display()
958                        );
959                    }
960                    return ProjectEnvironmentSelection::Active(from_virtual_env);
961                }
962                Some(false) => {}
963                None if !matches_project => {
964                    warn_user_once!(
965                        "`VIRTUAL_ENV={}` does not match the project environment path `{}` and will be ignored; use `--active` to target the active environment instead",
966                        from_virtual_env.user_display(),
967                        project_environment_path.user_display()
968                    );
969                }
970                None => {}
971            }
972        } else {
973            if active.unwrap_or_default() {
974                debug!(
975                    "Use of the active virtual environment was requested, but `VIRTUAL_ENV` is not set"
976                );
977            }
978        }
979
980        selection
981    }
982
983    /// The members of the workspace.
984    pub fn packages(&self) -> &BTreeMap<PackageName, WorkspaceMember> {
985        &self.packages
986    }
987
988    /// The sources table from the workspace `pyproject.toml`.
989    pub fn sources(&self) -> &BTreeMap<PackageName, Sources> {
990        &self.sources
991    }
992
993    /// The index table from the workspace `pyproject.toml`.
994    pub fn indexes(&self) -> &[Index] {
995        &self.indexes
996    }
997
998    /// The `pyproject.toml` of the workspace.
999    pub fn pyproject_toml(&self) -> &PyProjectToml {
1000        &self.pyproject_toml
1001    }
1002
1003    /// Returns `true` if the path is excluded by the workspace.
1004    pub fn excludes(&self, project_path: &Path) -> Result<bool, WorkspaceError> {
1005        if let Some(workspace) = self
1006            .pyproject_toml
1007            .tool
1008            .as_ref()
1009            .and_then(|tool| tool.uv.as_ref())
1010            .and_then(|uv| uv.workspace.as_ref())
1011        {
1012            is_excluded_from_workspace(project_path, &self.install_path, workspace)
1013        } else {
1014            Ok(false)
1015        }
1016    }
1017
1018    /// Returns `true` if the path is included by the workspace.
1019    pub fn includes(&self, project_path: &Path) -> Result<bool, WorkspaceError> {
1020        if let Some(workspace) = self
1021            .pyproject_toml
1022            .tool
1023            .as_ref()
1024            .and_then(|tool| tool.uv.as_ref())
1025            .and_then(|uv| uv.workspace.as_ref())
1026        {
1027            is_included_in_workspace(project_path, &self.install_path, workspace)
1028        } else {
1029            Ok(false)
1030        }
1031    }
1032
1033    /// Collect the workspace member projects and build the workspace object.
1034    async fn build(
1035        workspace_root: PathBuf,
1036        workspace_definition: ToolUvWorkspace,
1037        workspace_pyproject_toml: PyProjectToml,
1038        current_project: Option<WorkspaceMember>,
1039        options: &DiscoveryOptions,
1040        cache: &Cache,
1041    ) -> Result<Arc<Self>, WorkspaceError> {
1042        trace!(
1043            "Discovering workspace members for: `{}`",
1044            &workspace_root.simplified_display()
1045        );
1046        let workspace_members = Self::collect_members_only(
1047            &workspace_root,
1048            &workspace_definition,
1049            &workspace_pyproject_toml,
1050            options,
1051            cache,
1052        )
1053        .await?;
1054        let mut workspace_members = Arc::new(workspace_members);
1055
1056        // For the cases such as `MemberDiscovery::None`, add the current project if missing.
1057        if let Some(root_member) = current_project
1058            && !workspace_members.contains_key(&root_member.project.name)
1059        {
1060            assert_matches!(
1061                options.members,
1062                MemberDiscovery::None | MemberDiscovery::Ignore(_)
1063            );
1064            debug!(
1065                "Adding current workspace member: `{}`",
1066                root_member.root.simplified_display()
1067            );
1068
1069            Arc::make_mut(&mut workspace_members)
1070                .insert(root_member.project.name.clone(), root_member);
1071        }
1072
1073        let workspace_sources = workspace_pyproject_toml
1074            .tool
1075            .clone()
1076            .and_then(|tool| tool.uv)
1077            .and_then(|uv| uv.sources)
1078            .map(ToolUvSources::into_inner)
1079            .unwrap_or_default();
1080
1081        let workspace_indexes = workspace_pyproject_toml
1082            .tool
1083            .clone()
1084            .and_then(|tool| tool.uv)
1085            .and_then(|uv| uv.index)
1086            .unwrap_or_default();
1087
1088        let required_members = Self::collect_required_members(
1089            &workspace_members,
1090            &workspace_sources,
1091            &workspace_pyproject_toml,
1092        )?;
1093
1094        let dev_dependencies_members = workspace_members
1095            .values()
1096            .filter_map(|member| {
1097                member
1098                    .pyproject_toml
1099                    .tool
1100                    .as_ref()
1101                    .and_then(|tool| tool.uv.as_ref())
1102                    .and_then(|uv| uv.dev_dependencies.as_ref())
1103                    .map(|_| format!("`{}`", member.root().join("pyproject.toml").user_display()))
1104            })
1105            .join(", ");
1106        if !dev_dependencies_members.is_empty() {
1107            warn_user_once!(
1108                "The `tool.uv.dev-dependencies` field (used in {}) is deprecated and will be removed in a future release; use `dependency-groups.dev` instead",
1109                dev_dependencies_members
1110            );
1111        }
1112
1113        let workspace = Self {
1114            install_path: workspace_root,
1115            packages: workspace_members,
1116            required_members,
1117            sources: workspace_sources,
1118            indexes: workspace_indexes,
1119            pyproject_toml: workspace_pyproject_toml,
1120        };
1121        Ok(Arc::new(workspace))
1122    }
1123
1124    async fn collect_members_only(
1125        workspace_root: &PathBuf,
1126        workspace_definition: &ToolUvWorkspace,
1127        workspace_pyproject_toml: &PyProjectToml,
1128        options: &DiscoveryOptions,
1129        cache: &Cache,
1130    ) -> Result<BTreeMap<PackageName, WorkspaceMember>, WorkspaceError> {
1131        let mut workspace_members = BTreeMap::new();
1132        // Avoid reading a `pyproject.toml` more than once.
1133        let mut seen = FxHashSet::default();
1134
1135        let external_cache_root = options
1136            .stop_discovery_at
1137            .is_none()
1138            .then(|| {
1139                // We may receive an uninitialized cache with a relative cache root.
1140                let cache_root = if cache.root().is_absolute() {
1141                    cache.root().to_path_buf()
1142                } else {
1143                    CWD.join(cache.root())
1144                };
1145                normalize_path(&cache_root).into_owned()
1146            })
1147            .filter(|cache_root| !workspace_root.starts_with(cache_root));
1148
1149        // Add the project at the workspace root, if it exists and if it's distinct from the current
1150        // project. If it is the current project, it is added as such in the next step.
1151        if let Some(project) = &workspace_pyproject_toml.project {
1152            debug!(
1153                "Adding root workspace member: `{}`",
1154                workspace_root.simplified_display()
1155            );
1156
1157            seen.insert(workspace_root.clone());
1158            workspace_members.insert(
1159                project.name.clone(),
1160                WorkspaceMember {
1161                    root: workspace_root.clone(),
1162                    project: project.clone(),
1163                    pyproject_toml: workspace_pyproject_toml.clone(),
1164                },
1165            );
1166        }
1167
1168        // Prepare exclusions only after finding a member that is not explicitly ignored.
1169        let mut exclusions = None;
1170
1171        // Add all other workspace members.
1172        for member_glob in workspace_definition.members.as_deref().unwrap_or_default() {
1173            // Normalize the member glob to remove leading `./` and other relative path components
1174            let normalized_glob = normalize_path(Path::new(member_glob.as_str()));
1175            let absolute_glob = PathBuf::from(glob::Pattern::escape(
1176                workspace_root.simplified().to_string_lossy().as_ref(),
1177            ))
1178            .join(normalized_glob.as_ref())
1179            .to_string_lossy()
1180            .to_string();
1181            for member_root in glob(&absolute_glob)
1182                .map_err(|err| WorkspaceErrorKind::Pattern(absolute_glob.clone(), err))?
1183            {
1184                let member_root = member_root
1185                    .map_err(|err| WorkspaceErrorKind::GlobWalk(absolute_glob.clone(), err))?;
1186                if external_cache_root
1187                    .as_ref()
1188                    .is_some_and(|cache_root| member_root.starts_with(cache_root))
1189                {
1190                    debug!(
1191                        "Ignoring cache directory while discovering workspace members: `{}`",
1192                        member_root.simplified_display()
1193                    );
1194                    continue;
1195                }
1196                if !seen.insert(member_root.clone()) {
1197                    continue;
1198                }
1199                let member_root =
1200                    std::path::absolute(&member_root).map_err(WorkspaceErrorKind::Normalize)?;
1201
1202                // If the directory is explicitly ignored, skip it.
1203                let skip = match &options.members {
1204                    MemberDiscovery::All | MemberDiscovery::Existing => false,
1205                    MemberDiscovery::None => true,
1206                    MemberDiscovery::Ignore(ignore) => ignore.contains(member_root.as_path()),
1207                };
1208                if skip {
1209                    debug!(
1210                        "Ignoring workspace member: `{}`",
1211                        member_root.simplified_display()
1212                    );
1213                    continue;
1214                }
1215
1216                // If the member is excluded, ignore it.
1217                if exclusions
1218                    .get_or_insert_with(|| {
1219                        WorkspaceExclusions::new(workspace_root, workspace_definition)
1220                    })
1221                    .as_ref()
1222                    .map_err(WorkspaceError::clone)?
1223                    .matches(&member_root)
1224                {
1225                    debug!(
1226                        "Ignoring workspace member: `{}`",
1227                        member_root.simplified_display()
1228                    );
1229                    continue;
1230                }
1231
1232                trace!(
1233                    "Processing workspace member: `{}`",
1234                    member_root.user_display()
1235                );
1236
1237                // Read the member `pyproject.toml`.
1238                let pyproject_path = member_root.join("pyproject.toml");
1239                let contents = match fs_err::tokio::read_to_string(&pyproject_path).await {
1240                    Ok(contents) => contents,
1241                    Err(err) => {
1242                        let metadata = match fs_err::metadata(&member_root) {
1243                            Ok(metadata) => metadata,
1244                            Err(err)
1245                                if matches!(options.members, MemberDiscovery::Existing)
1246                                    && err.kind() == std::io::ErrorKind::NotFound =>
1247                            {
1248                                debug!(
1249                                    "Ignoring missing workspace member: `{}`",
1250                                    member_root.simplified_display()
1251                                );
1252                                continue;
1253                            }
1254                            Err(err) => return Err(err.into()),
1255                        };
1256                        if !metadata.is_dir() {
1257                            warn!(
1258                                "Ignoring non-directory workspace member: `{}`",
1259                                member_root.simplified_display()
1260                            );
1261                            continue;
1262                        }
1263
1264                        // A directory exists, but it doesn't contain a `pyproject.toml`.
1265                        if err.kind() == std::io::ErrorKind::NotFound {
1266                            // If the directory is hidden, skip it.
1267                            if member_root
1268                                .file_name()
1269                                .is_some_and(|name| name.as_encoded_bytes().starts_with(b"."))
1270                            {
1271                                debug!(
1272                                    "Ignoring hidden workspace member: `{}`",
1273                                    member_root.simplified_display()
1274                                );
1275                                continue;
1276                            }
1277
1278                            // If the directory only contains gitignored files
1279                            // (e.g., `__pycache__`), skip it.
1280                            if has_only_gitignored_files(&member_root) {
1281                                debug!(
1282                                    "Ignoring workspace member with only gitignored files: `{}`",
1283                                    member_root.simplified_display()
1284                                );
1285                                continue;
1286                            }
1287
1288                            if matches!(options.members, MemberDiscovery::Existing) {
1289                                debug!(
1290                                    "Ignoring missing workspace member: `{}`",
1291                                    member_root.simplified_display()
1292                                );
1293                                continue;
1294                            }
1295
1296                            return Err(WorkspaceError::from(
1297                                WorkspaceErrorKind::MissingPyprojectTomlMember(
1298                                    member_root,
1299                                    member_glob.to_string(),
1300                                ),
1301                            ));
1302                        }
1303
1304                        return Err(err.into());
1305                    }
1306                };
1307                let pyproject_toml = PyProjectToml::from_string(contents, &pyproject_path)
1308                    .map_err(|err| {
1309                        WorkspaceErrorKind::Toml(pyproject_path.clone(), Box::new(err))
1310                    })?;
1311
1312                // Check if the current project is explicitly marked as unmanaged.
1313                if pyproject_toml
1314                    .tool
1315                    .as_ref()
1316                    .and_then(|tool| tool.uv.as_ref())
1317                    .and_then(|uv| uv.managed)
1318                    == Some(false)
1319                {
1320                    if let Some(project) = pyproject_toml.project.as_ref() {
1321                        debug!(
1322                            "Project `{}` is marked as unmanaged; omitting from workspace members",
1323                            project.name
1324                        );
1325                    } else {
1326                        debug!(
1327                            "Workspace member at `{}` is marked as unmanaged; omitting from workspace members",
1328                            member_root.simplified_display()
1329                        );
1330                    }
1331                    continue;
1332                }
1333
1334                // Extract the package name.
1335                let Some(project) = pyproject_toml.project.clone() else {
1336                    return Err(WorkspaceError::from(WorkspaceErrorKind::MissingProject(
1337                        pyproject_path,
1338                    )));
1339                };
1340
1341                debug!(
1342                    "Adding discovered workspace member: `{}`",
1343                    member_root.simplified_display()
1344                );
1345
1346                if let Some(existing) = workspace_members.insert(
1347                    project.name.clone(),
1348                    WorkspaceMember {
1349                        root: member_root.clone(),
1350                        project,
1351                        pyproject_toml,
1352                    },
1353                ) {
1354                    return Err(WorkspaceError::from(WorkspaceErrorKind::DuplicatePackage {
1355                        name: existing.project.name,
1356                        first: existing.root.clone(),
1357                        second: member_root,
1358                    }));
1359                }
1360            }
1361        }
1362
1363        // Test for nested workspaces.
1364        for member in workspace_members.values() {
1365            if member.root() != workspace_root
1366                && member
1367                    .pyproject_toml
1368                    .tool
1369                    .as_ref()
1370                    .and_then(|tool| tool.uv.as_ref())
1371                    .and_then(|uv| uv.workspace.as_ref())
1372                    .is_some()
1373            {
1374                return Err(WorkspaceError::from(WorkspaceErrorKind::NestedWorkspace(
1375                    member.root.clone(),
1376                )));
1377            }
1378        }
1379        Ok(workspace_members)
1380    }
1381}
1382
1383/// A project in a workspace.
1384#[derive(Debug, Clone, PartialEq)]
1385#[cfg_attr(test, derive(serde::Serialize))]
1386pub struct WorkspaceMember {
1387    /// The path to the project root.
1388    root: PathBuf,
1389    /// The `[project]` table, from the `pyproject.toml` of the project found at
1390    /// `<root>/pyproject.toml`.
1391    project: Project,
1392    /// The `pyproject.toml` of the project, found at `<root>/pyproject.toml`.
1393    pyproject_toml: PyProjectToml,
1394}
1395
1396impl WorkspaceMember {
1397    /// The path to the project root.
1398    pub fn root(&self) -> &PathBuf {
1399        &self.root
1400    }
1401
1402    /// The `[project]` table, from the `pyproject.toml` of the project found at
1403    /// `<root>/pyproject.toml`.
1404    pub fn project(&self) -> &Project {
1405        &self.project
1406    }
1407
1408    /// The `pyproject.toml` of the project, found at `<root>/pyproject.toml`.
1409    pub fn pyproject_toml(&self) -> &PyProjectToml {
1410        &self.pyproject_toml
1411    }
1412}
1413
1414/// The current project and the workspace it is part of, with all of the workspace members.
1415///
1416/// # Structure
1417///
1418/// The workspace root is a directory with a `pyproject.toml`, all members need to be below that
1419/// directory. The workspace root defines members and exclusions. All packages below it must either
1420/// be a member or excluded. The workspace root can be a package itself or a virtual manifest.
1421///
1422/// For a simple single package project, the workspace root is implicitly the current project root
1423/// and the workspace has only this single member. Otherwise, a workspace root is declared through
1424/// a `tool.uv.workspace` section.
1425///
1426/// A workspace itself does not declare dependencies, instead one member is the current project used
1427/// as main requirement.
1428///
1429/// Each member is a directory with a `pyproject.toml` that contains a `[project]` section. Each
1430/// member is a Python package, with a name, a version and dependencies. Workspace members can
1431/// depend on other workspace members (`foo = { workspace = true }`). You can consider the
1432/// workspace another package source or index, similar to `--find-links`.
1433///
1434/// # Usage
1435///
1436/// There a two main usage patterns: A root package and helpers, and the flat workspace.
1437///
1438/// Root package and helpers:
1439///
1440/// ```text
1441/// albatross
1442/// ├── packages
1443/// │   ├── provider_a
1444/// │   │   ├── pyproject.toml
1445/// │   │   └── src
1446/// │   │       └── provider_a
1447/// │   │           ├── __init__.py
1448/// │   │           └── foo.py
1449/// │   └── provider_b
1450/// │       ├── pyproject.toml
1451/// │       └── src
1452/// │           └── provider_b
1453/// │               ├── __init__.py
1454/// │               └── bar.py
1455/// ├── pyproject.toml
1456/// ├── Readme.md
1457/// ├── uv.lock
1458/// └── src
1459///     └── albatross
1460///         ├── __init__.py
1461///         └── main.py
1462/// ```
1463///
1464/// Flat workspace:
1465///
1466/// ```text
1467/// albatross
1468/// ├── packages
1469/// │   ├── albatross
1470/// │   │   ├── pyproject.toml
1471/// │   │   └── src
1472/// │   │       └── albatross
1473/// │   │           ├── __init__.py
1474/// │   │           └── main.py
1475/// │   ├── provider_a
1476/// │   │   ├── pyproject.toml
1477/// │   │   └── src
1478/// │   │       └── provider_a
1479/// │   │           ├── __init__.py
1480/// │   │           └── foo.py
1481/// │   └── provider_b
1482/// │       ├── pyproject.toml
1483/// │       └── src
1484/// │           └── provider_b
1485/// │               ├── __init__.py
1486/// │               └── bar.py
1487/// ├── pyproject.toml
1488/// ├── Readme.md
1489/// └── uv.lock
1490/// ```
1491#[derive(Debug, Clone)]
1492#[cfg_attr(test, derive(serde::Serialize))]
1493pub struct ProjectWorkspace {
1494    /// The path to the project root.
1495    project_root: PathBuf,
1496    /// The name of the package.
1497    project_name: PackageName,
1498    /// The workspace the project is part of.
1499    workspace: Arc<Workspace>,
1500}
1501
1502impl ProjectWorkspace {
1503    fn from_cache(
1504        project_root: &Path,
1505        options: &DiscoveryOptions,
1506        cache: &WorkspaceCache,
1507    ) -> Result<Option<Self>, WorkspaceError> {
1508        let workspace = match cache.get(project_root, &options.members) {
1509            Some(Ok(workspace)) => workspace,
1510            Some(Err(error)) => return Err(error),
1511            None => return Ok(None),
1512        };
1513        let Some((project_name, _member)) = workspace
1514            .packages
1515            .iter()
1516            .find(|(_project_name, member)| member.root() == project_root)
1517        else {
1518            return Ok(None);
1519        };
1520
1521        Ok(Some(Self {
1522            project_root: project_root.to_path_buf(),
1523            project_name: project_name.clone(),
1524            workspace,
1525        }))
1526    }
1527
1528    /// Find the current project and workspace, given the current directory.
1529    ///
1530    /// `stop_discovery_at` must be either `None` or an ancestor of the current directory. If set,
1531    /// only directories between the current path and `stop_discovery_at` are considered.
1532    pub async fn discover(
1533        path: &Path,
1534        options: &DiscoveryOptions,
1535        cache: &Cache,
1536        workspace_cache: &WorkspaceCache,
1537    ) -> Result<Self, WorkspaceError> {
1538        assert!(
1539            path.is_absolute(),
1540            "project workspace discovery with relative path"
1541        );
1542        let project_root = path
1543            .ancestors()
1544            .take_while(|path| {
1545                // Only walk up the given directory, if any.
1546                options
1547                    .stop_discovery_at
1548                    .as_deref()
1549                    .and_then(Path::parent)
1550                    .is_none_or(|stop_discovery_at| stop_discovery_at != *path)
1551            })
1552            .find(|path| path.join("pyproject.toml").is_file())
1553            .ok_or_else(|| WorkspaceErrorKind::MissingPyprojectToml)?;
1554
1555        debug!(
1556            "Found project root: `{}`",
1557            project_root.simplified_display()
1558        );
1559
1560        Self::from_project_root(project_root, options, cache, workspace_cache).await
1561    }
1562
1563    /// Discover the workspace starting from the directory containing the `pyproject.toml`.
1564    async fn from_project_root(
1565        project_root: &Path,
1566        options: &DiscoveryOptions,
1567        cache: &Cache,
1568        workspace_cache: &WorkspaceCache,
1569    ) -> Result<Self, WorkspaceError> {
1570        if let Some(project) = Self::from_cache(project_root, options, workspace_cache)? {
1571            return Ok(project);
1572        }
1573
1574        // Read the current `pyproject.toml`.
1575        let pyproject_path = project_root.join("pyproject.toml");
1576
1577        let contents = fs_err::tokio::read_to_string(&pyproject_path).await?;
1578        let pyproject_toml = PyProjectToml::from_string(contents, &pyproject_path)
1579            .map_err(|err| WorkspaceErrorKind::Toml(pyproject_path.clone(), Box::new(err)))?;
1580
1581        // It must have a `[project]` table.
1582        let project = pyproject_toml
1583            .project
1584            .clone()
1585            .ok_or_else(|| WorkspaceErrorKind::MissingProject(pyproject_path))?;
1586
1587        Self::from_project(
1588            project_root,
1589            &project,
1590            &pyproject_toml,
1591            options,
1592            cache,
1593            workspace_cache,
1594        )
1595        .await
1596    }
1597
1598    /// If the current directory contains a `pyproject.toml` with a `project` table, discover the
1599    /// workspace and return it, otherwise it is a dynamic path dependency and we return `Ok(None)`.
1600    pub async fn from_maybe_project_root(
1601        project_root: &Path,
1602        options: &DiscoveryOptions,
1603        cache: &Cache,
1604        workspace_cache: &WorkspaceCache,
1605    ) -> Result<Option<Self>, WorkspaceError> {
1606        if let Some(project) = Self::from_cache(project_root, options, workspace_cache)? {
1607            return Ok(Some(project));
1608        }
1609
1610        // Read the `pyproject.toml`.
1611        let pyproject_path = project_root.join("pyproject.toml");
1612        let Ok(contents) = fs_err::tokio::read_to_string(&pyproject_path).await else {
1613            // No `pyproject.toml`, but there may still be a `setup.py` or `setup.cfg`.
1614            return Ok(None);
1615        };
1616        let pyproject_toml = PyProjectToml::from_string(contents, &pyproject_path)
1617            .map_err(|err| WorkspaceErrorKind::Toml(pyproject_path.clone(), Box::new(err)))?;
1618
1619        // Extract the `[project]` metadata.
1620        let Some(project) = pyproject_toml.project.clone() else {
1621            // We have to build to get the metadata.
1622            return Ok(None);
1623        };
1624
1625        match Self::from_project(
1626            project_root,
1627            &project,
1628            &pyproject_toml,
1629            options,
1630            cache,
1631            workspace_cache,
1632        )
1633        .await
1634        {
1635            Ok(workspace) => Ok(Some(workspace)),
1636            Err(error) if matches!(error.as_ref(), WorkspaceErrorKind::NonWorkspace(_)) => Ok(None),
1637            Err(err) => Err(err),
1638        }
1639    }
1640
1641    /// Returns the directory containing the closest `pyproject.toml` that defines the current
1642    /// project.
1643    pub fn project_root(&self) -> &Path {
1644        &self.project_root
1645    }
1646
1647    /// Returns the [`PackageName`] of the current project.
1648    pub fn project_name(&self) -> &PackageName {
1649        &self.project_name
1650    }
1651
1652    /// Returns the [`Workspace`] containing the current project.
1653    pub fn workspace(&self) -> &Workspace {
1654        &self.workspace
1655    }
1656
1657    /// Returns the current project as a [`WorkspaceMember`].
1658    pub fn current_project(&self) -> &WorkspaceMember {
1659        &self.workspace().packages[&self.project_name]
1660    }
1661
1662    /// Set the `pyproject.toml` for the current project.
1663    ///
1664    /// Assumes that the project name is unchanged in the updated [`PyProjectToml`].
1665    fn update_member(self, pyproject_toml: PyProjectToml) -> Result<Option<Self>, WorkspaceError> {
1666        let Some(workspace) =
1667            Workspace::update_member(self.workspace, &self.project_name, pyproject_toml)?
1668        else {
1669            return Ok(None);
1670        };
1671        Ok(Some(Self { workspace, ..self }))
1672    }
1673
1674    /// Find the workspace for a project.
1675    async fn from_project(
1676        install_path: &Path,
1677        project: &Project,
1678        project_pyproject_toml: &PyProjectToml,
1679        options: &DiscoveryOptions,
1680        cache: &Cache,
1681        workspace_cache: &WorkspaceCache,
1682    ) -> Result<Self, WorkspaceError> {
1683        let project_path = std::path::absolute(install_path)
1684            .map_err(WorkspaceErrorKind::Normalize)?
1685            .clone();
1686        let project_path = normalize_path(&project_path);
1687
1688        // Check if workspaces are explicitly disabled for the project.
1689        if project_pyproject_toml
1690            .tool
1691            .as_ref()
1692            .and_then(|tool| tool.uv.as_ref())
1693            .and_then(|uv| uv.managed)
1694            == Some(false)
1695        {
1696            debug!("Project `{}` is marked as unmanaged", project.name);
1697            return Err(WorkspaceError::from(WorkspaceErrorKind::NonWorkspace(
1698                project_path.to_path_buf(),
1699            )));
1700        }
1701
1702        if let Some(project) = Self::from_cache(&project_path, options, workspace_cache)? {
1703            return Ok(project);
1704        }
1705
1706        // Check if the current project is also an explicit workspace root.
1707        let mut workspace = project_pyproject_toml
1708            .tool
1709            .as_ref()
1710            .and_then(|tool| tool.uv.as_ref())
1711            .and_then(|uv| uv.workspace.as_ref())
1712            .map(|workspace| {
1713                (
1714                    project_path.to_path_buf(),
1715                    workspace.clone(),
1716                    project_pyproject_toml.clone(),
1717                )
1718            });
1719
1720        if workspace.is_none() {
1721            // The project isn't an explicit workspace root, check if we're a regular workspace
1722            // member by looking for an explicit workspace root above.
1723            workspace = find_workspace(&project_path, options, cache).await?;
1724        }
1725
1726        let current_project = WorkspaceMember {
1727            root: project_path.to_path_buf(),
1728            project: project.clone(),
1729            pyproject_toml: project_pyproject_toml.clone(),
1730        };
1731
1732        let Some((workspace_root, workspace_definition, workspace_pyproject_toml)) = workspace
1733        else {
1734            // The project isn't an explicit workspace root, but there's also no workspace root
1735            // above it, so the project is an implicit workspace root identical to the project root.
1736            debug!("No workspace root found, using project root");
1737
1738            let current_project_as_members = Arc::new(BTreeMap::from_iter([(
1739                project.name.clone(),
1740                current_project,
1741            )]));
1742            let workspace_sources = BTreeMap::default();
1743            let required_members = Workspace::collect_required_members(
1744                &current_project_as_members,
1745                &workspace_sources,
1746                project_pyproject_toml,
1747            )?;
1748
1749            let workspace = Workspace {
1750                install_path: project_path.to_path_buf(),
1751                packages: current_project_as_members,
1752                required_members,
1753                // There may be package sources, but we don't need to duplicate them into the
1754                // workspace sources.
1755                sources: workspace_sources,
1756                indexes: Vec::default(),
1757                pyproject_toml: project_pyproject_toml.clone(),
1758            };
1759            let workspace = Arc::new(workspace);
1760            if options.members == MemberDiscovery::All {
1761                workspace_cache.insert(Ok(workspace.clone()), &project_path);
1762            }
1763            return Ok(Self {
1764                project_root: project_path.to_path_buf(),
1765                project_name: project.name.clone(),
1766                workspace,
1767            });
1768        };
1769
1770        if options.members == MemberDiscovery::All {
1771            // Ensure that workspace discovery runs only once for any given workspace root.
1772            if let Some(workspace) = workspace_cache.register_or_wait(&workspace_root).await {
1773                return workspace.map(|workspace| Self {
1774                    project_root: project_path.to_path_buf(),
1775                    project_name: project.name.clone(),
1776                    workspace,
1777                });
1778            }
1779        }
1780
1781        debug!(
1782            "Found workspace root: `{}`",
1783            workspace_root.simplified_display()
1784        );
1785
1786        let result = Workspace::build(
1787            workspace_root.clone(),
1788            workspace_definition,
1789            workspace_pyproject_toml,
1790            Some(current_project),
1791            options,
1792            cache,
1793        )
1794        .await;
1795        if options.members == MemberDiscovery::All {
1796            workspace_cache.insert(result.clone(), &workspace_root);
1797        }
1798
1799        Ok(Self {
1800            project_root: project_path.to_path_buf(),
1801            project_name: project.name.clone(),
1802            workspace: result?,
1803        })
1804    }
1805}
1806
1807/// Find the workspace root above the current project, if any.
1808async fn find_workspace(
1809    project_root: &Path,
1810    options: &DiscoveryOptions,
1811    cache: &Cache,
1812) -> Result<Option<(PathBuf, ToolUvWorkspace, PyProjectToml)>, WorkspaceError> {
1813    let external_cache_root = if options.stop_discovery_at.is_none() {
1814        // We may receive an uninitialized cache with a relative cache root.
1815        let cache_root = if cache.root().is_absolute() {
1816            cache.root().to_path_buf()
1817        } else {
1818            CWD.join(cache.root())
1819        };
1820        Some(normalize_path(&cache_root).into_owned())
1821    } else {
1822        None
1823    };
1824    // Avoid panicking in the odd (unsupported) case that uv is running inside the cache dir.
1825    if let Some(cache_root) = external_cache_root
1826        && project_root.starts_with(cache_root)
1827    {
1828        debug!(
1829            "Project is contained in cache directory: `{}`",
1830            project_root.simplified_display()
1831        );
1832        return Ok(None);
1833    }
1834
1835    // Skip 1 to ignore the current project itself.
1836    for workspace_root in project_root
1837        .ancestors()
1838        .take_while(|path| {
1839            // Only walk up the given directory, if any.
1840            options
1841                .stop_discovery_at
1842                .as_deref()
1843                .and_then(Path::parent)
1844                .is_none_or(|stop_discovery_at| stop_discovery_at != *path)
1845        })
1846        .skip(1)
1847    {
1848        let pyproject_path = workspace_root.join("pyproject.toml");
1849        if !pyproject_path.is_file() {
1850            continue;
1851        }
1852        trace!(
1853            "Found `pyproject.toml` at: `{}`",
1854            pyproject_path.simplified_display()
1855        );
1856
1857        // Read the `pyproject.toml`.
1858        let contents = fs_err::tokio::read_to_string(&pyproject_path).await?;
1859        let pyproject_toml = PyProjectToml::from_string(contents, &pyproject_path)
1860            .map_err(|err| WorkspaceErrorKind::Toml(pyproject_path.clone(), Box::new(err)))?;
1861
1862        return if let Some(workspace) = pyproject_toml
1863            .tool
1864            .as_ref()
1865            .and_then(|tool| tool.uv.as_ref())
1866            .and_then(|uv| uv.workspace.as_ref())
1867        {
1868            if !is_included_in_workspace(project_root, workspace_root, workspace)? {
1869                debug!(
1870                    "Found workspace root `{}`, but project is not included",
1871                    workspace_root.simplified_display()
1872                );
1873                return Ok(None);
1874            }
1875
1876            if is_excluded_from_workspace(project_root, workspace_root, workspace)? {
1877                debug!(
1878                    "Found workspace root `{}`, but project is excluded",
1879                    workspace_root.simplified_display()
1880                );
1881                return Ok(None);
1882            }
1883
1884            // We found a workspace root.
1885            Ok(Some((
1886                workspace_root.to_path_buf(),
1887                workspace.clone(),
1888                pyproject_toml,
1889            )))
1890        } else if pyproject_toml.project.is_some() {
1891            // We're in a directory of another project, e.g. tests or examples.
1892            // Example:
1893            // ```
1894            // albatross
1895            // ├── examples
1896            // │   └── bird-feeder [CURRENT DIRECTORY]
1897            // │       ├── pyproject.toml
1898            // │       └── src
1899            // │           └── bird_feeder
1900            // │               └── __init__.py
1901            // ├── pyproject.toml
1902            // └── src
1903            //     └── albatross
1904            //         └── __init__.py
1905            // ```
1906            // The current project is the example (non-workspace) `bird-feeder` in `albatross`,
1907            // we ignore all `albatross` is doing and any potential workspace it might be
1908            // contained in.
1909            debug!(
1910                "Project is contained in non-workspace project: `{}`",
1911                workspace_root.simplified_display()
1912            );
1913            Ok(None)
1914        } else {
1915            // We require that a `project.toml` file either declares a workspace or a project.
1916            warn!(
1917                "`pyproject.toml` does not contain a `project` table: `{}`",
1918                pyproject_path.simplified_display()
1919            );
1920            Ok(None)
1921        };
1922    }
1923
1924    Ok(None)
1925}
1926
1927/// Check if a directory only contains files that are ignored.
1928///
1929/// Returns `true` if walking the directory while respecting `.gitignore` and `.ignore` rules
1930/// yields no files, indicating that any files present (e.g., `__pycache__`) are all ignored.
1931fn has_only_gitignored_files(path: &Path) -> bool {
1932    let walker = ignore::WalkBuilder::new(path)
1933        .hidden(false)
1934        .parents(true)
1935        .ignore(true)
1936        .git_ignore(true)
1937        .git_global(true)
1938        .git_exclude(true)
1939        .build();
1940
1941    for entry in walker {
1942        let Ok(entry) = entry else {
1943            // If we can't read an entry, assume non-ignored content exists.
1944            return false;
1945        };
1946
1947        // Skip directories.
1948        if entry.path().is_dir() {
1949            continue;
1950        }
1951
1952        // A non-ignored entry exists.
1953        return false;
1954    }
1955
1956    true
1957}
1958
1959/// Check if we're in the `tool.uv.workspace.excluded` of a workspace.
1960fn is_excluded_from_workspace(
1961    project_path: &Path,
1962    workspace_root: &Path,
1963    workspace: &ToolUvWorkspace,
1964) -> Result<bool, WorkspaceError> {
1965    Ok(WorkspaceExclusions::new(workspace_root, workspace)?.matches(project_path))
1966}
1967
1968/// Compiled workspace exclusion patterns.
1969#[derive(Debug)]
1970struct WorkspaceExclusions<'workspace> {
1971    workspace_root: &'workspace Path,
1972    patterns: Vec<WorkspaceExclusion<'workspace>>,
1973}
1974
1975/// A workspace exclusion that can reuse its parsed pattern or requires normalization.
1976#[derive(Debug)]
1977enum WorkspaceExclusion<'workspace> {
1978    Relative(&'workspace Pattern),
1979    Absolute(Pattern),
1980}
1981
1982impl<'workspace> WorkspaceExclusions<'workspace> {
1983    /// Compile the normalized workspace exclusion patterns.
1984    fn new(
1985        workspace_root: &'workspace Path,
1986        workspace: &'workspace ToolUvWorkspace,
1987    ) -> Result<Self, WorkspaceError> {
1988        let patterns = workspace
1989            .exclude
1990            .iter()
1991            .flatten()
1992            .map(|exclude_glob| Self::compile_pattern(workspace_root, exclude_glob))
1993            .collect::<Result<_, _>>()?;
1994
1995        Ok(Self {
1996            workspace_root,
1997            patterns,
1998        })
1999    }
2000
2001    /// Return whether any workspace exclusion matches the project path.
2002    fn matches(&self, project_path: &Path) -> bool {
2003        let relative_path = project_path
2004            .simplified()
2005            .strip_prefix(self.workspace_root.simplified())
2006            .ok();
2007
2008        self.patterns.iter().any(|pattern| match pattern {
2009            WorkspaceExclusion::Relative(pattern) => {
2010                relative_path.is_some_and(|relative_path| pattern.matches_path(relative_path))
2011            }
2012            WorkspaceExclusion::Absolute(pattern) => pattern.matches_path(project_path),
2013        })
2014    }
2015
2016    /// Reuse an already compiled relative pattern or compile its normalized absolute equivalent.
2017    fn compile_pattern(
2018        workspace_root: &Path,
2019        exclude_glob: &'workspace Pattern,
2020    ) -> Result<WorkspaceExclusion<'workspace>, WorkspaceError> {
2021        // Normalize the exclude glob to remove leading `./` and other relative path components.
2022        let normalized_glob = normalize_path(Path::new(exclude_glob.as_str()));
2023        if matches!(&normalized_glob, Cow::Borrowed(_)) && normalized_glob.is_relative() {
2024            return Ok(WorkspaceExclusion::Relative(exclude_glob));
2025        }
2026
2027        let absolute_glob = PathBuf::from(Pattern::escape(
2028            workspace_root.simplified().to_string_lossy().as_ref(),
2029        ))
2030        .join(normalized_glob.as_ref());
2031        let absolute_glob = absolute_glob.to_string_lossy();
2032        Pattern::new(&absolute_glob)
2033            .map(WorkspaceExclusion::Absolute)
2034            .map_err(|err| WorkspaceErrorKind::Pattern(absolute_glob.to_string(), err).into())
2035    }
2036}
2037
2038/// Check if we're in the `tool.uv.workspace.members` of a workspace.
2039fn is_included_in_workspace(
2040    project_path: &Path,
2041    workspace_root: &Path,
2042    workspace: &ToolUvWorkspace,
2043) -> Result<bool, WorkspaceError> {
2044    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}