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, 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 for member_glob in workspace.members.iter().flatten() {
2045 let normalized_glob = normalize_path(Path::new(member_glob.as_str()));
2047 let absolute_glob = PathBuf::from(glob::Pattern::escape(
2048 workspace_root.simplified().to_string_lossy().as_ref(),
2049 ))
2050 .join(normalized_glob);
2051 let absolute_glob = absolute_glob.to_string_lossy();
2052 let include_pattern = glob::Pattern::new(&absolute_glob)
2053 .map_err(|err| WorkspaceErrorKind::Pattern(absolute_glob.to_string(), err))?;
2054 if include_pattern.matches_path(project_path) {
2055 return Ok(true);
2056 }
2057 }
2058 Ok(false)
2059}
2060
2061#[derive(Debug, Clone)]
2066pub enum VirtualProject {
2067 Project(ProjectWorkspace),
2069 NonProject(Arc<Workspace>),
2071}
2072
2073impl VirtualProject {
2074 pub async fn discover(
2082 path: &Path,
2083 options: &DiscoveryOptions,
2084 cache: &Cache,
2085 workspace_cache: &WorkspaceCache,
2086 ) -> Result<Self, WorkspaceError> {
2087 assert!(
2088 path.is_absolute(),
2089 "virtual project discovery with relative path"
2090 );
2091 let project_root = path
2092 .ancestors()
2093 .take_while(|path| {
2094 options
2096 .stop_discovery_at
2097 .as_deref()
2098 .and_then(Path::parent)
2099 .is_none_or(|stop_discovery_at| stop_discovery_at != *path)
2100 })
2101 .find(|path| path.join("pyproject.toml").is_file())
2102 .ok_or(WorkspaceErrorKind::MissingPyprojectToml)?;
2103
2104 debug!(
2105 "Found project root: `{}`",
2106 project_root.simplified_display()
2107 );
2108
2109 if let Some(workspace) = workspace_cache.get(project_root, &options.members) {
2111 let workspace = workspace?;
2112 let virtual_project = if let Some((project_name, _member)) = workspace
2113 .packages
2114 .iter()
2115 .find(|(_package_name, member)| member.root == project_root)
2116 {
2117 Self::Project(ProjectWorkspace {
2118 project_root: project_root.to_path_buf(),
2119 project_name: project_name.clone(),
2120 workspace,
2121 })
2122 } else {
2123 Self::NonProject(workspace.clone())
2124 };
2125 return Ok(virtual_project);
2126 }
2127
2128 let pyproject_path = project_root.join("pyproject.toml");
2130 let contents = fs_err::tokio::read_to_string(&pyproject_path).await?;
2131 let pyproject_toml = PyProjectToml::from_string(contents, &pyproject_path)
2132 .map_err(|err| WorkspaceErrorKind::Toml(pyproject_path.clone(), Box::new(err)))?;
2133
2134 if let Some(project) = pyproject_toml.project.as_ref() {
2135 let project = ProjectWorkspace::from_project(
2137 project_root,
2138 project,
2139 &pyproject_toml,
2140 options,
2141 cache,
2142 workspace_cache,
2143 )
2144 .await?;
2145 Ok(Self::Project(project))
2146 } else if let Some(workspace) = pyproject_toml
2147 .tool
2148 .as_ref()
2149 .and_then(|tool| tool.uv.as_ref())
2150 .and_then(|uv| uv.workspace.as_ref())
2151 {
2152 let project_path = std::path::absolute(project_root)
2155 .map_err(WorkspaceErrorKind::Normalize)?
2156 .clone();
2157
2158 let result = Workspace::build(
2159 project_path.clone(),
2160 workspace.clone(),
2161 pyproject_toml,
2162 None,
2163 options,
2164 cache,
2165 )
2166 .await;
2167 if options.members == MemberDiscovery::All {
2168 workspace_cache.insert(result.clone(), &project_path);
2169 }
2170 Ok(Self::NonProject(result?))
2171 } else {
2172 let project_path = std::path::absolute(project_root)
2175 .map_err(WorkspaceErrorKind::Normalize)?
2176 .clone();
2177
2178 let result = Workspace::build(
2179 project_path.clone(),
2180 ToolUvWorkspace::default(),
2181 pyproject_toml,
2182 None,
2183 options,
2184 cache,
2185 )
2186 .await;
2187 if options.members == MemberDiscovery::All {
2188 workspace_cache.insert(result.clone(), &project_path);
2189 }
2190 Ok(Self::NonProject(result?))
2191 }
2192 }
2193
2194 pub async fn discover_with_package(
2196 path: &Path,
2197 options: &DiscoveryOptions,
2198 cache: &Cache,
2199 workspace_cache: &WorkspaceCache,
2200 package: PackageName,
2201 ) -> Result<Self, WorkspaceError> {
2202 let workspace = Workspace::discover(path, options, cache, workspace_cache).await?;
2203 let Some(project_workspace) =
2204 Workspace::with_current_project(workspace.clone(), package.clone())
2205 else {
2206 return Err(WorkspaceError::from(WorkspaceErrorKind::NoSuchMember(
2207 package,
2208 workspace.install_path.clone(),
2209 )));
2210 };
2211 Ok(Self::Project(project_workspace))
2212 }
2213
2214 pub fn update_member(
2223 self,
2224 pyproject_toml: PyProjectToml,
2225 workspace_cache: &WorkspaceCache,
2226 ) -> Result<Option<Self>, WorkspaceError> {
2227 workspace_cache.invalidate_workspace(self.workspace());
2229 Ok(match self {
2230 Self::Project(project) => {
2231 let Some(project) = project.update_member(pyproject_toml)? else {
2232 return Ok(None);
2233 };
2234 Some(Self::Project(project))
2235 }
2236 Self::NonProject(workspace) => {
2237 debug_assert_eq!(
2238 Arc::strong_count(&workspace),
2239 1,
2240 "cannot modify workspace still in use",
2241 );
2242
2243 let workspace = Arc::unwrap_or_clone(workspace);
2244 let workspace = Workspace {
2247 pyproject_toml,
2248 ..workspace
2249 };
2250 Some(Self::NonProject(Arc::new(workspace)))
2251 }
2252 })
2253 }
2254
2255 #[must_use]
2260 pub fn clone_detach(&self) -> Self {
2261 match self {
2262 Self::Project(project) => Self::Project(ProjectWorkspace {
2263 project_root: project.project_root.clone(),
2264 project_name: project.project_name.clone(),
2265 workspace: Arc::new((*project.workspace).clone()),
2266 }),
2267 Self::NonProject(workspace) => Self::NonProject(Arc::new((**workspace).clone())),
2268 }
2269 }
2270
2271 pub fn root(&self) -> &Path {
2273 match self {
2274 Self::Project(project) => project.project_root(),
2275 Self::NonProject(workspace) => workspace.install_path(),
2276 }
2277 }
2278
2279 pub fn pyproject_toml(&self) -> &PyProjectToml {
2281 match self {
2282 Self::Project(project) => project.current_project().pyproject_toml(),
2283 Self::NonProject(workspace) => &workspace.pyproject_toml,
2284 }
2285 }
2286
2287 pub fn workspace(&self) -> &Workspace {
2289 match self {
2290 Self::Project(project) => project.workspace(),
2291 Self::NonProject(workspace) => workspace,
2292 }
2293 }
2294
2295 pub fn project_name(&self) -> Option<&PackageName> {
2297 match self {
2298 Self::Project(project) => Some(project.project_name()),
2299 Self::NonProject(_) => None,
2300 }
2301 }
2302
2303 pub fn is_non_project(&self) -> bool {
2305 matches!(self, Self::NonProject(_))
2306 }
2307}
2308
2309#[cfg(test)]
2310#[cfg(unix)] mod tests {
2312 use std::collections::BTreeMap;
2313 use std::env;
2314 use std::path::Path;
2315 use std::str::FromStr;
2316 use std::sync::Arc;
2317
2318 use anyhow::Result;
2319 use assert_fs::fixture::ChildPath;
2320 use assert_fs::prelude::*;
2321 use insta::{assert_json_snapshot, assert_snapshot};
2322
2323 use uv_cache::Cache;
2324 use uv_normalize::{GroupName, PackageName};
2325 use uv_pypi_types::DependencyGroupSpecifier;
2326
2327 use crate::pyproject::PyProjectToml;
2328 use crate::workspace::{DiscoveryOptions, MemberDiscovery, ProjectWorkspace, Workspace};
2329 use crate::{WorkspaceCache, WorkspaceError};
2330
2331 async fn workspace_test(folder: &str) -> (ProjectWorkspace, String) {
2332 let root_dir = env::current_dir()
2333 .unwrap()
2334 .parent()
2335 .unwrap()
2336 .parent()
2337 .unwrap()
2338 .join("test")
2339 .join("workspaces");
2340 let cache = Cache::from_path(root_dir.join(".uv_cache"));
2341 let project = ProjectWorkspace::discover(
2342 &root_dir.join(folder),
2343 &DiscoveryOptions::default(),
2344 &cache,
2345 &WorkspaceCache::default(),
2346 )
2347 .await
2348 .unwrap();
2349 let root_escaped = regex::escape(root_dir.to_string_lossy().as_ref());
2350 (project, root_escaped)
2351 }
2352
2353 async fn temporary_test(
2354 folder: &Path,
2355 ) -> Result<(ProjectWorkspace, String), (WorkspaceError, String)> {
2356 let root_escaped = regex::escape(folder.to_string_lossy().as_ref());
2357 let cache = Cache::from_path(env::temp_dir().join("uv-workspace-cache"));
2358 let project = ProjectWorkspace::discover(
2359 folder,
2360 &DiscoveryOptions::default(),
2361 &cache,
2362 &WorkspaceCache::default(),
2363 )
2364 .await
2365 .map_err(|error| (error, root_escaped.clone()))?;
2366
2367 Ok((project, root_escaped))
2368 }
2369
2370 #[tokio::test]
2371 async fn albatross_in_example() {
2372 let (project, root_escaped) =
2373 workspace_test("albatross-in-example/examples/bird-feeder").await;
2374 let filters = vec![(root_escaped.as_str(), "[ROOT]")];
2375 insta::with_settings!({filters => filters}, {
2376 assert_json_snapshot!(
2377 project,
2378 {
2379 ".workspace.packages.*.pyproject_toml" => "[PYPROJECT_TOML]"
2380 },
2381 @r#"
2382 {
2383 "project_root": "[ROOT]/albatross-in-example/examples/bird-feeder",
2384 "project_name": "bird-feeder",
2385 "workspace": {
2386 "install_path": "[ROOT]/albatross-in-example/examples/bird-feeder",
2387 "packages": {
2388 "bird-feeder": {
2389 "root": "[ROOT]/albatross-in-example/examples/bird-feeder",
2390 "project": {
2391 "name": "bird-feeder",
2392 "version": "1.0.0",
2393 "requires-python": ">=3.12",
2394 "dependencies": [
2395 "iniconfig>=2,<3"
2396 ],
2397 "optional-dependencies": null
2398 },
2399 "pyproject_toml": "[PYPROJECT_TOML]"
2400 }
2401 },
2402 "required_members": {},
2403 "sources": {},
2404 "indexes": [],
2405 "pyproject_toml": {
2406 "project": {
2407 "name": "bird-feeder",
2408 "version": "1.0.0",
2409 "requires-python": ">=3.12",
2410 "dependencies": [
2411 "iniconfig>=2,<3"
2412 ],
2413 "optional-dependencies": null
2414 },
2415 "tool": null,
2416 "dependency-groups": null
2417 }
2418 }
2419 }
2420 "#);
2421 });
2422 }
2423
2424 #[tokio::test]
2425 async fn albatross_project_in_excluded() {
2426 let (project, root_escaped) =
2427 workspace_test("albatross-project-in-excluded/excluded/bird-feeder").await;
2428 let filters = vec![(root_escaped.as_str(), "[ROOT]")];
2429 insta::with_settings!({filters => filters}, {
2430 assert_json_snapshot!(
2431 project,
2432 {
2433 ".workspace.packages.*.pyproject_toml" => "[PYPROJECT_TOML]"
2434 },
2435 @r#"
2436 {
2437 "project_root": "[ROOT]/albatross-project-in-excluded/excluded/bird-feeder",
2438 "project_name": "bird-feeder",
2439 "workspace": {
2440 "install_path": "[ROOT]/albatross-project-in-excluded/excluded/bird-feeder",
2441 "packages": {
2442 "bird-feeder": {
2443 "root": "[ROOT]/albatross-project-in-excluded/excluded/bird-feeder",
2444 "project": {
2445 "name": "bird-feeder",
2446 "version": "1.0.0",
2447 "requires-python": ">=3.12",
2448 "dependencies": [
2449 "iniconfig>=2,<3"
2450 ],
2451 "optional-dependencies": null
2452 },
2453 "pyproject_toml": "[PYPROJECT_TOML]"
2454 }
2455 },
2456 "required_members": {},
2457 "sources": {},
2458 "indexes": [],
2459 "pyproject_toml": {
2460 "project": {
2461 "name": "bird-feeder",
2462 "version": "1.0.0",
2463 "requires-python": ">=3.12",
2464 "dependencies": [
2465 "iniconfig>=2,<3"
2466 ],
2467 "optional-dependencies": null
2468 },
2469 "tool": null,
2470 "dependency-groups": null
2471 }
2472 }
2473 }
2474 "#);
2475 });
2476 }
2477
2478 #[tokio::test]
2479 async fn albatross_root_workspace() {
2480 let (project, root_escaped) = workspace_test("albatross-root-workspace").await;
2481 let filters = vec![(root_escaped.as_str(), "[ROOT]")];
2482 insta::with_settings!({filters => filters}, {
2483 assert_json_snapshot!(
2484 project,
2485 {
2486 ".workspace.packages.*.pyproject_toml" => "[PYPROJECT_TOML]"
2487 },
2488 @r#"
2489 {
2490 "project_root": "[ROOT]/albatross-root-workspace",
2491 "project_name": "albatross",
2492 "workspace": {
2493 "install_path": "[ROOT]/albatross-root-workspace",
2494 "packages": {
2495 "albatross": {
2496 "root": "[ROOT]/albatross-root-workspace",
2497 "project": {
2498 "name": "albatross",
2499 "version": "0.1.0",
2500 "requires-python": ">=3.12",
2501 "dependencies": [
2502 "bird-feeder",
2503 "iniconfig>=2,<3"
2504 ],
2505 "optional-dependencies": null
2506 },
2507 "pyproject_toml": "[PYPROJECT_TOML]"
2508 },
2509 "bird-feeder": {
2510 "root": "[ROOT]/albatross-root-workspace/packages/bird-feeder",
2511 "project": {
2512 "name": "bird-feeder",
2513 "version": "1.0.0",
2514 "requires-python": ">=3.8",
2515 "dependencies": [
2516 "iniconfig>=2,<3",
2517 "seeds"
2518 ],
2519 "optional-dependencies": null
2520 },
2521 "pyproject_toml": "[PYPROJECT_TOML]"
2522 },
2523 "seeds": {
2524 "root": "[ROOT]/albatross-root-workspace/packages/seeds",
2525 "project": {
2526 "name": "seeds",
2527 "version": "1.0.0",
2528 "requires-python": ">=3.12",
2529 "dependencies": [
2530 "idna==3.6"
2531 ],
2532 "optional-dependencies": null
2533 },
2534 "pyproject_toml": "[PYPROJECT_TOML]"
2535 }
2536 },
2537 "required_members": {
2538 "bird-feeder": null,
2539 "seeds": null
2540 },
2541 "sources": {
2542 "bird-feeder": [
2543 {
2544 "workspace": true,
2545 "editable": null,
2546 "extra": null,
2547 "group": null
2548 }
2549 ]
2550 },
2551 "indexes": [],
2552 "pyproject_toml": {
2553 "project": {
2554 "name": "albatross",
2555 "version": "0.1.0",
2556 "requires-python": ">=3.12",
2557 "dependencies": [
2558 "bird-feeder",
2559 "iniconfig>=2,<3"
2560 ],
2561 "optional-dependencies": null
2562 },
2563 "tool": {
2564 "uv": {
2565 "sources": {
2566 "bird-feeder": [
2567 {
2568 "workspace": true,
2569 "editable": null,
2570 "extra": null,
2571 "group": null
2572 }
2573 ]
2574 },
2575 "index": null,
2576 "workspace": {
2577 "members": [
2578 "packages/*"
2579 ],
2580 "exclude": null
2581 },
2582 "managed": null,
2583 "package": null,
2584 "default-groups": null,
2585 "dependency-groups": null,
2586 "dev-dependencies": null,
2587 "override-dependencies": null,
2588 "exclude-dependencies": null,
2589 "constraint-dependencies": null,
2590 "build-constraint-dependencies": null,
2591 "environments": null,
2592 "required-environments": null,
2593 "conflicts": null,
2594 "build-backend": null
2595 }
2596 },
2597 "dependency-groups": null
2598 }
2599 }
2600 }
2601 "#);
2602 });
2603 }
2604
2605 #[tokio::test]
2606 async fn albatross_virtual_workspace() {
2607 let (project, root_escaped) =
2608 workspace_test("albatross-virtual-workspace/packages/albatross").await;
2609 let filters = vec![(root_escaped.as_str(), "[ROOT]")];
2610 insta::with_settings!({filters => filters}, {
2611 assert_json_snapshot!(
2612 project,
2613 {
2614 ".workspace.packages.*.pyproject_toml" => "[PYPROJECT_TOML]"
2615 },
2616 @r#"
2617 {
2618 "project_root": "[ROOT]/albatross-virtual-workspace/packages/albatross",
2619 "project_name": "albatross",
2620 "workspace": {
2621 "install_path": "[ROOT]/albatross-virtual-workspace",
2622 "packages": {
2623 "albatross": {
2624 "root": "[ROOT]/albatross-virtual-workspace/packages/albatross",
2625 "project": {
2626 "name": "albatross",
2627 "version": "0.1.0",
2628 "requires-python": ">=3.12",
2629 "dependencies": [
2630 "bird-feeder",
2631 "iniconfig>=2,<3"
2632 ],
2633 "optional-dependencies": null
2634 },
2635 "pyproject_toml": "[PYPROJECT_TOML]"
2636 },
2637 "bird-feeder": {
2638 "root": "[ROOT]/albatross-virtual-workspace/packages/bird-feeder",
2639 "project": {
2640 "name": "bird-feeder",
2641 "version": "1.0.0",
2642 "requires-python": ">=3.12",
2643 "dependencies": [
2644 "anyio>=4.3.0,<5",
2645 "seeds"
2646 ],
2647 "optional-dependencies": null
2648 },
2649 "pyproject_toml": "[PYPROJECT_TOML]"
2650 },
2651 "seeds": {
2652 "root": "[ROOT]/albatross-virtual-workspace/packages/seeds",
2653 "project": {
2654 "name": "seeds",
2655 "version": "1.0.0",
2656 "requires-python": ">=3.12",
2657 "dependencies": [
2658 "idna==3.6"
2659 ],
2660 "optional-dependencies": null
2661 },
2662 "pyproject_toml": "[PYPROJECT_TOML]"
2663 }
2664 },
2665 "required_members": {
2666 "bird-feeder": null,
2667 "seeds": null
2668 },
2669 "sources": {},
2670 "indexes": [],
2671 "pyproject_toml": {
2672 "project": null,
2673 "tool": {
2674 "uv": {
2675 "sources": null,
2676 "index": null,
2677 "workspace": {
2678 "members": [
2679 "packages/*"
2680 ],
2681 "exclude": null
2682 },
2683 "managed": null,
2684 "package": null,
2685 "default-groups": null,
2686 "dependency-groups": null,
2687 "dev-dependencies": null,
2688 "override-dependencies": null,
2689 "exclude-dependencies": null,
2690 "constraint-dependencies": null,
2691 "build-constraint-dependencies": null,
2692 "environments": null,
2693 "required-environments": null,
2694 "conflicts": null,
2695 "build-backend": null
2696 }
2697 },
2698 "dependency-groups": null
2699 }
2700 }
2701 }
2702 "#);
2703 });
2704 }
2705
2706 #[tokio::test]
2707 async fn workspace_cache_reuses_workspace_for_member() -> Result<()> {
2708 let root = tempfile::TempDir::new()?;
2709 let root = ChildPath::new(root.path());
2710
2711 root.child("pyproject.toml").write_str(
2712 r#"
2713 [project]
2714 name = "albatross"
2715 version = "0.1.0"
2716 requires-python = ">=3.12"
2717
2718 [tool.uv.workspace]
2719 members = ["packages/*"]
2720 "#,
2721 )?;
2722
2723 root.child("packages")
2724 .child("seeds")
2725 .child("pyproject.toml")
2726 .write_str(
2727 r#"
2728 [project]
2729 name = "seeds"
2730 version = "1.0.0"
2731 requires-python = ">=3.12"
2732 "#,
2733 )?;
2734
2735 let cache = Cache::from_path(env::temp_dir().join("uv-workspace-cache"));
2736 let workspace_cache = WorkspaceCache::default();
2737 let root_workspace = Workspace::discover(
2738 root.as_ref(),
2739 &DiscoveryOptions::default(),
2740 &cache,
2741 &workspace_cache,
2742 )
2743 .await?;
2744 let member_workspace = Workspace::discover(
2745 root.child("packages").child("seeds").as_ref(),
2746 &DiscoveryOptions::default(),
2747 &cache,
2748 &workspace_cache,
2749 )
2750 .await?;
2751
2752 assert!(Arc::ptr_eq(&root_workspace, &member_workspace));
2753
2754 root.child("pyproject.toml")
2755 .write_str("not valid toml >.<")?;
2756 let member_project = ProjectWorkspace::from_maybe_project_root(
2757 root.child("packages").child("seeds").as_ref(),
2758 &DiscoveryOptions::default(),
2759 &cache,
2760 &workspace_cache,
2761 )
2762 .await?
2763 .expect("cached workspace member ignores invalid change in the meantime");
2764
2765 assert!(Arc::ptr_eq(&root_workspace, &member_project.workspace));
2766
2767 Ok(())
2768 }
2769
2770 #[tokio::test]
2771 async fn workspace_cache_does_not_store_partial_discovery() -> Result<()> {
2772 let root = tempfile::TempDir::new()?;
2773 let root = ChildPath::new(root.path());
2774
2775 root.child("pyproject.toml").write_str(
2776 r#"
2777 [project]
2778 name = "albatross"
2779 version = "0.1.0"
2780 requires-python = ">=3.12"
2781
2782 [tool.uv.workspace]
2783 members = ["packages/*"]
2784 "#,
2785 )?;
2786
2787 root.child("packages")
2788 .child("seeds")
2789 .child("pyproject.toml")
2790 .write_str(
2791 r#"
2792 [project]
2793 name = "seeds"
2794 version = "1.0.0"
2795 requires-python = ">=3.12"
2796 "#,
2797 )?;
2798
2799 let cache = Cache::from_path(env::temp_dir().join("uv-workspace-cache"));
2800 let workspace_cache = WorkspaceCache::default();
2801 let partial_options = DiscoveryOptions {
2802 members: MemberDiscovery::None,
2803 ..DiscoveryOptions::default()
2804 };
2805 let partial_project =
2806 ProjectWorkspace::discover(root.as_ref(), &partial_options, &cache, &workspace_cache)
2807 .await?;
2808
2809 assert_eq!(partial_project.workspace().packages().len(), 1);
2810
2811 let member_project = ProjectWorkspace::discover(
2812 root.child("packages").child("seeds").as_ref(),
2813 &DiscoveryOptions::default(),
2814 &cache,
2815 &workspace_cache,
2816 )
2817 .await?;
2818 let seeds = PackageName::from_str("seeds")?;
2819
2820 assert_eq!(member_project.project_name(), &seeds);
2821 assert_eq!(member_project.workspace().packages().len(), 2);
2822 assert!(member_project.workspace().packages().contains_key(&seeds));
2823
2824 Ok(())
2825 }
2826
2827 #[tokio::test]
2828 async fn albatross_just_project() {
2829 let (project, root_escaped) = workspace_test("albatross-just-project").await;
2830 let filters = vec![(root_escaped.as_str(), "[ROOT]")];
2831 insta::with_settings!({filters => filters}, {
2832 assert_json_snapshot!(
2833 project,
2834 {
2835 ".workspace.packages.*.pyproject_toml" => "[PYPROJECT_TOML]"
2836 },
2837 @r#"
2838 {
2839 "project_root": "[ROOT]/albatross-just-project",
2840 "project_name": "albatross",
2841 "workspace": {
2842 "install_path": "[ROOT]/albatross-just-project",
2843 "packages": {
2844 "albatross": {
2845 "root": "[ROOT]/albatross-just-project",
2846 "project": {
2847 "name": "albatross",
2848 "version": "0.1.0",
2849 "requires-python": ">=3.12",
2850 "dependencies": [
2851 "iniconfig>=2,<3"
2852 ],
2853 "optional-dependencies": null
2854 },
2855 "pyproject_toml": "[PYPROJECT_TOML]"
2856 }
2857 },
2858 "required_members": {},
2859 "sources": {},
2860 "indexes": [],
2861 "pyproject_toml": {
2862 "project": {
2863 "name": "albatross",
2864 "version": "0.1.0",
2865 "requires-python": ">=3.12",
2866 "dependencies": [
2867 "iniconfig>=2,<3"
2868 ],
2869 "optional-dependencies": null
2870 },
2871 "tool": null,
2872 "dependency-groups": null
2873 }
2874 }
2875 }
2876 "#);
2877 });
2878 }
2879
2880 #[tokio::test]
2881 async fn exclude_package() -> Result<()> {
2882 let root = tempfile::TempDir::new()?;
2883 let root = ChildPath::new(root.path());
2884
2885 root.child("pyproject.toml").write_str(
2887 r#"
2888 [project]
2889 name = "albatross"
2890 version = "0.1.0"
2891 requires-python = ">=3.12"
2892 dependencies = ["tqdm>=4,<5"]
2893
2894 [tool.uv.workspace]
2895 members = ["packages/*"]
2896 exclude = ["packages/bird-feeder"]
2897
2898 [build-system]
2899 requires = ["hatchling"]
2900 build-backend = "hatchling.build"
2901 "#,
2902 )?;
2903 root.child("albatross").child("__init__.py").touch()?;
2904
2905 root.child("packages")
2907 .child("seeds")
2908 .child("pyproject.toml")
2909 .write_str(
2910 r#"
2911 [project]
2912 name = "seeds"
2913 version = "1.0.0"
2914 requires-python = ">=3.12"
2915 dependencies = ["idna==3.6"]
2916
2917 [build-system]
2918 requires = ["hatchling"]
2919 build-backend = "hatchling.build"
2920 "#,
2921 )?;
2922 root.child("packages")
2923 .child("seeds")
2924 .child("seeds")
2925 .child("__init__.py")
2926 .touch()?;
2927
2928 root.child("packages")
2930 .child("bird-feeder")
2931 .child("pyproject.toml")
2932 .write_str(
2933 r#"
2934 [project]
2935 name = "bird-feeder"
2936 version = "1.0.0"
2937 requires-python = ">=3.12"
2938 dependencies = ["anyio>=4.3.0,<5"]
2939
2940 [build-system]
2941 requires = ["hatchling"]
2942 build-backend = "hatchling.build"
2943 "#,
2944 )?;
2945 root.child("packages")
2946 .child("bird-feeder")
2947 .child("bird_feeder")
2948 .child("__init__.py")
2949 .touch()?;
2950
2951 let (project, root_escaped) = temporary_test(root.as_ref()).await.unwrap();
2952 let filters = vec![(root_escaped.as_str(), "[ROOT]")];
2953 insta::with_settings!({filters => filters}, {
2954 assert_json_snapshot!(
2955 project,
2956 {
2957 ".workspace.packages.*.pyproject_toml" => "[PYPROJECT_TOML]"
2958 },
2959 @r#"
2960 {
2961 "project_root": "[ROOT]",
2962 "project_name": "albatross",
2963 "workspace": {
2964 "install_path": "[ROOT]",
2965 "packages": {
2966 "albatross": {
2967 "root": "[ROOT]",
2968 "project": {
2969 "name": "albatross",
2970 "version": "0.1.0",
2971 "requires-python": ">=3.12",
2972 "dependencies": [
2973 "tqdm>=4,<5"
2974 ],
2975 "optional-dependencies": null
2976 },
2977 "pyproject_toml": "[PYPROJECT_TOML]"
2978 },
2979 "seeds": {
2980 "root": "[ROOT]/packages/seeds",
2981 "project": {
2982 "name": "seeds",
2983 "version": "1.0.0",
2984 "requires-python": ">=3.12",
2985 "dependencies": [
2986 "idna==3.6"
2987 ],
2988 "optional-dependencies": null
2989 },
2990 "pyproject_toml": "[PYPROJECT_TOML]"
2991 }
2992 },
2993 "required_members": {},
2994 "sources": {},
2995 "indexes": [],
2996 "pyproject_toml": {
2997 "project": {
2998 "name": "albatross",
2999 "version": "0.1.0",
3000 "requires-python": ">=3.12",
3001 "dependencies": [
3002 "tqdm>=4,<5"
3003 ],
3004 "optional-dependencies": null
3005 },
3006 "tool": {
3007 "uv": {
3008 "sources": null,
3009 "index": null,
3010 "workspace": {
3011 "members": [
3012 "packages/*"
3013 ],
3014 "exclude": [
3015 "packages/bird-feeder"
3016 ]
3017 },
3018 "managed": null,
3019 "package": null,
3020 "default-groups": null,
3021 "dependency-groups": null,
3022 "dev-dependencies": null,
3023 "override-dependencies": null,
3024 "exclude-dependencies": null,
3025 "constraint-dependencies": null,
3026 "build-constraint-dependencies": null,
3027 "environments": null,
3028 "required-environments": null,
3029 "conflicts": null,
3030 "build-backend": null
3031 }
3032 },
3033 "dependency-groups": null
3034 }
3035 }
3036 }
3037 "#);
3038 });
3039
3040 root.child("pyproject.toml").write_str(
3042 r#"
3043 [project]
3044 name = "albatross"
3045 version = "0.1.0"
3046 requires-python = ">=3.12"
3047 dependencies = ["tqdm>=4,<5"]
3048
3049 [tool.uv.workspace]
3050 members = ["packages/seeds", "packages/bird-feeder"]
3051 exclude = ["packages/bird-feeder"]
3052
3053 [build-system]
3054 requires = ["hatchling"]
3055 build-backend = "hatchling.build"
3056 "#,
3057 )?;
3058
3059 let (project, root_escaped) = temporary_test(root.as_ref()).await.unwrap();
3061 let filters = vec![(root_escaped.as_str(), "[ROOT]")];
3062 insta::with_settings!({filters => filters}, {
3063 assert_json_snapshot!(
3064 project,
3065 {
3066 ".workspace.packages.*.pyproject_toml" => "[PYPROJECT_TOML]"
3067 },
3068 @r#"
3069 {
3070 "project_root": "[ROOT]",
3071 "project_name": "albatross",
3072 "workspace": {
3073 "install_path": "[ROOT]",
3074 "packages": {
3075 "albatross": {
3076 "root": "[ROOT]",
3077 "project": {
3078 "name": "albatross",
3079 "version": "0.1.0",
3080 "requires-python": ">=3.12",
3081 "dependencies": [
3082 "tqdm>=4,<5"
3083 ],
3084 "optional-dependencies": null
3085 },
3086 "pyproject_toml": "[PYPROJECT_TOML]"
3087 },
3088 "seeds": {
3089 "root": "[ROOT]/packages/seeds",
3090 "project": {
3091 "name": "seeds",
3092 "version": "1.0.0",
3093 "requires-python": ">=3.12",
3094 "dependencies": [
3095 "idna==3.6"
3096 ],
3097 "optional-dependencies": null
3098 },
3099 "pyproject_toml": "[PYPROJECT_TOML]"
3100 }
3101 },
3102 "required_members": {},
3103 "sources": {},
3104 "indexes": [],
3105 "pyproject_toml": {
3106 "project": {
3107 "name": "albatross",
3108 "version": "0.1.0",
3109 "requires-python": ">=3.12",
3110 "dependencies": [
3111 "tqdm>=4,<5"
3112 ],
3113 "optional-dependencies": null
3114 },
3115 "tool": {
3116 "uv": {
3117 "sources": null,
3118 "index": null,
3119 "workspace": {
3120 "members": [
3121 "packages/seeds",
3122 "packages/bird-feeder"
3123 ],
3124 "exclude": [
3125 "packages/bird-feeder"
3126 ]
3127 },
3128 "managed": null,
3129 "package": null,
3130 "default-groups": null,
3131 "dependency-groups": null,
3132 "dev-dependencies": null,
3133 "override-dependencies": null,
3134 "exclude-dependencies": null,
3135 "constraint-dependencies": null,
3136 "build-constraint-dependencies": null,
3137 "environments": null,
3138 "required-environments": null,
3139 "conflicts": null,
3140 "build-backend": null
3141 }
3142 },
3143 "dependency-groups": null
3144 }
3145 }
3146 }
3147 "#);
3148 });
3149
3150 root.child("pyproject.toml").write_str(
3152 r#"
3153 [project]
3154 name = "albatross"
3155 version = "0.1.0"
3156 requires-python = ">=3.12"
3157 dependencies = ["tqdm>=4,<5"]
3158
3159 [tool.uv.workspace]
3160 members = ["packages/seeds", "packages/bird-feeder"]
3161 exclude = ["packages"]
3162
3163 [build-system]
3164 requires = ["hatchling"]
3165 build-backend = "hatchling.build"
3166 "#,
3167 )?;
3168
3169 let (project, root_escaped) = temporary_test(root.as_ref()).await.unwrap();
3171 let filters = vec![(root_escaped.as_str(), "[ROOT]")];
3172 insta::with_settings!({filters => filters}, {
3173 assert_json_snapshot!(
3174 project,
3175 {
3176 ".workspace.packages.*.pyproject_toml" => "[PYPROJECT_TOML]"
3177 },
3178 @r#"
3179 {
3180 "project_root": "[ROOT]",
3181 "project_name": "albatross",
3182 "workspace": {
3183 "install_path": "[ROOT]",
3184 "packages": {
3185 "albatross": {
3186 "root": "[ROOT]",
3187 "project": {
3188 "name": "albatross",
3189 "version": "0.1.0",
3190 "requires-python": ">=3.12",
3191 "dependencies": [
3192 "tqdm>=4,<5"
3193 ],
3194 "optional-dependencies": null
3195 },
3196 "pyproject_toml": "[PYPROJECT_TOML]"
3197 },
3198 "bird-feeder": {
3199 "root": "[ROOT]/packages/bird-feeder",
3200 "project": {
3201 "name": "bird-feeder",
3202 "version": "1.0.0",
3203 "requires-python": ">=3.12",
3204 "dependencies": [
3205 "anyio>=4.3.0,<5"
3206 ],
3207 "optional-dependencies": null
3208 },
3209 "pyproject_toml": "[PYPROJECT_TOML]"
3210 },
3211 "seeds": {
3212 "root": "[ROOT]/packages/seeds",
3213 "project": {
3214 "name": "seeds",
3215 "version": "1.0.0",
3216 "requires-python": ">=3.12",
3217 "dependencies": [
3218 "idna==3.6"
3219 ],
3220 "optional-dependencies": null
3221 },
3222 "pyproject_toml": "[PYPROJECT_TOML]"
3223 }
3224 },
3225 "required_members": {},
3226 "sources": {},
3227 "indexes": [],
3228 "pyproject_toml": {
3229 "project": {
3230 "name": "albatross",
3231 "version": "0.1.0",
3232 "requires-python": ">=3.12",
3233 "dependencies": [
3234 "tqdm>=4,<5"
3235 ],
3236 "optional-dependencies": null
3237 },
3238 "tool": {
3239 "uv": {
3240 "sources": null,
3241 "index": null,
3242 "workspace": {
3243 "members": [
3244 "packages/seeds",
3245 "packages/bird-feeder"
3246 ],
3247 "exclude": [
3248 "packages"
3249 ]
3250 },
3251 "managed": null,
3252 "package": null,
3253 "default-groups": null,
3254 "dependency-groups": null,
3255 "dev-dependencies": null,
3256 "override-dependencies": null,
3257 "exclude-dependencies": null,
3258 "constraint-dependencies": null,
3259 "build-constraint-dependencies": null,
3260 "environments": null,
3261 "required-environments": null,
3262 "conflicts": null,
3263 "build-backend": null
3264 }
3265 },
3266 "dependency-groups": null
3267 }
3268 }
3269 }
3270 "#);
3271 });
3272
3273 root.child("pyproject.toml").write_str(
3275 r#"
3276 [project]
3277 name = "albatross"
3278 version = "0.1.0"
3279 requires-python = ">=3.12"
3280 dependencies = ["tqdm>=4,<5"]
3281
3282 [tool.uv.workspace]
3283 members = ["packages/seeds", "packages/bird-feeder"]
3284 exclude = ["packages/*"]
3285
3286 [build-system]
3287 requires = ["hatchling"]
3288 build-backend = "hatchling.build"
3289 "#,
3290 )?;
3291
3292 let (project, root_escaped) = temporary_test(root.as_ref()).await.unwrap();
3294 let filters = vec![(root_escaped.as_str(), "[ROOT]")];
3295 insta::with_settings!({filters => filters}, {
3296 assert_json_snapshot!(
3297 project,
3298 {
3299 ".workspace.packages.*.pyproject_toml" => "[PYPROJECT_TOML]"
3300 },
3301 @r#"
3302 {
3303 "project_root": "[ROOT]",
3304 "project_name": "albatross",
3305 "workspace": {
3306 "install_path": "[ROOT]",
3307 "packages": {
3308 "albatross": {
3309 "root": "[ROOT]",
3310 "project": {
3311 "name": "albatross",
3312 "version": "0.1.0",
3313 "requires-python": ">=3.12",
3314 "dependencies": [
3315 "tqdm>=4,<5"
3316 ],
3317 "optional-dependencies": null
3318 },
3319 "pyproject_toml": "[PYPROJECT_TOML]"
3320 }
3321 },
3322 "required_members": {},
3323 "sources": {},
3324 "indexes": [],
3325 "pyproject_toml": {
3326 "project": {
3327 "name": "albatross",
3328 "version": "0.1.0",
3329 "requires-python": ">=3.12",
3330 "dependencies": [
3331 "tqdm>=4,<5"
3332 ],
3333 "optional-dependencies": null
3334 },
3335 "tool": {
3336 "uv": {
3337 "sources": null,
3338 "index": null,
3339 "workspace": {
3340 "members": [
3341 "packages/seeds",
3342 "packages/bird-feeder"
3343 ],
3344 "exclude": [
3345 "packages/*"
3346 ]
3347 },
3348 "managed": null,
3349 "package": null,
3350 "default-groups": null,
3351 "dependency-groups": null,
3352 "dev-dependencies": null,
3353 "override-dependencies": null,
3354 "exclude-dependencies": null,
3355 "constraint-dependencies": null,
3356 "build-constraint-dependencies": null,
3357 "environments": null,
3358 "required-environments": null,
3359 "conflicts": null,
3360 "build-backend": null
3361 }
3362 },
3363 "dependency-groups": null
3364 }
3365 }
3366 }
3367 "#);
3368 });
3369
3370 Ok(())
3371 }
3372
3373 #[tokio::test]
3374 async fn exclude_package_with_normalized_glob_and_escaped_root() -> Result<()> {
3375 let temp_dir = tempfile::TempDir::new()?;
3376 let temp_dir_root = ChildPath::new(temp_dir.path());
3377 let root = temp_dir_root.child("workspace[glob]?");
3378
3379 root.child("pyproject.toml").write_str(
3380 r#"
3381 [project]
3382 name = "albatross"
3383 version = "0.1.0"
3384 requires-python = ">=3.12"
3385
3386 [tool.uv.workspace]
3387 members = ["./packages/*", "../external-*"]
3388 exclude = [
3389 "packages/excluded-borrowed-*",
3390 "./ignored/../packages/excluded",
3391 "./packages/./excluded-glob-*",
3392 "../external-excluded",
3393 ]
3394 "#,
3395 )?;
3396
3397 for member in [
3398 "included",
3399 "excluded",
3400 "excluded-glob-one",
3401 "excluded-borrowed-one",
3402 ] {
3403 root.child("packages")
3404 .child(member)
3405 .child("pyproject.toml")
3406 .write_str(&format!(
3407 r#"
3408 [project]
3409 name = "{member}"
3410 version = "0.1.0"
3411 requires-python = ">=3.12"
3412 "#,
3413 ))?;
3414 }
3415
3416 for member in ["external-included", "external-excluded"] {
3417 temp_dir_root
3418 .child(member)
3419 .child("pyproject.toml")
3420 .write_str(&format!(
3421 r#"
3422 [project]
3423 name = "{member}"
3424 version = "0.1.0"
3425 requires-python = ">=3.12"
3426 "#,
3427 ))?;
3428 }
3429
3430 let (project, _) = temporary_test(root.as_ref())
3431 .await
3432 .map_err(|(error, _)| error)?;
3433 assert_json_snapshot!(
3434 project.workspace().packages().keys().collect::<Vec<_>>(),
3435 @r#"
3436 [
3437 "albatross",
3438 "external-included",
3439 "included"
3440 ]
3441 "#
3442 );
3443
3444 Ok(())
3445 }
3446
3447 #[test]
3448 fn read_dependency_groups() {
3449 let toml = r#"
3450[dependency-groups]
3451foo = ["a", {include-group = "bar"}]
3452bar = ["b"]
3453future = [{include-group = "bar", unknown = "value"}]
3454"#;
3455
3456 let result = PyProjectToml::from_string(toml.to_string(), "pyproject.toml")
3457 .expect("Deserialization should succeed");
3458
3459 let groups = result
3460 .dependency_groups
3461 .expect("`dependency-groups` should be present");
3462 let foo = groups
3463 .get(&GroupName::from_str("foo").unwrap())
3464 .expect("Group `foo` should be present");
3465 assert_eq!(
3466 foo,
3467 &[
3468 DependencyGroupSpecifier::Requirement("a".to_string()),
3469 DependencyGroupSpecifier::IncludeGroup {
3470 include_group: GroupName::from_str("bar").unwrap(),
3471 }
3472 ]
3473 );
3474
3475 let bar = groups
3476 .get(&GroupName::from_str("bar").unwrap())
3477 .expect("Group `bar` should be present");
3478 assert_eq!(
3479 bar,
3480 &[DependencyGroupSpecifier::Requirement("b".to_string())]
3481 );
3482
3483 let future = groups
3484 .get(&GroupName::from_str("future").unwrap())
3485 .expect("Group `future` should be present");
3486 assert_eq!(
3487 future,
3488 &[DependencyGroupSpecifier::Object(BTreeMap::from([
3489 ("include-group".to_string(), "bar".to_string()),
3490 ("unknown".to_string(), "value".to_string()),
3491 ]))]
3492 );
3493 }
3494
3495 #[test]
3496 fn reject_colliding_optional_dependency_names() {
3497 let err = PyProjectToml::from_string(
3498 r#"
3499[project]
3500name = "example"
3501version = "1.0.0"
3502
3503[project.optional-dependencies]
3504foo-bar = ["anyio"]
3505foo_bar = ["iniconfig"]
3506"#
3507 .to_string(),
3508 "pyproject.toml",
3509 )
3510 .unwrap_err();
3511
3512 assert_snapshot!(err.to_string(), @r#"
3513 TOML parse error at line 6, column 1
3514 |
3515 6 | [project.optional-dependencies]
3516 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
3517 duplicate normalized extra name `foo-bar`
3518 "#);
3519 }
3520
3521 #[tokio::test]
3522 async fn nested_workspace() -> Result<()> {
3523 let root = tempfile::TempDir::new()?;
3524 let root = ChildPath::new(root.path());
3525
3526 root.child("pyproject.toml").write_str(
3528 r#"
3529 [project]
3530 name = "albatross"
3531 version = "0.1.0"
3532 requires-python = ">=3.12"
3533 dependencies = ["tqdm>=4,<5"]
3534
3535 [tool.uv.workspace]
3536 members = ["packages/*"]
3537 "#,
3538 )?;
3539
3540 root.child("packages")
3542 .child("seeds")
3543 .child("pyproject.toml")
3544 .write_str(
3545 r#"
3546 [project]
3547 name = "seeds"
3548 version = "1.0.0"
3549 requires-python = ">=3.12"
3550 dependencies = ["idna==3.6"]
3551
3552 [tool.uv.workspace]
3553 members = ["nested_packages/*"]
3554 "#,
3555 )?;
3556
3557 let (error, root_escaped) = temporary_test(root.as_ref()).await.unwrap_err();
3558 let filters = vec![(root_escaped.as_str(), "[ROOT]")];
3559 insta::with_settings!({filters => filters}, {
3560 assert_snapshot!(
3561 error,
3562 @"Nested workspaces are not supported, but workspace member has a `tool.uv.workspace` table: [ROOT]/packages/seeds");
3563 });
3564
3565 Ok(())
3566 }
3567
3568 #[tokio::test]
3569 async fn duplicate_names() -> Result<()> {
3570 let root = tempfile::TempDir::new()?;
3571 let root = ChildPath::new(root.path());
3572
3573 root.child("pyproject.toml").write_str(
3575 r#"
3576 [project]
3577 name = "albatross"
3578 version = "0.1.0"
3579 requires-python = ">=3.12"
3580 dependencies = ["tqdm>=4,<5"]
3581
3582 [tool.uv.workspace]
3583 members = ["packages/*"]
3584 "#,
3585 )?;
3586
3587 root.child("packages")
3589 .child("seeds")
3590 .child("pyproject.toml")
3591 .write_str(
3592 r#"
3593 [project]
3594 name = "seeds"
3595 version = "1.0.0"
3596 requires-python = ">=3.12"
3597 dependencies = ["idna==3.6"]
3598
3599 [tool.uv.workspace]
3600 members = ["nested_packages/*"]
3601 "#,
3602 )?;
3603
3604 root.child("packages")
3606 .child("seeds2")
3607 .child("pyproject.toml")
3608 .write_str(
3609 r#"
3610 [project]
3611 name = "seeds"
3612 version = "1.0.0"
3613 requires-python = ">=3.12"
3614 dependencies = ["idna==3.6"]
3615
3616 [tool.uv.workspace]
3617 members = ["nested_packages/*"]
3618 "#,
3619 )?;
3620
3621 let (error, root_escaped) = temporary_test(root.as_ref()).await.unwrap_err();
3622 let filters = vec![(root_escaped.as_str(), "[ROOT]")];
3623 insta::with_settings!({filters => filters}, {
3624 assert_snapshot!(
3625 error,
3626 @"Two workspace members are both named `seeds`: `[ROOT]/packages/seeds` and `[ROOT]/packages/seeds2`");
3627 });
3628
3629 Ok(())
3630 }
3631}