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