1use std::borrow::Cow;
4use std::collections::{BTreeMap, BTreeSet};
5use std::error::Error;
6use std::fmt;
7use std::fmt::Display;
8use std::hash::BuildHasherDefault;
9use std::path::{Path, PathBuf};
10use std::sync::Arc;
11
12use glob::{GlobError, Pattern, PatternError, glob};
13use itertools::Itertools;
14use rustc_hash::{FxHashSet, FxHasher};
15use tracing::{debug, trace, warn};
16
17use uv_cache::Cache;
18use uv_configuration::{DependencyGroupsWithDefaults, ExcludeDependency};
19use uv_distribution_types::{Index, Requirement, RequirementSource};
20use uv_fs::{CWD, Simplified, normalize_path};
21use uv_normalize::{DEV_DEPENDENCIES, GroupName, PackageName};
22use uv_once_map::OnceMap;
23use uv_pep440::VersionSpecifiers;
24use uv_pep508::{MarkerTree, VerbatimUrl};
25use uv_pypi_types::{ConflictError, Conflicts, SupportedEnvironments, VerbatimParsedUrl};
26use uv_static::EnvVars;
27use uv_warnings::warn_user_once;
28
29use crate::dependency_groups::{DependencyGroupError, FlatDependencyGroup, FlatDependencyGroups};
30use crate::pyproject::{
31 OverrideDependency, Project, PyProjectToml, PyprojectTomlError, Source, Sources, ToolUvSources,
32 ToolUvWorkspace, WorkspaceReference,
33};
34
35#[derive(Debug)]
37pub enum ProjectEnvironmentSelection {
38 Default,
40 Override(PathBuf),
42 Active(PathBuf),
44}
45
46impl ProjectEnvironmentSelection {
47 pub fn is_default(&self) -> bool {
49 matches!(self, Self::Default)
50 }
51
52 pub fn explicit_path(&self) -> Option<&Path> {
54 match self {
55 Self::Default => None,
56 Self::Override(path) | Self::Active(path) => Some(path),
57 }
58 }
59}
60
61type WorkspaceMembers = Arc<BTreeMap<PackageName, WorkspaceMember>>;
62type FxOnceMap<K, V> = OnceMap<K, V, BuildHasherDefault<FxHasher>>;
63type CachedWorkspaceResult = Result<Arc<Workspace>, WorkspaceError>;
64
65#[derive(Debug, Default, Clone)]
79pub struct WorkspaceCache {
80 workspaces: Arc<FxOnceMap<PathBuf, CachedWorkspaceResult>>,
81}
82
83impl WorkspaceCache {
84 fn insert(&self, result: CachedWorkspaceResult, install_path: &Path) {
89 match result {
90 Ok(workspace) => {
91 for package in workspace.packages.values() {
92 if has_intermediate_pyproject(&workspace.install_path, &package.root) {
97 continue;
98 }
99 self.workspaces
100 .done(package.root.clone(), Ok(workspace.clone()));
101 }
102 self.workspaces
103 .done(workspace.install_path.clone(), Ok(workspace));
104 }
105 Err(err) => {
106 self.workspaces.done(install_path.to_path_buf(), Err(err));
107 }
108 }
109 }
110
111 async fn register_or_wait(&self, workspace_root: &PathBuf) -> Option<CachedWorkspaceResult> {
116 self.workspaces.register_or_wait(workspace_root).await
117 }
118
119 fn get(
124 &self,
125 path: &Path,
126 member_discovery: &MemberDiscovery,
127 ) -> Option<CachedWorkspaceResult> {
128 match member_discovery {
129 MemberDiscovery::All => self.workspaces.get(path),
130 MemberDiscovery::Existing => match self.workspaces.get(path) {
131 Some(Ok(workspace)) => Some(Ok(workspace)),
132 Some(Err(_)) | None => None,
133 },
134 MemberDiscovery::None | MemberDiscovery::Ignore(_) => None,
135 }
136 }
137
138 fn invalidate_workspace(&self, workspace: &Workspace) {
144 if let Some(Ok(workspace)) = self.workspaces.remove(workspace.install_path()) {
145 for member in workspace.packages.values() {
146 self.workspaces.remove(&member.root);
147 }
148 }
149 }
150}
151
152fn has_intermediate_pyproject(workspace_root: &Path, project_dir: &Path) -> bool {
155 if project_dir == workspace_root {
156 return false;
157 }
158
159 let Ok(_) = project_dir.strip_prefix(workspace_root) else {
160 return false;
161 };
162
163 project_dir
164 .ancestors()
165 .skip(1)
166 .take_while(|ancestor| *ancestor != workspace_root)
167 .any(|ancestor| ancestor.join("pyproject.toml").is_file())
168}
169
170#[derive(Debug, Clone)]
171pub struct WorkspaceError(Arc<WorkspaceErrorKind>);
172
173impl AsRef<WorkspaceErrorKind> for WorkspaceError {
174 fn as_ref(&self) -> &WorkspaceErrorKind {
175 &self.0
176 }
177}
178
179impl<T> From<T> for WorkspaceError
180where
181 T: Into<WorkspaceErrorKind>,
182{
183 fn from(error: T) -> Self {
184 Self(Arc::new(error.into()))
185 }
186}
187
188impl Display for WorkspaceError {
189 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
190 Display::fmt(&self.0, f)
191 }
192}
193
194impl Error for WorkspaceError {
195 fn source(&self) -> Option<&(dyn Error + 'static)> {
196 self.0.source()
197 }
198}
199
200#[derive(thiserror::Error, Debug)]
201pub enum WorkspaceErrorKind {
202 #[error("No `pyproject.toml` found in current directory or any parent directory")]
204 MissingPyprojectToml,
205 #[error("Workspace member `{}` is missing a `pyproject.toml` (matches: `{}`)", _0.simplified_display(), _1)]
206 MissingPyprojectTomlMember(PathBuf, String),
207 #[error("No `project` table found in: {}", _0.simplified_display())]
208 MissingProject(PathBuf),
209 #[error("No workspace found for: {}", _0.simplified_display())]
210 MissingWorkspace(PathBuf),
211 #[error("The project is marked as unmanaged: {}", _0.simplified_display())]
212 NonWorkspace(PathBuf),
213 #[error("Nested workspaces are not supported, but workspace member has a `tool.uv.workspace` table: {}", _0.simplified_display())]
214 NestedWorkspace(PathBuf),
215 #[error("The workspace does not have a member {}: {}", _0, _1.simplified_display())]
216 NoSuchMember(PackageName, PathBuf),
217 #[error("Two workspace members are both named `{name}`: `{}` and `{}`", first.simplified_display(), second.simplified_display())]
218 DuplicatePackage {
219 name: PackageName,
220 first: PathBuf,
221 second: PathBuf,
222 },
223 #[error("pyproject.toml section is declared as dynamic, but must be static: `{0}`")]
224 DynamicNotAllowed(&'static str),
225 #[error(
226 "Workspace member `{}` was requested as both `editable = true` and `editable = false`",
227 _0
228 )]
229 EditableConflict(PackageName),
230 #[error("Failed to find directories for glob: `{0}`")]
231 Pattern(String, #[source] PatternError),
232 #[error("Directory walking failed for `tool.uv.workspace.members` glob: `{0}`")]
234 GlobWalk(String, #[source] GlobError),
235 #[error(transparent)]
236 Io(#[from] std::io::Error),
237 #[error("Failed to parse: `{}`", _0.user_display())]
238 Toml(PathBuf, #[source] Box<PyprojectTomlError>),
239 #[error(transparent)]
240 Conflicts(#[from] ConflictError),
241 #[error("Failed to normalize workspace member path")]
244 Normalize(#[source] std::io::Error),
245}
246
247#[derive(Debug, Default, Clone, Hash, PartialEq, Eq)]
248pub enum MemberDiscovery {
249 #[default]
251 All,
252 Existing,
254 None,
256 Ignore(BTreeSet<PathBuf>),
258}
259
260#[derive(Debug, Default, Clone, Hash, PartialEq, Eq)]
261pub struct DiscoveryOptions {
262 pub stop_discovery_at: Option<PathBuf>,
268 pub members: MemberDiscovery,
270}
271
272pub type RequiresPythonSources = BTreeMap<(PackageName, Option<GroupName>), VersionSpecifiers>;
273
274pub type Editability = Option<bool>;
275
276#[derive(Debug, Clone)]
278#[cfg_attr(test, derive(serde::Serialize))]
279pub struct Workspace {
280 install_path: PathBuf,
285 packages: WorkspaceMembers,
287 required_members: BTreeMap<PackageName, Editability>,
290 sources: BTreeMap<PackageName, Sources>,
294 indexes: Vec<Index>,
298 pyproject_toml: PyProjectToml,
300}
301
302impl Workspace {
303 pub async fn discover(
325 path: &Path,
326 options: &DiscoveryOptions,
327 cache: &Cache,
328 workspace_cache: &WorkspaceCache,
329 ) -> Result<Arc<Self>, WorkspaceError> {
330 let path = std::path::absolute(path)
331 .map_err(WorkspaceErrorKind::Normalize)?
332 .clone();
333 let path = normalize_path(&path);
334
335 let project_path = path
336 .ancestors()
337 .find(|path| path.join("pyproject.toml").is_file())
338 .ok_or(WorkspaceErrorKind::MissingPyprojectToml)?
339 .to_path_buf();
340
341 if let Some(workspace) = workspace_cache.get(&project_path, &options.members) {
347 return workspace;
348 }
349
350 let pyproject_path = project_path.join("pyproject.toml");
351 let contents = fs_err::tokio::read_to_string(&pyproject_path).await?;
352 let pyproject_toml = PyProjectToml::from_string(contents, &pyproject_path)
353 .map_err(|err| WorkspaceErrorKind::Toml(pyproject_path.clone(), Box::new(err)))?;
354
355 if pyproject_toml
357 .tool
358 .as_ref()
359 .and_then(|tool| tool.uv.as_ref())
360 .and_then(|uv| uv.managed)
361 == Some(false)
362 {
363 debug!(
364 "Project `{}` is marked as unmanaged",
365 project_path.simplified_display()
366 );
367 return Err(WorkspaceError::from(WorkspaceErrorKind::NonWorkspace(
368 project_path,
369 )));
370 }
371
372 let explicit_root = pyproject_toml
374 .tool
375 .as_ref()
376 .and_then(|tool| tool.uv.as_ref())
377 .and_then(|uv| uv.workspace.as_ref())
378 .map(|workspace| {
379 (
380 project_path.clone(),
381 workspace.clone(),
382 pyproject_toml.clone(),
383 )
384 });
385
386 let (workspace_root, workspace_definition, workspace_pyproject_toml) =
387 if let Some(workspace) = explicit_root {
388 workspace
390 } else if pyproject_toml.project.is_none() {
391 return Err(WorkspaceError::from(WorkspaceErrorKind::MissingProject(
393 pyproject_path,
394 )));
395 } else if let Some(workspace) = find_workspace(&project_path, options, cache).await? {
396 workspace
398 } else {
399 (
401 project_path.clone(),
402 ToolUvWorkspace::default(),
403 pyproject_toml.clone(),
404 )
405 };
406
407 if options.members == MemberDiscovery::All {
408 if let Some(workspace) = workspace_cache.register_or_wait(&workspace_root).await {
414 return workspace;
415 }
416 }
417
418 debug!(
419 "Found workspace root: `{}`",
420 workspace_root.simplified_display()
421 );
422
423 let current_project = pyproject_toml
426 .project
427 .clone()
428 .map(|project| WorkspaceMember {
429 root: project_path,
430 project,
431 pyproject_toml,
432 });
433
434 let result = Self::build(
435 workspace_root.clone(),
436 workspace_definition,
437 workspace_pyproject_toml,
438 current_project,
439 options,
440 cache,
441 )
442 .await;
443 if options.members == MemberDiscovery::All {
444 workspace_cache.insert(result.clone(), &workspace_root);
445 }
446 result
447 }
448
449 fn with_current_project(
453 self: Arc<Self>,
454 package_name: PackageName,
455 ) -> Option<ProjectWorkspace> {
456 let member = self.packages.get(&package_name)?;
457 Some(ProjectWorkspace {
458 project_root: member.root().clone(),
459 project_name: package_name,
460 workspace: self,
461 })
462 }
463
464 fn update_member(
470 self: Arc<Self>,
471 package_name: &PackageName,
472 pyproject_toml: PyProjectToml,
473 ) -> Result<Option<Arc<Self>>, WorkspaceError> {
474 debug_assert_eq!(
475 Arc::strong_count(&self),
476 1,
477 "cannot modify workspace still in use",
478 );
479
480 let slf = Arc::unwrap_or_clone(self);
481 let mut packages = slf.packages;
482
483 let Some(member) = Arc::make_mut(&mut packages).get_mut(package_name) else {
484 return Ok(None);
485 };
486
487 if member.root == slf.install_path {
488 let workspace_pyproject_toml = pyproject_toml.clone();
491
492 let workspace_sources = workspace_pyproject_toml
494 .tool
495 .clone()
496 .and_then(|tool| tool.uv)
497 .and_then(|uv| uv.sources)
498 .map(ToolUvSources::into_inner)
499 .unwrap_or_default();
500
501 member.pyproject_toml = pyproject_toml;
503
504 let required_members = Self::collect_required_members(
506 &packages,
507 &workspace_sources,
508 &workspace_pyproject_toml,
509 )?;
510
511 let workspace = Self {
512 pyproject_toml: workspace_pyproject_toml,
513 sources: workspace_sources,
514 packages,
515 required_members,
516 ..slf
517 };
518 Ok(Some(Arc::new(workspace)))
519 } else {
520 member.pyproject_toml = pyproject_toml;
522
523 let required_members =
525 Self::collect_required_members(&packages, &slf.sources, &slf.pyproject_toml)?;
526
527 let workspace = Self {
528 packages,
529 required_members,
530 ..slf
531 };
532 Ok(Some(Arc::new(workspace)))
533 }
534 }
535
536 pub fn is_non_project(&self) -> bool {
538 !self
539 .packages
540 .values()
541 .any(|member| *member.root() == self.install_path)
542 }
543
544 pub fn members_requirements(&self) -> impl Iterator<Item = Requirement> + '_ {
546 self.packages.iter().filter_map(|(name, member)| {
547 let url = VerbatimUrl::from_absolute_path(&member.root).expect("path is valid URL");
548 Some(Requirement {
549 name: member.pyproject_toml.project.as_ref()?.name.clone(),
550 extras: Box::new([]),
551 groups: Box::new([]),
552 marker: MarkerTree::TRUE,
553 source: if member
554 .pyproject_toml()
555 .is_package(!self.is_required_member(name))
556 {
557 RequirementSource::Directory {
558 install_path: member.root.clone().into_boxed_path(),
559 editable: Some(
560 self.required_members
561 .get(name)
562 .copied()
563 .flatten()
564 .unwrap_or(true),
565 ),
566 r#virtual: Some(false),
567 url,
568 }
569 } else {
570 RequirementSource::Directory {
571 install_path: member.root.clone().into_boxed_path(),
572 editable: Some(false),
573 r#virtual: Some(true),
574 url,
575 }
576 },
577 origin: None,
578 })
579 })
580 }
581
582 pub fn required_members(&self) -> &BTreeMap<PackageName, Editability> {
584 &self.required_members
585 }
586
587 fn collect_required_members(
594 packages: &BTreeMap<PackageName, WorkspaceMember>,
595 sources: &BTreeMap<PackageName, Sources>,
596 pyproject_toml: &PyProjectToml,
597 ) -> Result<BTreeMap<PackageName, Editability>, WorkspaceError> {
598 let mut required_members = BTreeMap::new();
599
600 for (package, sources) in sources
601 .iter()
602 .filter(|(name, _)| {
603 pyproject_toml
604 .project
605 .as_ref()
606 .is_none_or(|project| project.name != **name)
607 })
608 .chain(
609 packages
610 .iter()
611 .filter_map(|(name, member)| {
612 member
613 .pyproject_toml
614 .tool
615 .as_ref()
616 .and_then(|tool| tool.uv.as_ref())
617 .and_then(|uv| uv.sources.as_ref())
618 .map(ToolUvSources::inner)
619 .map(move |sources| {
620 sources
621 .iter()
622 .filter(move |(source_name, _)| name != *source_name)
623 })
624 })
625 .flatten(),
626 )
627 {
628 for source in sources.iter() {
629 let Source::Workspace {
630 workspace: WorkspaceReference::Bool(true),
631 editable,
632 ..
633 } = &source
634 else {
635 continue;
636 };
637 let existing = required_members.insert(package.clone(), *editable);
638 if let Some(Some(existing)) = existing {
639 if let Some(editable) = editable {
640 if existing != *editable {
642 return Err(WorkspaceError::from(
643 WorkspaceErrorKind::EditableConflict(package.clone()),
644 ));
645 }
646 }
647 }
648 }
649 }
650
651 Ok(required_members)
652 }
653
654 fn is_required_member(&self, name: &PackageName) -> bool {
656 self.required_members().contains_key(name)
657 }
658
659 pub fn group_requirements(&self) -> impl Iterator<Item = Requirement> + '_ {
661 self.packages.iter().filter_map(|(name, member)| {
662 let url = VerbatimUrl::from_absolute_path(&member.root).expect("path is valid URL");
663
664 let groups = {
665 let mut groups = member
666 .pyproject_toml
667 .dependency_groups
668 .as_ref()
669 .map(|groups| groups.keys().cloned().collect::<Vec<_>>())
670 .unwrap_or_default();
671 if member
672 .pyproject_toml
673 .tool
674 .as_ref()
675 .and_then(|tool| tool.uv.as_ref())
676 .and_then(|uv| uv.dev_dependencies.as_ref())
677 .is_some()
678 {
679 groups.push(DEV_DEPENDENCIES.clone());
680 groups.sort_unstable();
681 }
682 groups
683 };
684 if groups.is_empty() {
685 return None;
686 }
687
688 let value = self.required_members.get(name);
689 let is_required_member = value.is_some();
690 let editability = value.copied().flatten();
691
692 Some(Requirement {
693 name: member.pyproject_toml.project.as_ref()?.name.clone(),
694 extras: Box::new([]),
695 groups: groups.into_boxed_slice(),
696 marker: MarkerTree::TRUE,
697 source: if member.pyproject_toml().is_package(!is_required_member) {
698 RequirementSource::Directory {
699 install_path: member.root.clone().into_boxed_path(),
700 editable: Some(editability.unwrap_or(true)),
701 r#virtual: Some(false),
702 url,
703 }
704 } else {
705 RequirementSource::Directory {
706 install_path: member.root.clone().into_boxed_path(),
707 editable: Some(false),
708 r#virtual: Some(true),
709 url,
710 }
711 },
712 origin: None,
713 })
714 })
715 }
716
717 pub fn environments(&self) -> Option<&SupportedEnvironments> {
719 self.pyproject_toml
720 .tool
721 .as_ref()
722 .and_then(|tool| tool.uv.as_ref())
723 .and_then(|uv| uv.environments.as_ref())
724 }
725
726 pub fn required_environments(&self) -> Option<&SupportedEnvironments> {
728 self.pyproject_toml
729 .tool
730 .as_ref()
731 .and_then(|tool| tool.uv.as_ref())
732 .and_then(|uv| uv.required_environments.as_ref())
733 }
734
735 pub fn conflicts(&self) -> Result<Conflicts, WorkspaceError> {
737 let mut conflicting = Conflicts::empty();
738 if self.is_non_project()
739 && let Some(root_conflicts) = self
740 .pyproject_toml
741 .tool
742 .as_ref()
743 .and_then(|tool| tool.uv.as_ref())
744 .and_then(|uv| uv.conflicts.as_ref())
745 {
746 let mut root_conflicts = root_conflicts.to_conflicts()?;
747 conflicting.append(&mut root_conflicts);
748 }
749 for member in self.packages.values() {
750 conflicting.append(&mut member.pyproject_toml.conflicts()?);
751 }
752 Ok(conflicting)
753 }
754
755 pub fn requires_python(
757 &self,
758 groups: &DependencyGroupsWithDefaults,
759 ) -> Result<RequiresPythonSources, DependencyGroupError> {
760 let mut requires = RequiresPythonSources::new();
761 for (name, member) in self.packages() {
762 let top_requires = member
768 .pyproject_toml()
769 .project
770 .as_ref()
771 .and_then(|project| project.requires_python.as_ref())
772 .map(|requires_python| ((name.to_owned(), None), requires_python.clone()));
773 requires.extend(top_requires);
774
775 let dependency_groups =
778 FlatDependencyGroups::from_pyproject_toml(member.root(), &member.pyproject_toml)?;
779 let group_requires =
780 dependency_groups
781 .into_iter()
782 .filter_map(move |(group_name, flat_group)| {
783 if groups.contains(&group_name) {
784 flat_group.requires_python.map(|requires_python| {
785 ((name.to_owned(), Some(group_name)), requires_python)
786 })
787 } else {
788 None
789 }
790 });
791 requires.extend(group_requires);
792 }
793 Ok(requires)
794 }
795
796 pub fn requirements(&self) -> Vec<uv_pep508::Requirement<VerbatimParsedUrl>> {
801 Vec::new()
802 }
803
804 pub fn workspace_dependency_groups(
812 &self,
813 ) -> Result<BTreeMap<GroupName, FlatDependencyGroup>, DependencyGroupError> {
814 if self
815 .packages
816 .values()
817 .any(|member| *member.root() == self.install_path)
818 {
819 Ok(BTreeMap::default())
822 } else {
823 let dependency_groups = FlatDependencyGroups::from_pyproject_toml(
825 &self.install_path,
826 &self.pyproject_toml,
827 )?;
828 Ok(dependency_groups.into_inner())
829 }
830 }
831
832 pub fn overrides(&self) -> Vec<OverrideDependency> {
834 let Some(overrides) = self
835 .pyproject_toml
836 .tool
837 .as_ref()
838 .and_then(|tool| tool.uv.as_ref())
839 .and_then(|uv| uv.override_dependencies.as_ref())
840 else {
841 return vec![];
842 };
843 overrides.clone()
844 }
845
846 pub fn exclude_dependencies(&self) -> Vec<ExcludeDependency> {
848 let Some(excludes) = self
849 .pyproject_toml
850 .tool
851 .as_ref()
852 .and_then(|tool| tool.uv.as_ref())
853 .and_then(|uv| uv.exclude_dependencies.as_ref())
854 else {
855 return vec![];
856 };
857 excludes.clone()
858 }
859
860 pub fn constraints(&self) -> Vec<uv_pep508::Requirement<VerbatimParsedUrl>> {
862 let Some(constraints) = self
863 .pyproject_toml
864 .tool
865 .as_ref()
866 .and_then(|tool| tool.uv.as_ref())
867 .and_then(|uv| uv.constraint_dependencies.as_ref())
868 else {
869 return vec![];
870 };
871 constraints.clone()
872 }
873
874 pub fn build_constraints(&self) -> Vec<uv_pep508::Requirement<VerbatimParsedUrl>> {
876 let Some(build_constraints) = self
877 .pyproject_toml
878 .tool
879 .as_ref()
880 .and_then(|tool| tool.uv.as_ref())
881 .and_then(|uv| uv.build_constraint_dependencies.as_ref())
882 else {
883 return vec![];
884 };
885 build_constraints.clone()
886 }
887
888 pub fn install_path(&self) -> &PathBuf {
891 &self.install_path
892 }
893
894 pub fn environment_selection(&self, active: Option<bool>) -> ProjectEnvironmentSelection {
903 fn from_project_environment_variable(workspace: &Workspace) -> Option<PathBuf> {
905 let value = std::env::var_os(EnvVars::UV_PROJECT_ENVIRONMENT)?;
906
907 if value.is_empty() {
908 return None;
909 }
910
911 let path = PathBuf::from(value);
912 if path.is_absolute() {
913 return Some(path);
914 }
915
916 Some(workspace.install_path.join(path))
918 }
919
920 fn from_virtual_env_variable() -> Option<PathBuf> {
922 let value = std::env::var_os(EnvVars::VIRTUAL_ENV)?;
923
924 if value.is_empty() {
925 return None;
926 }
927
928 let path = PathBuf::from(value);
929 if path.is_absolute() {
930 return Some(path);
931 }
932
933 Some(CWD.join(path))
936 }
937
938 let selection = from_project_environment_variable(self)
939 .map(ProjectEnvironmentSelection::Override)
940 .unwrap_or(ProjectEnvironmentSelection::Default);
941 let project_environment_path = selection
942 .explicit_path()
943 .map_or_else(|| self.install_path.join(".venv"), Path::to_path_buf);
944
945 if let Some(from_virtual_env) = from_virtual_env_variable() {
947 let matches_project =
948 uv_fs::is_same_file_allow_missing(&from_virtual_env, &project_environment_path)
949 .unwrap_or(false);
950 match active {
951 Some(true) => {
952 if !matches_project {
953 debug!(
954 "Using active virtual environment `{}` instead of project environment `{}`",
955 from_virtual_env.user_display(),
956 project_environment_path.user_display()
957 );
958 }
959 return ProjectEnvironmentSelection::Active(from_virtual_env);
960 }
961 Some(false) => {}
962 None if !matches_project => {
963 warn_user_once!(
964 "`VIRTUAL_ENV={}` does not match the project environment path `{}` and will be ignored; use `--active` to target the active environment instead",
965 from_virtual_env.user_display(),
966 project_environment_path.user_display()
967 );
968 }
969 None => {}
970 }
971 } else {
972 if active.unwrap_or_default() {
973 debug!(
974 "Use of the active virtual environment was requested, but `VIRTUAL_ENV` is not set"
975 );
976 }
977 }
978
979 selection
980 }
981
982 pub fn packages(&self) -> &BTreeMap<PackageName, WorkspaceMember> {
984 &self.packages
985 }
986
987 pub fn sources(&self) -> &BTreeMap<PackageName, Sources> {
989 &self.sources
990 }
991
992 pub fn indexes(&self) -> &[Index] {
994 &self.indexes
995 }
996
997 pub fn pyproject_toml(&self) -> &PyProjectToml {
999 &self.pyproject_toml
1000 }
1001
1002 pub fn excludes(&self, project_path: &Path) -> Result<bool, WorkspaceError> {
1004 if let Some(workspace) = self
1005 .pyproject_toml
1006 .tool
1007 .as_ref()
1008 .and_then(|tool| tool.uv.as_ref())
1009 .and_then(|uv| uv.workspace.as_ref())
1010 {
1011 is_excluded_from_workspace(project_path, &self.install_path, workspace)
1012 } else {
1013 Ok(false)
1014 }
1015 }
1016
1017 pub fn includes(&self, project_path: &Path) -> Result<bool, WorkspaceError> {
1019 if let Some(workspace) = self
1020 .pyproject_toml
1021 .tool
1022 .as_ref()
1023 .and_then(|tool| tool.uv.as_ref())
1024 .and_then(|uv| uv.workspace.as_ref())
1025 {
1026 is_included_in_workspace(project_path, &self.install_path, workspace)
1027 } else {
1028 Ok(false)
1029 }
1030 }
1031
1032 async fn build(
1034 workspace_root: PathBuf,
1035 workspace_definition: ToolUvWorkspace,
1036 workspace_pyproject_toml: PyProjectToml,
1037 current_project: Option<WorkspaceMember>,
1038 options: &DiscoveryOptions,
1039 cache: &Cache,
1040 ) -> Result<Arc<Self>, WorkspaceError> {
1041 trace!(
1042 "Discovering workspace members for: `{}`",
1043 &workspace_root.simplified_display()
1044 );
1045 let workspace_members = Self::collect_members_only(
1046 &workspace_root,
1047 &workspace_definition,
1048 &workspace_pyproject_toml,
1049 options,
1050 cache,
1051 )
1052 .await?;
1053 let mut workspace_members = Arc::new(workspace_members);
1054
1055 if let Some(root_member) = current_project
1057 && !workspace_members.contains_key(&root_member.project.name)
1058 {
1059 assert!(matches!(
1060 options.members,
1061 MemberDiscovery::None | MemberDiscovery::Ignore(_)
1062 ));
1063 debug!(
1064 "Adding current workspace member: `{}`",
1065 root_member.root.simplified_display()
1066 );
1067
1068 Arc::make_mut(&mut workspace_members)
1069 .insert(root_member.project.name.clone(), root_member);
1070 }
1071
1072 let workspace_sources = workspace_pyproject_toml
1073 .tool
1074 .clone()
1075 .and_then(|tool| tool.uv)
1076 .and_then(|uv| uv.sources)
1077 .map(ToolUvSources::into_inner)
1078 .unwrap_or_default();
1079
1080 let workspace_indexes = workspace_pyproject_toml
1081 .tool
1082 .clone()
1083 .and_then(|tool| tool.uv)
1084 .and_then(|uv| uv.index)
1085 .unwrap_or_default();
1086
1087 let required_members = Self::collect_required_members(
1088 &workspace_members,
1089 &workspace_sources,
1090 &workspace_pyproject_toml,
1091 )?;
1092
1093 let dev_dependencies_members = workspace_members
1094 .values()
1095 .filter_map(|member| {
1096 member
1097 .pyproject_toml
1098 .tool
1099 .as_ref()
1100 .and_then(|tool| tool.uv.as_ref())
1101 .and_then(|uv| uv.dev_dependencies.as_ref())
1102 .map(|_| format!("`{}`", member.root().join("pyproject.toml").user_display()))
1103 })
1104 .join(", ");
1105 if !dev_dependencies_members.is_empty() {
1106 warn_user_once!(
1107 "The `tool.uv.dev-dependencies` field (used in {}) is deprecated and will be removed in a future release; use `dependency-groups.dev` instead",
1108 dev_dependencies_members
1109 );
1110 }
1111
1112 let workspace = Self {
1113 install_path: workspace_root,
1114 packages: workspace_members,
1115 required_members,
1116 sources: workspace_sources,
1117 indexes: workspace_indexes,
1118 pyproject_toml: workspace_pyproject_toml,
1119 };
1120 Ok(Arc::new(workspace))
1121 }
1122
1123 async fn collect_members_only(
1124 workspace_root: &PathBuf,
1125 workspace_definition: &ToolUvWorkspace,
1126 workspace_pyproject_toml: &PyProjectToml,
1127 options: &DiscoveryOptions,
1128 cache: &Cache,
1129 ) -> Result<BTreeMap<PackageName, WorkspaceMember>, WorkspaceError> {
1130 let mut workspace_members = BTreeMap::new();
1131 let mut seen = FxHashSet::default();
1133
1134 let external_cache_root = options
1135 .stop_discovery_at
1136 .is_none()
1137 .then(|| {
1138 let cache_root = if cache.root().is_absolute() {
1140 cache.root().to_path_buf()
1141 } else {
1142 CWD.join(cache.root())
1143 };
1144 normalize_path(&cache_root).into_owned()
1145 })
1146 .filter(|cache_root| !workspace_root.starts_with(cache_root));
1147
1148 if let Some(project) = &workspace_pyproject_toml.project {
1151 debug!(
1152 "Adding root workspace member: `{}`",
1153 workspace_root.simplified_display()
1154 );
1155
1156 seen.insert(workspace_root.clone());
1157 workspace_members.insert(
1158 project.name.clone(),
1159 WorkspaceMember {
1160 root: workspace_root.clone(),
1161 project: project.clone(),
1162 pyproject_toml: workspace_pyproject_toml.clone(),
1163 },
1164 );
1165 }
1166
1167 let mut exclusions = None;
1169
1170 for member_glob in workspace_definition.clone().members.unwrap_or_default() {
1172 let normalized_glob = normalize_path(Path::new(member_glob.as_str()));
1174 let absolute_glob = PathBuf::from(glob::Pattern::escape(
1175 workspace_root.simplified().to_string_lossy().as_ref(),
1176 ))
1177 .join(normalized_glob.as_ref())
1178 .to_string_lossy()
1179 .to_string();
1180 for member_root in glob(&absolute_glob)
1181 .map_err(|err| WorkspaceErrorKind::Pattern(absolute_glob.clone(), err))?
1182 {
1183 let member_root = member_root
1184 .map_err(|err| WorkspaceErrorKind::GlobWalk(absolute_glob.clone(), err))?;
1185 if external_cache_root
1186 .as_ref()
1187 .is_some_and(|cache_root| member_root.starts_with(cache_root))
1188 {
1189 debug!(
1190 "Ignoring cache directory while discovering workspace members: `{}`",
1191 member_root.simplified_display()
1192 );
1193 continue;
1194 }
1195 if !seen.insert(member_root.clone()) {
1196 continue;
1197 }
1198 let member_root = std::path::absolute(&member_root)
1199 .map_err(WorkspaceErrorKind::Normalize)?
1200 .clone();
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}