Skip to main content

uv_workspace/
workspace.rs

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