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