1use std::collections::{BTreeMap, BTreeSet};
4use std::path::{Path, PathBuf};
5use std::sync::{Arc, Mutex};
6
7use glob::{GlobError, PatternError, glob};
8use itertools::Itertools;
9use rustc_hash::{FxHashMap, FxHashSet};
10use tracing::{debug, trace, warn};
11
12use uv_configuration::DependencyGroupsWithDefaults;
13use uv_distribution_types::{Index, Requirement, RequirementSource};
14use uv_fs::{CWD, Simplified};
15use uv_normalize::{DEV_DEPENDENCIES, GroupName, PackageName};
16use uv_pep440::VersionSpecifiers;
17use uv_pep508::{MarkerTree, VerbatimUrl};
18use uv_pypi_types::{Conflicts, SupportedEnvironments, VerbatimParsedUrl};
19use uv_static::EnvVars;
20use uv_warnings::warn_user_once;
21
22use crate::dependency_groups::{DependencyGroupError, FlatDependencyGroup, FlatDependencyGroups};
23use crate::pyproject::{
24 Project, PyProjectToml, PyprojectTomlError, Source, Sources, ToolUvSources, ToolUvWorkspace,
25};
26
27type WorkspaceMembers = Arc<BTreeMap<PackageName, WorkspaceMember>>;
28
29#[derive(Debug, Default, Clone, Hash, PartialEq, Eq)]
33struct WorkspaceCacheKey {
34 workspace_root: PathBuf,
35 discovery_options: DiscoveryOptions,
36}
37
38#[derive(Debug, Default, Clone)]
43pub struct WorkspaceCache(Arc<Mutex<FxHashMap<WorkspaceCacheKey, WorkspaceMembers>>>);
44
45#[derive(thiserror::Error, Debug)]
46pub enum WorkspaceError {
47 #[error("No `pyproject.toml` found in current directory or any parent directory")]
49 MissingPyprojectToml,
50 #[error("Workspace member `{}` is missing a `pyproject.toml` (matches: `{}`)", _0.simplified_display(), _1)]
51 MissingPyprojectTomlMember(PathBuf, String),
52 #[error("No `project` table found in: {}", _0.simplified_display())]
53 MissingProject(PathBuf),
54 #[error("No workspace found for: {}", _0.simplified_display())]
55 MissingWorkspace(PathBuf),
56 #[error("The project is marked as unmanaged: {}", _0.simplified_display())]
57 NonWorkspace(PathBuf),
58 #[error("Nested workspaces are not supported, but workspace member has a `tool.uv.workspace` table: {}", _0.simplified_display())]
59 NestedWorkspace(PathBuf),
60 #[error("The workspace does not have a member {}: {}", _0, _1.simplified_display())]
61 NoSuchMember(PackageName, PathBuf),
62 #[error("Two workspace members are both named `{name}`: `{}` and `{}`", first.simplified_display(), second.simplified_display())]
63 DuplicatePackage {
64 name: PackageName,
65 first: PathBuf,
66 second: PathBuf,
67 },
68 #[error("pyproject.toml section is declared as dynamic, but must be static: `{0}`")]
69 DynamicNotAllowed(&'static str),
70 #[error(
71 "Workspace member `{}` was requested as both `editable = true` and `editable = false`",
72 _0
73 )]
74 EditableConflict(PackageName),
75 #[error("Failed to find directories for glob: `{0}`")]
76 Pattern(String, #[source] PatternError),
77 #[error("Directory walking failed for `tool.uv.workspace.members` glob: `{0}`")]
79 GlobWalk(String, #[source] GlobError),
80 #[error(transparent)]
81 Io(#[from] std::io::Error),
82 #[error("Failed to parse: `{}`", _0.user_display())]
83 Toml(PathBuf, #[source] Box<PyprojectTomlError>),
84 #[error("Failed to normalize workspace member path")]
85 Normalize(#[source] std::io::Error),
86}
87
88#[derive(Debug, Default, Clone, Hash, PartialEq, Eq)]
89pub enum MemberDiscovery {
90 #[default]
92 All,
93 None,
95 Ignore(BTreeSet<PathBuf>),
97}
98
99#[derive(Debug, Default, Clone, Hash, PartialEq, Eq)]
101pub enum ProjectDiscovery {
102 #[default]
105 Optional,
106 Required,
110}
111
112impl ProjectDiscovery {
113 pub fn allows_implicit_workspace(&self) -> bool {
115 matches!(self, Self::Optional)
116 }
117
118 pub fn allows_non_project_workspace(&self) -> bool {
120 matches!(self, Self::Optional)
121 }
122}
123
124#[derive(Debug, Default, Clone, Hash, PartialEq, Eq)]
125pub struct DiscoveryOptions {
126 pub stop_discovery_at: Option<PathBuf>,
128 pub members: MemberDiscovery,
130 pub project: ProjectDiscovery,
132}
133
134pub type RequiresPythonSources = BTreeMap<(PackageName, Option<GroupName>), VersionSpecifiers>;
135
136pub type Editability = Option<bool>;
137
138#[derive(Debug, Clone)]
140#[cfg_attr(test, derive(serde::Serialize))]
141pub struct Workspace {
142 install_path: PathBuf,
147 packages: WorkspaceMembers,
149 required_members: BTreeMap<PackageName, Editability>,
152 sources: BTreeMap<PackageName, Sources>,
156 indexes: Vec<Index>,
160 pyproject_toml: PyProjectToml,
162}
163
164impl Workspace {
165 pub async fn discover(
187 path: &Path,
188 options: &DiscoveryOptions,
189 cache: &WorkspaceCache,
190 ) -> Result<Self, WorkspaceError> {
191 let path = std::path::absolute(path)
192 .map_err(WorkspaceError::Normalize)?
193 .clone();
194 let path = uv_fs::normalize_path(&path);
196 let path = path.components().collect::<PathBuf>();
198
199 let project_path = path
200 .ancestors()
201 .find(|path| path.join("pyproject.toml").is_file())
202 .ok_or(WorkspaceError::MissingPyprojectToml)?
203 .to_path_buf();
204
205 let pyproject_path = project_path.join("pyproject.toml");
206 let contents = fs_err::tokio::read_to_string(&pyproject_path).await?;
207 let pyproject_toml = PyProjectToml::from_string(contents, &pyproject_path)
208 .map_err(|err| WorkspaceError::Toml(pyproject_path.clone(), Box::new(err)))?;
209
210 if pyproject_toml
212 .tool
213 .as_ref()
214 .and_then(|tool| tool.uv.as_ref())
215 .and_then(|uv| uv.managed)
216 == Some(false)
217 {
218 debug!(
219 "Project `{}` is marked as unmanaged",
220 project_path.simplified_display()
221 );
222 return Err(WorkspaceError::NonWorkspace(project_path));
223 }
224
225 let explicit_root = pyproject_toml
227 .tool
228 .as_ref()
229 .and_then(|tool| tool.uv.as_ref())
230 .and_then(|uv| uv.workspace.as_ref())
231 .map(|workspace| {
232 (
233 project_path.clone(),
234 workspace.clone(),
235 pyproject_toml.clone(),
236 )
237 });
238
239 let (workspace_root, workspace_definition, workspace_pyproject_toml) =
240 if let Some(workspace) = explicit_root {
241 workspace
243 } else if pyproject_toml.project.is_none() {
244 return Err(WorkspaceError::MissingProject(pyproject_path));
246 } else if let Some(workspace) = find_workspace(&project_path, options).await? {
247 workspace
249 } else {
250 (
252 project_path.clone(),
253 ToolUvWorkspace::default(),
254 pyproject_toml.clone(),
255 )
256 };
257
258 debug!(
259 "Found workspace root: `{}`",
260 workspace_root.simplified_display()
261 );
262
263 let current_project = pyproject_toml
266 .project
267 .clone()
268 .map(|project| WorkspaceMember {
269 root: project_path,
270 project,
271 pyproject_toml,
272 });
273
274 Self::collect_members(
275 workspace_root.clone(),
276 workspace_definition,
277 workspace_pyproject_toml,
278 current_project,
279 options,
280 cache,
281 )
282 .await
283 }
284
285 pub fn with_current_project(self, package_name: PackageName) -> Option<ProjectWorkspace> {
289 let member = self.packages.get(&package_name)?;
290 Some(ProjectWorkspace {
291 project_root: member.root().clone(),
292 project_name: package_name,
293 workspace: self,
294 })
295 }
296
297 pub fn update_member(
301 self,
302 package_name: &PackageName,
303 pyproject_toml: PyProjectToml,
304 ) -> Result<Option<Self>, WorkspaceError> {
305 let mut packages = self.packages;
306
307 let Some(member) = Arc::make_mut(&mut packages).get_mut(package_name) else {
308 return Ok(None);
309 };
310
311 if member.root == self.install_path {
312 let workspace_pyproject_toml = pyproject_toml.clone();
315
316 let workspace_sources = workspace_pyproject_toml
318 .tool
319 .clone()
320 .and_then(|tool| tool.uv)
321 .and_then(|uv| uv.sources)
322 .map(ToolUvSources::into_inner)
323 .unwrap_or_default();
324
325 member.pyproject_toml = pyproject_toml;
327
328 let required_members = Self::collect_required_members(
330 &packages,
331 &workspace_sources,
332 &workspace_pyproject_toml,
333 )?;
334
335 Ok(Some(Self {
336 pyproject_toml: workspace_pyproject_toml,
337 sources: workspace_sources,
338 packages,
339 required_members,
340 ..self
341 }))
342 } else {
343 member.pyproject_toml = pyproject_toml;
345
346 let required_members =
348 Self::collect_required_members(&packages, &self.sources, &self.pyproject_toml)?;
349
350 Ok(Some(Self {
351 packages,
352 required_members,
353 ..self
354 }))
355 }
356 }
357
358 pub fn is_non_project(&self) -> bool {
360 !self
361 .packages
362 .values()
363 .any(|member| *member.root() == self.install_path)
364 }
365
366 pub fn members_requirements(&self) -> impl Iterator<Item = Requirement> + '_ {
368 self.packages.iter().filter_map(|(name, member)| {
369 let url = VerbatimUrl::from_absolute_path(&member.root)
370 .expect("path is valid URL")
371 .with_given(member.root.to_string_lossy());
372 Some(Requirement {
373 name: member.pyproject_toml.project.as_ref()?.name.clone(),
374 extras: Box::new([]),
375 groups: Box::new([]),
376 marker: MarkerTree::TRUE,
377 source: if member
378 .pyproject_toml()
379 .is_package(!self.is_required_member(name))
380 {
381 RequirementSource::Directory {
382 install_path: member.root.clone().into_boxed_path(),
383 editable: Some(
384 self.required_members
385 .get(name)
386 .copied()
387 .flatten()
388 .unwrap_or(true),
389 ),
390 r#virtual: Some(false),
391 url,
392 }
393 } else {
394 RequirementSource::Directory {
395 install_path: member.root.clone().into_boxed_path(),
396 editable: Some(false),
397 r#virtual: Some(true),
398 url,
399 }
400 },
401 origin: None,
402 })
403 })
404 }
405
406 pub fn required_members(&self) -> &BTreeMap<PackageName, Editability> {
408 &self.required_members
409 }
410
411 fn collect_required_members(
418 packages: &BTreeMap<PackageName, WorkspaceMember>,
419 sources: &BTreeMap<PackageName, Sources>,
420 pyproject_toml: &PyProjectToml,
421 ) -> Result<BTreeMap<PackageName, Editability>, WorkspaceError> {
422 let mut required_members = BTreeMap::new();
423
424 for (package, sources) in sources
425 .iter()
426 .filter(|(name, _)| {
427 pyproject_toml
428 .project
429 .as_ref()
430 .is_none_or(|project| project.name != **name)
431 })
432 .chain(
433 packages
434 .iter()
435 .filter_map(|(name, member)| {
436 member
437 .pyproject_toml
438 .tool
439 .as_ref()
440 .and_then(|tool| tool.uv.as_ref())
441 .and_then(|uv| uv.sources.as_ref())
442 .map(ToolUvSources::inner)
443 .map(move |sources| {
444 sources
445 .iter()
446 .filter(move |(source_name, _)| name != *source_name)
447 })
448 })
449 .flatten(),
450 )
451 {
452 for source in sources.iter() {
453 let Source::Workspace { editable, .. } = &source else {
454 continue;
455 };
456 let existing = required_members.insert(package.clone(), *editable);
457 if let Some(Some(existing)) = existing {
458 if let Some(editable) = editable {
459 if existing != *editable {
461 return Err(WorkspaceError::EditableConflict(package.clone()));
462 }
463 }
464 }
465 }
466 }
467
468 Ok(required_members)
469 }
470
471 pub fn is_required_member(&self, name: &PackageName) -> bool {
473 self.required_members().contains_key(name)
474 }
475
476 pub fn group_requirements(&self) -> impl Iterator<Item = Requirement> + '_ {
478 self.packages.iter().filter_map(|(name, member)| {
479 let url = VerbatimUrl::from_absolute_path(&member.root)
480 .expect("path is valid URL")
481 .with_given(member.root.to_string_lossy());
482
483 let groups = {
484 let mut groups = member
485 .pyproject_toml
486 .dependency_groups
487 .as_ref()
488 .map(|groups| groups.keys().cloned().collect::<Vec<_>>())
489 .unwrap_or_default();
490 if member
491 .pyproject_toml
492 .tool
493 .as_ref()
494 .and_then(|tool| tool.uv.as_ref())
495 .and_then(|uv| uv.dev_dependencies.as_ref())
496 .is_some()
497 {
498 groups.push(DEV_DEPENDENCIES.clone());
499 groups.sort_unstable();
500 }
501 groups
502 };
503 if groups.is_empty() {
504 return None;
505 }
506
507 let value = self.required_members.get(name);
508 let is_required_member = value.is_some();
509 let editability = value.copied().flatten();
510
511 Some(Requirement {
512 name: member.pyproject_toml.project.as_ref()?.name.clone(),
513 extras: Box::new([]),
514 groups: groups.into_boxed_slice(),
515 marker: MarkerTree::TRUE,
516 source: if member.pyproject_toml().is_package(!is_required_member) {
517 RequirementSource::Directory {
518 install_path: member.root.clone().into_boxed_path(),
519 editable: Some(editability.unwrap_or(true)),
520 r#virtual: Some(false),
521 url,
522 }
523 } else {
524 RequirementSource::Directory {
525 install_path: member.root.clone().into_boxed_path(),
526 editable: Some(false),
527 r#virtual: Some(true),
528 url,
529 }
530 },
531 origin: None,
532 })
533 })
534 }
535
536 pub fn environments(&self) -> Option<&SupportedEnvironments> {
538 self.pyproject_toml
539 .tool
540 .as_ref()
541 .and_then(|tool| tool.uv.as_ref())
542 .and_then(|uv| uv.environments.as_ref())
543 }
544
545 pub fn required_environments(&self) -> Option<&SupportedEnvironments> {
547 self.pyproject_toml
548 .tool
549 .as_ref()
550 .and_then(|tool| tool.uv.as_ref())
551 .and_then(|uv| uv.required_environments.as_ref())
552 }
553
554 pub fn conflicts(&self) -> Conflicts {
556 let mut conflicting = Conflicts::empty();
557 for member in self.packages.values() {
558 conflicting.append(&mut member.pyproject_toml.conflicts());
559 }
560 conflicting
561 }
562
563 pub fn requires_python(
565 &self,
566 groups: &DependencyGroupsWithDefaults,
567 ) -> Result<RequiresPythonSources, DependencyGroupError> {
568 let mut requires = RequiresPythonSources::new();
569 for (name, member) in self.packages() {
570 let top_requires = member
576 .pyproject_toml()
577 .project
578 .as_ref()
579 .and_then(|project| project.requires_python.as_ref())
580 .map(|requires_python| ((name.to_owned(), None), requires_python.clone()));
581 requires.extend(top_requires);
582
583 let dependency_groups =
586 FlatDependencyGroups::from_pyproject_toml(member.root(), &member.pyproject_toml)?;
587 let group_requires =
588 dependency_groups
589 .into_iter()
590 .filter_map(move |(group_name, flat_group)| {
591 if groups.contains(&group_name) {
592 flat_group.requires_python.map(|requires_python| {
593 ((name.to_owned(), Some(group_name)), requires_python)
594 })
595 } else {
596 None
597 }
598 });
599 requires.extend(group_requires);
600 }
601 Ok(requires)
602 }
603
604 pub fn requirements(&self) -> Vec<uv_pep508::Requirement<VerbatimParsedUrl>> {
609 Vec::new()
610 }
611
612 pub fn workspace_dependency_groups(
620 &self,
621 ) -> Result<BTreeMap<GroupName, FlatDependencyGroup>, DependencyGroupError> {
622 if self
623 .packages
624 .values()
625 .any(|member| *member.root() == self.install_path)
626 {
627 Ok(BTreeMap::default())
630 } else {
631 let dependency_groups = FlatDependencyGroups::from_pyproject_toml(
633 &self.install_path,
634 &self.pyproject_toml,
635 )?;
636 Ok(dependency_groups.into_inner())
637 }
638 }
639
640 pub fn overrides(&self) -> Vec<uv_pep508::Requirement<VerbatimParsedUrl>> {
642 let Some(overrides) = self
643 .pyproject_toml
644 .tool
645 .as_ref()
646 .and_then(|tool| tool.uv.as_ref())
647 .and_then(|uv| uv.override_dependencies.as_ref())
648 else {
649 return vec![];
650 };
651 overrides.clone()
652 }
653
654 pub fn exclude_dependencies(&self) -> Vec<uv_normalize::PackageName> {
656 let Some(excludes) = self
657 .pyproject_toml
658 .tool
659 .as_ref()
660 .and_then(|tool| tool.uv.as_ref())
661 .and_then(|uv| uv.exclude_dependencies.as_ref())
662 else {
663 return vec![];
664 };
665 excludes.clone()
666 }
667
668 pub fn constraints(&self) -> Vec<uv_pep508::Requirement<VerbatimParsedUrl>> {
670 let Some(constraints) = self
671 .pyproject_toml
672 .tool
673 .as_ref()
674 .and_then(|tool| tool.uv.as_ref())
675 .and_then(|uv| uv.constraint_dependencies.as_ref())
676 else {
677 return vec![];
678 };
679 constraints.clone()
680 }
681
682 pub fn build_constraints(&self) -> Vec<uv_pep508::Requirement<VerbatimParsedUrl>> {
684 let Some(build_constraints) = self
685 .pyproject_toml
686 .tool
687 .as_ref()
688 .and_then(|tool| tool.uv.as_ref())
689 .and_then(|uv| uv.build_constraint_dependencies.as_ref())
690 else {
691 return vec![];
692 };
693 build_constraints.clone()
694 }
695
696 pub fn install_path(&self) -> &PathBuf {
699 &self.install_path
700 }
701
702 pub fn venv(&self, active: Option<bool>) -> PathBuf {
713 fn from_project_environment_variable(workspace: &Workspace) -> Option<PathBuf> {
715 let value = std::env::var_os(EnvVars::UV_PROJECT_ENVIRONMENT)?;
716
717 if value.is_empty() {
718 return None;
719 }
720
721 let path = PathBuf::from(value);
722 if path.is_absolute() {
723 return Some(path);
724 }
725
726 Some(workspace.install_path.join(path))
728 }
729
730 fn from_virtual_env_variable() -> Option<PathBuf> {
732 let value = std::env::var_os(EnvVars::VIRTUAL_ENV)?;
733
734 if value.is_empty() {
735 return None;
736 }
737
738 let path = PathBuf::from(value);
739 if path.is_absolute() {
740 return Some(path);
741 }
742
743 Some(CWD.join(path))
746 }
747
748 let project_env = from_project_environment_variable(self)
750 .unwrap_or_else(|| self.install_path.join(".venv"));
751
752 if let Some(from_virtual_env) = from_virtual_env_variable() {
754 if !uv_fs::is_same_file_allow_missing(&from_virtual_env, &project_env).unwrap_or(false)
755 {
756 match active {
757 Some(true) => {
758 debug!(
759 "Using active virtual environment `{}` instead of project environment `{}`",
760 from_virtual_env.user_display(),
761 project_env.user_display()
762 );
763 return from_virtual_env;
764 }
765 Some(false) => {}
766 None => {
767 warn_user_once!(
768 "`VIRTUAL_ENV={}` does not match the project environment path `{}` and will be ignored; use `--active` to target the active environment instead",
769 from_virtual_env.user_display(),
770 project_env.user_display()
771 );
772 }
773 }
774 }
775 } else {
776 if active.unwrap_or_default() {
777 debug!(
778 "Use of the active virtual environment was requested, but `VIRTUAL_ENV` is not set"
779 );
780 }
781 }
782
783 project_env
784 }
785
786 pub fn packages(&self) -> &BTreeMap<PackageName, WorkspaceMember> {
788 &self.packages
789 }
790
791 pub fn sources(&self) -> &BTreeMap<PackageName, Sources> {
793 &self.sources
794 }
795
796 pub fn indexes(&self) -> &[Index] {
798 &self.indexes
799 }
800
801 pub fn pyproject_toml(&self) -> &PyProjectToml {
803 &self.pyproject_toml
804 }
805
806 pub fn excludes(&self, project_path: &Path) -> Result<bool, WorkspaceError> {
808 if let Some(workspace) = self
809 .pyproject_toml
810 .tool
811 .as_ref()
812 .and_then(|tool| tool.uv.as_ref())
813 .and_then(|uv| uv.workspace.as_ref())
814 {
815 is_excluded_from_workspace(project_path, &self.install_path, workspace)
816 } else {
817 Ok(false)
818 }
819 }
820
821 pub fn includes(&self, project_path: &Path) -> Result<bool, WorkspaceError> {
823 if let Some(workspace) = self
824 .pyproject_toml
825 .tool
826 .as_ref()
827 .and_then(|tool| tool.uv.as_ref())
828 .and_then(|uv| uv.workspace.as_ref())
829 {
830 is_included_in_workspace(project_path, &self.install_path, workspace)
831 } else {
832 Ok(false)
833 }
834 }
835
836 async fn collect_members(
838 workspace_root: PathBuf,
839 workspace_definition: ToolUvWorkspace,
840 workspace_pyproject_toml: PyProjectToml,
841 current_project: Option<WorkspaceMember>,
842 options: &DiscoveryOptions,
843 cache: &WorkspaceCache,
844 ) -> Result<Self, WorkspaceError> {
845 let cache_key = WorkspaceCacheKey {
846 workspace_root: workspace_root.clone(),
847 discovery_options: options.clone(),
848 };
849 let cache_entry = {
850 let cache = cache.0.lock().expect("there was a panic in another thread");
852 cache.get(&cache_key).cloned()
853 };
854 let mut workspace_members = if let Some(workspace_members) = cache_entry {
855 trace!(
856 "Cached workspace members for: `{}`",
857 &workspace_root.simplified_display()
858 );
859 workspace_members
860 } else {
861 trace!(
862 "Discovering workspace members for: `{}`",
863 &workspace_root.simplified_display()
864 );
865 let workspace_members = Self::collect_members_only(
866 &workspace_root,
867 &workspace_definition,
868 &workspace_pyproject_toml,
869 options,
870 )
871 .await?;
872 {
873 let mut cache = cache.0.lock().expect("there was a panic in another thread");
875 cache.insert(cache_key, Arc::new(workspace_members.clone()));
876 }
877 Arc::new(workspace_members)
878 };
879
880 if let Some(root_member) = current_project {
882 if !workspace_members.contains_key(&root_member.project.name) {
883 debug!(
884 "Adding current workspace member: `{}`",
885 root_member.root.simplified_display()
886 );
887
888 Arc::make_mut(&mut workspace_members)
889 .insert(root_member.project.name.clone(), root_member);
890 }
891 }
892
893 let workspace_sources = workspace_pyproject_toml
894 .tool
895 .clone()
896 .and_then(|tool| tool.uv)
897 .and_then(|uv| uv.sources)
898 .map(ToolUvSources::into_inner)
899 .unwrap_or_default();
900
901 let workspace_indexes = workspace_pyproject_toml
902 .tool
903 .clone()
904 .and_then(|tool| tool.uv)
905 .and_then(|uv| uv.index)
906 .unwrap_or_default();
907
908 let required_members = Self::collect_required_members(
909 &workspace_members,
910 &workspace_sources,
911 &workspace_pyproject_toml,
912 )?;
913
914 let dev_dependencies_members = workspace_members
915 .iter()
916 .filter_map(|(_, member)| {
917 member
918 .pyproject_toml
919 .tool
920 .as_ref()
921 .and_then(|tool| tool.uv.as_ref())
922 .and_then(|uv| uv.dev_dependencies.as_ref())
923 .map(|_| format!("`{}`", member.root().join("pyproject.toml").user_display()))
924 })
925 .join(", ");
926 if !dev_dependencies_members.is_empty() {
927 warn_user_once!(
928 "The `tool.uv.dev-dependencies` field (used in {}) is deprecated and will be removed in a future release; use `dependency-groups.dev` instead",
929 dev_dependencies_members
930 );
931 }
932
933 Ok(Self {
934 install_path: workspace_root,
935 packages: workspace_members,
936 required_members,
937 sources: workspace_sources,
938 indexes: workspace_indexes,
939 pyproject_toml: workspace_pyproject_toml,
940 })
941 }
942
943 async fn collect_members_only(
944 workspace_root: &PathBuf,
945 workspace_definition: &ToolUvWorkspace,
946 workspace_pyproject_toml: &PyProjectToml,
947 options: &DiscoveryOptions,
948 ) -> Result<BTreeMap<PackageName, WorkspaceMember>, WorkspaceError> {
949 let mut workspace_members = BTreeMap::new();
950 let mut seen = FxHashSet::default();
952
953 if let Some(project) = &workspace_pyproject_toml.project {
956 let pyproject_path = workspace_root.join("pyproject.toml");
957 let contents = fs_err::read_to_string(&pyproject_path)?;
958 let pyproject_toml = PyProjectToml::from_string(contents, &pyproject_path)
959 .map_err(|err| WorkspaceError::Toml(pyproject_path.clone(), Box::new(err)))?;
960
961 debug!(
962 "Adding root workspace member: `{}`",
963 workspace_root.simplified_display()
964 );
965
966 seen.insert(workspace_root.clone());
967 workspace_members.insert(
968 project.name.clone(),
969 WorkspaceMember {
970 root: workspace_root.clone(),
971 project: project.clone(),
972 pyproject_toml,
973 },
974 );
975 }
976
977 for member_glob in workspace_definition.clone().members.unwrap_or_default() {
979 let normalized_glob = uv_fs::normalize_path(Path::new(member_glob.as_str()));
981 let absolute_glob = PathBuf::from(glob::Pattern::escape(
982 workspace_root.simplified().to_string_lossy().as_ref(),
983 ))
984 .join(normalized_glob.as_ref())
985 .to_string_lossy()
986 .to_string();
987 for member_root in glob(&absolute_glob)
988 .map_err(|err| WorkspaceError::Pattern(absolute_glob.clone(), err))?
989 {
990 let member_root = member_root
991 .map_err(|err| WorkspaceError::GlobWalk(absolute_glob.clone(), err))?;
992 if !seen.insert(member_root.clone()) {
993 continue;
994 }
995 let member_root = std::path::absolute(&member_root)
996 .map_err(WorkspaceError::Normalize)?
997 .clone();
998
999 let skip = match &options.members {
1001 MemberDiscovery::All => false,
1002 MemberDiscovery::None => true,
1003 MemberDiscovery::Ignore(ignore) => ignore.contains(member_root.as_path()),
1004 };
1005 if skip {
1006 debug!(
1007 "Ignoring workspace member: `{}`",
1008 member_root.simplified_display()
1009 );
1010 continue;
1011 }
1012
1013 if is_excluded_from_workspace(&member_root, workspace_root, workspace_definition)? {
1015 debug!(
1016 "Ignoring workspace member: `{}`",
1017 member_root.simplified_display()
1018 );
1019 continue;
1020 }
1021
1022 trace!(
1023 "Processing workspace member: `{}`",
1024 member_root.user_display()
1025 );
1026
1027 let pyproject_path = member_root.join("pyproject.toml");
1029 let contents = match fs_err::tokio::read_to_string(&pyproject_path).await {
1030 Ok(contents) => contents,
1031 Err(err) => {
1032 if !fs_err::metadata(&member_root)?.is_dir() {
1033 warn!(
1034 "Ignoring non-directory workspace member: `{}`",
1035 member_root.simplified_display()
1036 );
1037 continue;
1038 }
1039
1040 if err.kind() == std::io::ErrorKind::NotFound {
1042 if member_root
1044 .file_name()
1045 .map(|name| name.as_encoded_bytes().starts_with(b"."))
1046 .unwrap_or(false)
1047 {
1048 debug!(
1049 "Ignoring hidden workspace member: `{}`",
1050 member_root.simplified_display()
1051 );
1052 continue;
1053 }
1054
1055 if has_only_gitignored_files(&member_root) {
1058 debug!(
1059 "Ignoring workspace member with only gitignored files: `{}`",
1060 member_root.simplified_display()
1061 );
1062 continue;
1063 }
1064
1065 return Err(WorkspaceError::MissingPyprojectTomlMember(
1066 member_root,
1067 member_glob.to_string(),
1068 ));
1069 }
1070
1071 return Err(err.into());
1072 }
1073 };
1074 let pyproject_toml = PyProjectToml::from_string(contents, &pyproject_path)
1075 .map_err(|err| WorkspaceError::Toml(pyproject_path.clone(), Box::new(err)))?;
1076
1077 if pyproject_toml
1079 .tool
1080 .as_ref()
1081 .and_then(|tool| tool.uv.as_ref())
1082 .and_then(|uv| uv.managed)
1083 == Some(false)
1084 {
1085 if let Some(project) = pyproject_toml.project.as_ref() {
1086 debug!(
1087 "Project `{}` is marked as unmanaged; omitting from workspace members",
1088 project.name
1089 );
1090 } else {
1091 debug!(
1092 "Workspace member at `{}` is marked as unmanaged; omitting from workspace members",
1093 member_root.simplified_display()
1094 );
1095 }
1096 continue;
1097 }
1098
1099 let Some(project) = pyproject_toml.project.clone() else {
1101 return Err(WorkspaceError::MissingProject(pyproject_path));
1102 };
1103
1104 debug!(
1105 "Adding discovered workspace member: `{}`",
1106 member_root.simplified_display()
1107 );
1108
1109 if let Some(existing) = workspace_members.insert(
1110 project.name.clone(),
1111 WorkspaceMember {
1112 root: member_root.clone(),
1113 project,
1114 pyproject_toml,
1115 },
1116 ) {
1117 return Err(WorkspaceError::DuplicatePackage {
1118 name: existing.project.name,
1119 first: existing.root.clone(),
1120 second: member_root,
1121 });
1122 }
1123 }
1124 }
1125
1126 for member in workspace_members.values() {
1128 if member.root() != workspace_root
1129 && member
1130 .pyproject_toml
1131 .tool
1132 .as_ref()
1133 .and_then(|tool| tool.uv.as_ref())
1134 .and_then(|uv| uv.workspace.as_ref())
1135 .is_some()
1136 {
1137 return Err(WorkspaceError::NestedWorkspace(member.root.clone()));
1138 }
1139 }
1140 Ok(workspace_members)
1141 }
1142}
1143
1144#[derive(Debug, Clone, PartialEq)]
1146#[cfg_attr(test, derive(serde::Serialize))]
1147pub struct WorkspaceMember {
1148 root: PathBuf,
1150 project: Project,
1153 pyproject_toml: PyProjectToml,
1155}
1156
1157impl WorkspaceMember {
1158 pub fn root(&self) -> &PathBuf {
1160 &self.root
1161 }
1162
1163 pub fn project(&self) -> &Project {
1166 &self.project
1167 }
1168
1169 pub fn pyproject_toml(&self) -> &PyProjectToml {
1171 &self.pyproject_toml
1172 }
1173}
1174
1175#[derive(Debug, Clone)]
1253#[cfg_attr(test, derive(serde::Serialize))]
1254pub struct ProjectWorkspace {
1255 project_root: PathBuf,
1257 project_name: PackageName,
1259 workspace: Workspace,
1261}
1262
1263impl ProjectWorkspace {
1264 pub async fn discover(
1269 path: &Path,
1270 options: &DiscoveryOptions,
1271 cache: &WorkspaceCache,
1272 ) -> Result<Self, WorkspaceError> {
1273 let project_root = path
1274 .ancestors()
1275 .take_while(|path| {
1276 options
1278 .stop_discovery_at
1279 .as_deref()
1280 .and_then(Path::parent)
1281 .map(|stop_discovery_at| stop_discovery_at != *path)
1282 .unwrap_or(true)
1283 })
1284 .find(|path| path.join("pyproject.toml").is_file())
1285 .ok_or(WorkspaceError::MissingPyprojectToml)?;
1286
1287 debug!(
1288 "Found project root: `{}`",
1289 project_root.simplified_display()
1290 );
1291
1292 Self::from_project_root(project_root, options, cache).await
1293 }
1294
1295 async fn from_project_root(
1297 project_root: &Path,
1298 options: &DiscoveryOptions,
1299 cache: &WorkspaceCache,
1300 ) -> Result<Self, WorkspaceError> {
1301 let pyproject_path = project_root.join("pyproject.toml");
1303 let contents = fs_err::tokio::read_to_string(&pyproject_path).await?;
1304 let pyproject_toml = PyProjectToml::from_string(contents, &pyproject_path)
1305 .map_err(|err| WorkspaceError::Toml(pyproject_path.clone(), Box::new(err)))?;
1306
1307 let project = pyproject_toml
1309 .project
1310 .clone()
1311 .ok_or(WorkspaceError::MissingProject(pyproject_path))?;
1312
1313 Self::from_project(project_root, &project, &pyproject_toml, options, cache).await
1314 }
1315
1316 pub async fn from_maybe_project_root(
1319 install_path: &Path,
1320 options: &DiscoveryOptions,
1321 cache: &WorkspaceCache,
1322 ) -> Result<Option<Self>, WorkspaceError> {
1323 let pyproject_path = install_path.join("pyproject.toml");
1325 let Ok(contents) = fs_err::tokio::read_to_string(&pyproject_path).await else {
1326 return Ok(None);
1328 };
1329 let pyproject_toml = PyProjectToml::from_string(contents, &pyproject_path)
1330 .map_err(|err| WorkspaceError::Toml(pyproject_path.clone(), Box::new(err)))?;
1331
1332 let Some(project) = pyproject_toml.project.clone() else {
1334 return Ok(None);
1336 };
1337
1338 match Self::from_project(install_path, &project, &pyproject_toml, options, cache).await {
1339 Ok(workspace) => Ok(Some(workspace)),
1340 Err(WorkspaceError::NonWorkspace(_)) => Ok(None),
1341 Err(err) => Err(err),
1342 }
1343 }
1344
1345 pub fn project_root(&self) -> &Path {
1348 &self.project_root
1349 }
1350
1351 pub fn project_name(&self) -> &PackageName {
1353 &self.project_name
1354 }
1355
1356 pub fn workspace(&self) -> &Workspace {
1358 &self.workspace
1359 }
1360
1361 pub fn current_project(&self) -> &WorkspaceMember {
1363 &self.workspace().packages[&self.project_name]
1364 }
1365
1366 pub fn update_member(
1370 self,
1371 pyproject_toml: PyProjectToml,
1372 ) -> Result<Option<Self>, WorkspaceError> {
1373 let Some(workspace) = self
1374 .workspace
1375 .update_member(&self.project_name, pyproject_toml)?
1376 else {
1377 return Ok(None);
1378 };
1379 Ok(Some(Self { workspace, ..self }))
1380 }
1381
1382 async fn from_project(
1384 install_path: &Path,
1385 project: &Project,
1386 project_pyproject_toml: &PyProjectToml,
1387 options: &DiscoveryOptions,
1388 cache: &WorkspaceCache,
1389 ) -> Result<Self, WorkspaceError> {
1390 let project_path = std::path::absolute(install_path)
1391 .map_err(WorkspaceError::Normalize)?
1392 .clone();
1393 let project_path = uv_fs::normalize_path(&project_path);
1395 let project_path = project_path.components().collect::<PathBuf>();
1397
1398 if project_pyproject_toml
1400 .tool
1401 .as_ref()
1402 .and_then(|tool| tool.uv.as_ref())
1403 .and_then(|uv| uv.managed)
1404 == Some(false)
1405 {
1406 debug!("Project `{}` is marked as unmanaged", project.name);
1407 return Err(WorkspaceError::NonWorkspace(project_path));
1408 }
1409
1410 let mut workspace = project_pyproject_toml
1412 .tool
1413 .as_ref()
1414 .and_then(|tool| tool.uv.as_ref())
1415 .and_then(|uv| uv.workspace.as_ref())
1416 .map(|workspace| {
1417 (
1418 project_path.clone(),
1419 workspace.clone(),
1420 project_pyproject_toml.clone(),
1421 )
1422 });
1423
1424 if workspace.is_none() {
1425 workspace = find_workspace(&project_path, options).await?;
1428 }
1429
1430 let current_project = WorkspaceMember {
1431 root: project_path.clone(),
1432 project: project.clone(),
1433 pyproject_toml: project_pyproject_toml.clone(),
1434 };
1435
1436 let Some((workspace_root, workspace_definition, workspace_pyproject_toml)) = workspace
1437 else {
1438 debug!("No workspace root found, using project root");
1441
1442 let current_project_as_members = Arc::new(BTreeMap::from_iter([(
1443 project.name.clone(),
1444 current_project,
1445 )]));
1446 let workspace_sources = BTreeMap::default();
1447 let required_members = Workspace::collect_required_members(
1448 ¤t_project_as_members,
1449 &workspace_sources,
1450 project_pyproject_toml,
1451 )?;
1452
1453 return Ok(Self {
1454 project_root: project_path.clone(),
1455 project_name: project.name.clone(),
1456 workspace: Workspace {
1457 install_path: project_path.clone(),
1458 packages: current_project_as_members,
1459 required_members,
1460 sources: workspace_sources,
1463 indexes: Vec::default(),
1464 pyproject_toml: project_pyproject_toml.clone(),
1465 },
1466 });
1467 };
1468
1469 debug!(
1470 "Found workspace root: `{}`",
1471 workspace_root.simplified_display()
1472 );
1473
1474 let workspace = Workspace::collect_members(
1475 workspace_root,
1476 workspace_definition,
1477 workspace_pyproject_toml,
1478 Some(current_project),
1479 options,
1480 cache,
1481 )
1482 .await?;
1483
1484 Ok(Self {
1485 project_root: project_path,
1486 project_name: project.name.clone(),
1487 workspace,
1488 })
1489 }
1490}
1491
1492async fn find_workspace(
1494 project_root: &Path,
1495 options: &DiscoveryOptions,
1496) -> Result<Option<(PathBuf, ToolUvWorkspace, PyProjectToml)>, WorkspaceError> {
1497 for workspace_root in project_root
1499 .ancestors()
1500 .take_while(|path| {
1501 options
1503 .stop_discovery_at
1504 .as_deref()
1505 .and_then(Path::parent)
1506 .map(|stop_discovery_at| stop_discovery_at != *path)
1507 .unwrap_or(true)
1508 })
1509 .skip(1)
1510 {
1511 let pyproject_path = workspace_root.join("pyproject.toml");
1512 if !pyproject_path.is_file() {
1513 continue;
1514 }
1515 trace!(
1516 "Found `pyproject.toml` at: `{}`",
1517 pyproject_path.simplified_display()
1518 );
1519
1520 let contents = fs_err::tokio::read_to_string(&pyproject_path).await?;
1522 let pyproject_toml = PyProjectToml::from_string(contents, &pyproject_path)
1523 .map_err(|err| WorkspaceError::Toml(pyproject_path.clone(), Box::new(err)))?;
1524
1525 return if let Some(workspace) = pyproject_toml
1526 .tool
1527 .as_ref()
1528 .and_then(|tool| tool.uv.as_ref())
1529 .and_then(|uv| uv.workspace.as_ref())
1530 {
1531 if !is_included_in_workspace(project_root, workspace_root, workspace)? {
1532 debug!(
1533 "Found workspace root `{}`, but project is not included",
1534 workspace_root.simplified_display()
1535 );
1536 return Ok(None);
1537 }
1538
1539 if is_excluded_from_workspace(project_root, workspace_root, workspace)? {
1540 debug!(
1541 "Found workspace root `{}`, but project is excluded",
1542 workspace_root.simplified_display()
1543 );
1544 return Ok(None);
1545 }
1546
1547 Ok(Some((
1549 workspace_root.to_path_buf(),
1550 workspace.clone(),
1551 pyproject_toml,
1552 )))
1553 } else if pyproject_toml.project.is_some() {
1554 debug!(
1573 "Project is contained in non-workspace project: `{}`",
1574 workspace_root.simplified_display()
1575 );
1576 Ok(None)
1577 } else {
1578 warn!(
1580 "`pyproject.toml` does not contain a `project` table: `{}`",
1581 pyproject_path.simplified_display()
1582 );
1583 Ok(None)
1584 };
1585 }
1586
1587 Ok(None)
1588}
1589
1590fn has_only_gitignored_files(path: &Path) -> bool {
1595 let walker = ignore::WalkBuilder::new(path)
1596 .hidden(false)
1597 .parents(true)
1598 .ignore(true)
1599 .git_ignore(true)
1600 .git_global(true)
1601 .git_exclude(true)
1602 .build();
1603
1604 for entry in walker {
1605 let Ok(entry) = entry else {
1606 return false;
1608 };
1609
1610 if entry.path().is_dir() {
1612 continue;
1613 }
1614
1615 return false;
1617 }
1618
1619 true
1620}
1621
1622fn is_excluded_from_workspace(
1624 project_path: &Path,
1625 workspace_root: &Path,
1626 workspace: &ToolUvWorkspace,
1627) -> Result<bool, WorkspaceError> {
1628 for exclude_glob in workspace.exclude.iter().flatten() {
1629 let normalized_glob = uv_fs::normalize_path(Path::new(exclude_glob.as_str()));
1631 let absolute_glob = PathBuf::from(glob::Pattern::escape(
1632 workspace_root.simplified().to_string_lossy().as_ref(),
1633 ))
1634 .join(normalized_glob.as_ref());
1635 let absolute_glob = absolute_glob.to_string_lossy();
1636 let exclude_pattern = glob::Pattern::new(&absolute_glob)
1637 .map_err(|err| WorkspaceError::Pattern(absolute_glob.to_string(), err))?;
1638 if exclude_pattern.matches_path(project_path) {
1639 return Ok(true);
1640 }
1641 }
1642 Ok(false)
1643}
1644
1645fn is_included_in_workspace(
1647 project_path: &Path,
1648 workspace_root: &Path,
1649 workspace: &ToolUvWorkspace,
1650) -> Result<bool, WorkspaceError> {
1651 for member_glob in workspace.members.iter().flatten() {
1652 let normalized_glob = uv_fs::normalize_path(Path::new(member_glob.as_str()));
1654 let absolute_glob = PathBuf::from(glob::Pattern::escape(
1655 workspace_root.simplified().to_string_lossy().as_ref(),
1656 ))
1657 .join(normalized_glob.as_ref());
1658 let absolute_glob = absolute_glob.to_string_lossy();
1659 let include_pattern = glob::Pattern::new(&absolute_glob)
1660 .map_err(|err| WorkspaceError::Pattern(absolute_glob.to_string(), err))?;
1661 if include_pattern.matches_path(project_path) {
1662 return Ok(true);
1663 }
1664 }
1665 Ok(false)
1666}
1667
1668#[derive(Debug, Clone)]
1673pub enum VirtualProject {
1674 Project(ProjectWorkspace),
1676 NonProject(Workspace),
1678}
1679
1680impl VirtualProject {
1681 pub async fn discover(
1689 path: &Path,
1690 options: &DiscoveryOptions,
1691 cache: &WorkspaceCache,
1692 ) -> Result<Self, WorkspaceError> {
1693 assert!(
1694 path.is_absolute(),
1695 "virtual project discovery with relative path"
1696 );
1697 let project_root = path
1698 .ancestors()
1699 .take_while(|path| {
1700 options
1702 .stop_discovery_at
1703 .as_deref()
1704 .and_then(Path::parent)
1705 .map(|stop_discovery_at| stop_discovery_at != *path)
1706 .unwrap_or(true)
1707 })
1708 .find(|path| path.join("pyproject.toml").is_file())
1709 .ok_or(WorkspaceError::MissingPyprojectToml)?;
1710
1711 debug!(
1712 "Found project root: `{}`",
1713 project_root.simplified_display()
1714 );
1715
1716 let pyproject_path = project_root.join("pyproject.toml");
1718 let contents = fs_err::tokio::read_to_string(&pyproject_path).await?;
1719 let pyproject_toml = PyProjectToml::from_string(contents, &pyproject_path)
1720 .map_err(|err| WorkspaceError::Toml(pyproject_path.clone(), Box::new(err)))?;
1721
1722 if let Some(project) = pyproject_toml.project.as_ref() {
1723 let project = ProjectWorkspace::from_project(
1725 project_root,
1726 project,
1727 &pyproject_toml,
1728 options,
1729 cache,
1730 )
1731 .await?;
1732 Ok(Self::Project(project))
1733 } else if let Some(workspace) = pyproject_toml
1734 .tool
1735 .as_ref()
1736 .and_then(|tool| tool.uv.as_ref())
1737 .and_then(|uv| uv.workspace.as_ref())
1738 .filter(|_| options.project.allows_non_project_workspace())
1739 {
1740 let project_path = std::path::absolute(project_root)
1743 .map_err(WorkspaceError::Normalize)?
1744 .clone();
1745
1746 let workspace = Workspace::collect_members(
1747 project_path,
1748 workspace.clone(),
1749 pyproject_toml,
1750 None,
1751 options,
1752 cache,
1753 )
1754 .await?;
1755
1756 Ok(Self::NonProject(workspace))
1757 } else if options.project.allows_implicit_workspace() {
1758 let project_path = std::path::absolute(project_root)
1761 .map_err(WorkspaceError::Normalize)?
1762 .clone();
1763
1764 let workspace = Workspace::collect_members(
1765 project_path,
1766 ToolUvWorkspace::default(),
1767 pyproject_toml,
1768 None,
1769 options,
1770 cache,
1771 )
1772 .await?;
1773
1774 Ok(Self::NonProject(workspace))
1775 } else {
1776 Err(WorkspaceError::MissingProject(pyproject_path))
1777 }
1778 }
1779
1780 pub async fn discover_with_package(
1782 path: &Path,
1783 options: &DiscoveryOptions,
1784 cache: &WorkspaceCache,
1785 package: PackageName,
1786 ) -> Result<Self, WorkspaceError> {
1787 let workspace = Workspace::discover(path, options, cache).await?;
1788 let project_workspace = Workspace::with_current_project(workspace.clone(), package.clone());
1789 Ok(Self::Project(project_workspace.ok_or_else(|| {
1790 WorkspaceError::NoSuchMember(package.clone(), workspace.install_path)
1791 })?))
1792 }
1793
1794 pub fn update_member(
1798 self,
1799 pyproject_toml: PyProjectToml,
1800 ) -> Result<Option<Self>, WorkspaceError> {
1801 Ok(match self {
1802 Self::Project(project) => {
1803 let Some(project) = project.update_member(pyproject_toml)? else {
1804 return Ok(None);
1805 };
1806 Some(Self::Project(project))
1807 }
1808 Self::NonProject(workspace) => {
1809 Some(Self::NonProject(Workspace {
1812 pyproject_toml,
1813 ..workspace.clone()
1814 }))
1815 }
1816 })
1817 }
1818
1819 pub fn root(&self) -> &Path {
1821 match self {
1822 Self::Project(project) => project.project_root(),
1823 Self::NonProject(workspace) => workspace.install_path(),
1824 }
1825 }
1826
1827 pub fn pyproject_toml(&self) -> &PyProjectToml {
1829 match self {
1830 Self::Project(project) => project.current_project().pyproject_toml(),
1831 Self::NonProject(workspace) => &workspace.pyproject_toml,
1832 }
1833 }
1834
1835 pub fn workspace(&self) -> &Workspace {
1837 match self {
1838 Self::Project(project) => project.workspace(),
1839 Self::NonProject(workspace) => workspace,
1840 }
1841 }
1842
1843 pub fn project_name(&self) -> Option<&PackageName> {
1845 match self {
1846 Self::Project(project) => Some(project.project_name()),
1847 Self::NonProject(_) => None,
1848 }
1849 }
1850
1851 pub fn is_non_project(&self) -> bool {
1853 matches!(self, Self::NonProject(_))
1854 }
1855}
1856
1857#[cfg(test)]
1858#[cfg(unix)] mod tests {
1860 use std::env;
1861 use std::path::Path;
1862 use std::str::FromStr;
1863
1864 use anyhow::Result;
1865 use assert_fs::fixture::ChildPath;
1866 use assert_fs::prelude::*;
1867 use insta::{assert_json_snapshot, assert_snapshot};
1868
1869 use uv_normalize::GroupName;
1870 use uv_pypi_types::DependencyGroupSpecifier;
1871
1872 use crate::pyproject::PyProjectToml;
1873 use crate::workspace::{DiscoveryOptions, ProjectWorkspace};
1874 use crate::{WorkspaceCache, WorkspaceError};
1875
1876 async fn workspace_test(folder: &str) -> (ProjectWorkspace, String) {
1877 let root_dir = env::current_dir()
1878 .unwrap()
1879 .parent()
1880 .unwrap()
1881 .parent()
1882 .unwrap()
1883 .join("test")
1884 .join("workspaces");
1885 let project = ProjectWorkspace::discover(
1886 &root_dir.join(folder),
1887 &DiscoveryOptions::default(),
1888 &WorkspaceCache::default(),
1889 )
1890 .await
1891 .unwrap();
1892 let root_escaped = regex::escape(root_dir.to_string_lossy().as_ref());
1893 (project, root_escaped)
1894 }
1895
1896 async fn temporary_test(
1897 folder: &Path,
1898 ) -> Result<(ProjectWorkspace, String), (WorkspaceError, String)> {
1899 let root_escaped = regex::escape(folder.to_string_lossy().as_ref());
1900 let project = ProjectWorkspace::discover(
1901 folder,
1902 &DiscoveryOptions::default(),
1903 &WorkspaceCache::default(),
1904 )
1905 .await
1906 .map_err(|error| (error, root_escaped.clone()))?;
1907
1908 Ok((project, root_escaped))
1909 }
1910
1911 #[tokio::test]
1912 async fn albatross_in_example() {
1913 let (project, root_escaped) =
1914 workspace_test("albatross-in-example/examples/bird-feeder").await;
1915 let filters = vec![(root_escaped.as_str(), "[ROOT]")];
1916 insta::with_settings!({filters => filters}, {
1917 assert_json_snapshot!(
1918 project,
1919 {
1920 ".workspace.packages.*.pyproject_toml" => "[PYPROJECT_TOML]"
1921 },
1922 @r#"
1923 {
1924 "project_root": "[ROOT]/albatross-in-example/examples/bird-feeder",
1925 "project_name": "bird-feeder",
1926 "workspace": {
1927 "install_path": "[ROOT]/albatross-in-example/examples/bird-feeder",
1928 "packages": {
1929 "bird-feeder": {
1930 "root": "[ROOT]/albatross-in-example/examples/bird-feeder",
1931 "project": {
1932 "name": "bird-feeder",
1933 "version": "1.0.0",
1934 "requires-python": ">=3.12",
1935 "dependencies": [
1936 "iniconfig>=2,<3"
1937 ],
1938 "optional-dependencies": null
1939 },
1940 "pyproject_toml": "[PYPROJECT_TOML]"
1941 }
1942 },
1943 "required_members": {},
1944 "sources": {},
1945 "indexes": [],
1946 "pyproject_toml": {
1947 "project": {
1948 "name": "bird-feeder",
1949 "version": "1.0.0",
1950 "requires-python": ">=3.12",
1951 "dependencies": [
1952 "iniconfig>=2,<3"
1953 ],
1954 "optional-dependencies": null
1955 },
1956 "tool": null,
1957 "dependency-groups": null
1958 }
1959 }
1960 }
1961 "#);
1962 });
1963 }
1964
1965 #[tokio::test]
1966 async fn albatross_project_in_excluded() {
1967 let (project, root_escaped) =
1968 workspace_test("albatross-project-in-excluded/excluded/bird-feeder").await;
1969 let filters = vec![(root_escaped.as_str(), "[ROOT]")];
1970 insta::with_settings!({filters => filters}, {
1971 assert_json_snapshot!(
1972 project,
1973 {
1974 ".workspace.packages.*.pyproject_toml" => "[PYPROJECT_TOML]"
1975 },
1976 @r#"
1977 {
1978 "project_root": "[ROOT]/albatross-project-in-excluded/excluded/bird-feeder",
1979 "project_name": "bird-feeder",
1980 "workspace": {
1981 "install_path": "[ROOT]/albatross-project-in-excluded/excluded/bird-feeder",
1982 "packages": {
1983 "bird-feeder": {
1984 "root": "[ROOT]/albatross-project-in-excluded/excluded/bird-feeder",
1985 "project": {
1986 "name": "bird-feeder",
1987 "version": "1.0.0",
1988 "requires-python": ">=3.12",
1989 "dependencies": [
1990 "iniconfig>=2,<3"
1991 ],
1992 "optional-dependencies": null
1993 },
1994 "pyproject_toml": "[PYPROJECT_TOML]"
1995 }
1996 },
1997 "required_members": {},
1998 "sources": {},
1999 "indexes": [],
2000 "pyproject_toml": {
2001 "project": {
2002 "name": "bird-feeder",
2003 "version": "1.0.0",
2004 "requires-python": ">=3.12",
2005 "dependencies": [
2006 "iniconfig>=2,<3"
2007 ],
2008 "optional-dependencies": null
2009 },
2010 "tool": null,
2011 "dependency-groups": null
2012 }
2013 }
2014 }
2015 "#);
2016 });
2017 }
2018
2019 #[tokio::test]
2020 async fn albatross_root_workspace() {
2021 let (project, root_escaped) = workspace_test("albatross-root-workspace").await;
2022 let filters = vec![(root_escaped.as_str(), "[ROOT]")];
2023 insta::with_settings!({filters => filters}, {
2024 assert_json_snapshot!(
2025 project,
2026 {
2027 ".workspace.packages.*.pyproject_toml" => "[PYPROJECT_TOML]"
2028 },
2029 @r#"
2030 {
2031 "project_root": "[ROOT]/albatross-root-workspace",
2032 "project_name": "albatross",
2033 "workspace": {
2034 "install_path": "[ROOT]/albatross-root-workspace",
2035 "packages": {
2036 "albatross": {
2037 "root": "[ROOT]/albatross-root-workspace",
2038 "project": {
2039 "name": "albatross",
2040 "version": "0.1.0",
2041 "requires-python": ">=3.12",
2042 "dependencies": [
2043 "bird-feeder",
2044 "iniconfig>=2,<3"
2045 ],
2046 "optional-dependencies": null
2047 },
2048 "pyproject_toml": "[PYPROJECT_TOML]"
2049 },
2050 "bird-feeder": {
2051 "root": "[ROOT]/albatross-root-workspace/packages/bird-feeder",
2052 "project": {
2053 "name": "bird-feeder",
2054 "version": "1.0.0",
2055 "requires-python": ">=3.8",
2056 "dependencies": [
2057 "iniconfig>=2,<3",
2058 "seeds"
2059 ],
2060 "optional-dependencies": null
2061 },
2062 "pyproject_toml": "[PYPROJECT_TOML]"
2063 },
2064 "seeds": {
2065 "root": "[ROOT]/albatross-root-workspace/packages/seeds",
2066 "project": {
2067 "name": "seeds",
2068 "version": "1.0.0",
2069 "requires-python": ">=3.12",
2070 "dependencies": [
2071 "idna==3.6"
2072 ],
2073 "optional-dependencies": null
2074 },
2075 "pyproject_toml": "[PYPROJECT_TOML]"
2076 }
2077 },
2078 "required_members": {
2079 "bird-feeder": null,
2080 "seeds": null
2081 },
2082 "sources": {
2083 "bird-feeder": [
2084 {
2085 "workspace": true,
2086 "editable": null,
2087 "extra": null,
2088 "group": null
2089 }
2090 ]
2091 },
2092 "indexes": [],
2093 "pyproject_toml": {
2094 "project": {
2095 "name": "albatross",
2096 "version": "0.1.0",
2097 "requires-python": ">=3.12",
2098 "dependencies": [
2099 "bird-feeder",
2100 "iniconfig>=2,<3"
2101 ],
2102 "optional-dependencies": null
2103 },
2104 "tool": {
2105 "uv": {
2106 "sources": {
2107 "bird-feeder": [
2108 {
2109 "workspace": true,
2110 "editable": null,
2111 "extra": null,
2112 "group": null
2113 }
2114 ]
2115 },
2116 "index": null,
2117 "workspace": {
2118 "members": [
2119 "packages/*"
2120 ],
2121 "exclude": null
2122 },
2123 "managed": null,
2124 "package": null,
2125 "default-groups": null,
2126 "dependency-groups": null,
2127 "dev-dependencies": null,
2128 "override-dependencies": null,
2129 "exclude-dependencies": null,
2130 "constraint-dependencies": null,
2131 "build-constraint-dependencies": null,
2132 "environments": null,
2133 "required-environments": null,
2134 "conflicts": null,
2135 "build-backend": null
2136 }
2137 },
2138 "dependency-groups": null
2139 }
2140 }
2141 }
2142 "#);
2143 });
2144 }
2145
2146 #[tokio::test]
2147 async fn albatross_virtual_workspace() {
2148 let (project, root_escaped) =
2149 workspace_test("albatross-virtual-workspace/packages/albatross").await;
2150 let filters = vec![(root_escaped.as_str(), "[ROOT]")];
2151 insta::with_settings!({filters => filters}, {
2152 assert_json_snapshot!(
2153 project,
2154 {
2155 ".workspace.packages.*.pyproject_toml" => "[PYPROJECT_TOML]"
2156 },
2157 @r#"
2158 {
2159 "project_root": "[ROOT]/albatross-virtual-workspace/packages/albatross",
2160 "project_name": "albatross",
2161 "workspace": {
2162 "install_path": "[ROOT]/albatross-virtual-workspace",
2163 "packages": {
2164 "albatross": {
2165 "root": "[ROOT]/albatross-virtual-workspace/packages/albatross",
2166 "project": {
2167 "name": "albatross",
2168 "version": "0.1.0",
2169 "requires-python": ">=3.12",
2170 "dependencies": [
2171 "bird-feeder",
2172 "iniconfig>=2,<3"
2173 ],
2174 "optional-dependencies": null
2175 },
2176 "pyproject_toml": "[PYPROJECT_TOML]"
2177 },
2178 "bird-feeder": {
2179 "root": "[ROOT]/albatross-virtual-workspace/packages/bird-feeder",
2180 "project": {
2181 "name": "bird-feeder",
2182 "version": "1.0.0",
2183 "requires-python": ">=3.12",
2184 "dependencies": [
2185 "anyio>=4.3.0,<5",
2186 "seeds"
2187 ],
2188 "optional-dependencies": null
2189 },
2190 "pyproject_toml": "[PYPROJECT_TOML]"
2191 },
2192 "seeds": {
2193 "root": "[ROOT]/albatross-virtual-workspace/packages/seeds",
2194 "project": {
2195 "name": "seeds",
2196 "version": "1.0.0",
2197 "requires-python": ">=3.12",
2198 "dependencies": [
2199 "idna==3.6"
2200 ],
2201 "optional-dependencies": null
2202 },
2203 "pyproject_toml": "[PYPROJECT_TOML]"
2204 }
2205 },
2206 "required_members": {
2207 "bird-feeder": null,
2208 "seeds": null
2209 },
2210 "sources": {},
2211 "indexes": [],
2212 "pyproject_toml": {
2213 "project": null,
2214 "tool": {
2215 "uv": {
2216 "sources": null,
2217 "index": null,
2218 "workspace": {
2219 "members": [
2220 "packages/*"
2221 ],
2222 "exclude": null
2223 },
2224 "managed": null,
2225 "package": null,
2226 "default-groups": null,
2227 "dependency-groups": null,
2228 "dev-dependencies": null,
2229 "override-dependencies": null,
2230 "exclude-dependencies": null,
2231 "constraint-dependencies": null,
2232 "build-constraint-dependencies": null,
2233 "environments": null,
2234 "required-environments": null,
2235 "conflicts": null,
2236 "build-backend": null
2237 }
2238 },
2239 "dependency-groups": null
2240 }
2241 }
2242 }
2243 "#);
2244 });
2245 }
2246
2247 #[tokio::test]
2248 async fn albatross_just_project() {
2249 let (project, root_escaped) = workspace_test("albatross-just-project").await;
2250 let filters = vec![(root_escaped.as_str(), "[ROOT]")];
2251 insta::with_settings!({filters => filters}, {
2252 assert_json_snapshot!(
2253 project,
2254 {
2255 ".workspace.packages.*.pyproject_toml" => "[PYPROJECT_TOML]"
2256 },
2257 @r#"
2258 {
2259 "project_root": "[ROOT]/albatross-just-project",
2260 "project_name": "albatross",
2261 "workspace": {
2262 "install_path": "[ROOT]/albatross-just-project",
2263 "packages": {
2264 "albatross": {
2265 "root": "[ROOT]/albatross-just-project",
2266 "project": {
2267 "name": "albatross",
2268 "version": "0.1.0",
2269 "requires-python": ">=3.12",
2270 "dependencies": [
2271 "iniconfig>=2,<3"
2272 ],
2273 "optional-dependencies": null
2274 },
2275 "pyproject_toml": "[PYPROJECT_TOML]"
2276 }
2277 },
2278 "required_members": {},
2279 "sources": {},
2280 "indexes": [],
2281 "pyproject_toml": {
2282 "project": {
2283 "name": "albatross",
2284 "version": "0.1.0",
2285 "requires-python": ">=3.12",
2286 "dependencies": [
2287 "iniconfig>=2,<3"
2288 ],
2289 "optional-dependencies": null
2290 },
2291 "tool": null,
2292 "dependency-groups": null
2293 }
2294 }
2295 }
2296 "#);
2297 });
2298 }
2299
2300 #[tokio::test]
2301 async fn exclude_package() -> Result<()> {
2302 let root = tempfile::TempDir::new()?;
2303 let root = ChildPath::new(root.path());
2304
2305 root.child("pyproject.toml").write_str(
2307 r#"
2308 [project]
2309 name = "albatross"
2310 version = "0.1.0"
2311 requires-python = ">=3.12"
2312 dependencies = ["tqdm>=4,<5"]
2313
2314 [tool.uv.workspace]
2315 members = ["packages/*"]
2316 exclude = ["packages/bird-feeder"]
2317
2318 [build-system]
2319 requires = ["hatchling"]
2320 build-backend = "hatchling.build"
2321 "#,
2322 )?;
2323 root.child("albatross").child("__init__.py").touch()?;
2324
2325 root.child("packages")
2327 .child("seeds")
2328 .child("pyproject.toml")
2329 .write_str(
2330 r#"
2331 [project]
2332 name = "seeds"
2333 version = "1.0.0"
2334 requires-python = ">=3.12"
2335 dependencies = ["idna==3.6"]
2336
2337 [build-system]
2338 requires = ["hatchling"]
2339 build-backend = "hatchling.build"
2340 "#,
2341 )?;
2342 root.child("packages")
2343 .child("seeds")
2344 .child("seeds")
2345 .child("__init__.py")
2346 .touch()?;
2347
2348 root.child("packages")
2350 .child("bird-feeder")
2351 .child("pyproject.toml")
2352 .write_str(
2353 r#"
2354 [project]
2355 name = "bird-feeder"
2356 version = "1.0.0"
2357 requires-python = ">=3.12"
2358 dependencies = ["anyio>=4.3.0,<5"]
2359
2360 [build-system]
2361 requires = ["hatchling"]
2362 build-backend = "hatchling.build"
2363 "#,
2364 )?;
2365 root.child("packages")
2366 .child("bird-feeder")
2367 .child("bird_feeder")
2368 .child("__init__.py")
2369 .touch()?;
2370
2371 let (project, root_escaped) = temporary_test(root.as_ref()).await.unwrap();
2372 let filters = vec![(root_escaped.as_str(), "[ROOT]")];
2373 insta::with_settings!({filters => filters}, {
2374 assert_json_snapshot!(
2375 project,
2376 {
2377 ".workspace.packages.*.pyproject_toml" => "[PYPROJECT_TOML]"
2378 },
2379 @r#"
2380 {
2381 "project_root": "[ROOT]",
2382 "project_name": "albatross",
2383 "workspace": {
2384 "install_path": "[ROOT]",
2385 "packages": {
2386 "albatross": {
2387 "root": "[ROOT]",
2388 "project": {
2389 "name": "albatross",
2390 "version": "0.1.0",
2391 "requires-python": ">=3.12",
2392 "dependencies": [
2393 "tqdm>=4,<5"
2394 ],
2395 "optional-dependencies": null
2396 },
2397 "pyproject_toml": "[PYPROJECT_TOML]"
2398 },
2399 "seeds": {
2400 "root": "[ROOT]/packages/seeds",
2401 "project": {
2402 "name": "seeds",
2403 "version": "1.0.0",
2404 "requires-python": ">=3.12",
2405 "dependencies": [
2406 "idna==3.6"
2407 ],
2408 "optional-dependencies": null
2409 },
2410 "pyproject_toml": "[PYPROJECT_TOML]"
2411 }
2412 },
2413 "required_members": {},
2414 "sources": {},
2415 "indexes": [],
2416 "pyproject_toml": {
2417 "project": {
2418 "name": "albatross",
2419 "version": "0.1.0",
2420 "requires-python": ">=3.12",
2421 "dependencies": [
2422 "tqdm>=4,<5"
2423 ],
2424 "optional-dependencies": null
2425 },
2426 "tool": {
2427 "uv": {
2428 "sources": null,
2429 "index": null,
2430 "workspace": {
2431 "members": [
2432 "packages/*"
2433 ],
2434 "exclude": [
2435 "packages/bird-feeder"
2436 ]
2437 },
2438 "managed": null,
2439 "package": null,
2440 "default-groups": null,
2441 "dependency-groups": null,
2442 "dev-dependencies": null,
2443 "override-dependencies": null,
2444 "exclude-dependencies": null,
2445 "constraint-dependencies": null,
2446 "build-constraint-dependencies": null,
2447 "environments": null,
2448 "required-environments": null,
2449 "conflicts": null,
2450 "build-backend": null
2451 }
2452 },
2453 "dependency-groups": null
2454 }
2455 }
2456 }
2457 "#);
2458 });
2459
2460 root.child("pyproject.toml").write_str(
2462 r#"
2463 [project]
2464 name = "albatross"
2465 version = "0.1.0"
2466 requires-python = ">=3.12"
2467 dependencies = ["tqdm>=4,<5"]
2468
2469 [tool.uv.workspace]
2470 members = ["packages/seeds", "packages/bird-feeder"]
2471 exclude = ["packages/bird-feeder"]
2472
2473 [build-system]
2474 requires = ["hatchling"]
2475 build-backend = "hatchling.build"
2476 "#,
2477 )?;
2478
2479 let (project, root_escaped) = temporary_test(root.as_ref()).await.unwrap();
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]",
2491 "project_name": "albatross",
2492 "workspace": {
2493 "install_path": "[ROOT]",
2494 "packages": {
2495 "albatross": {
2496 "root": "[ROOT]",
2497 "project": {
2498 "name": "albatross",
2499 "version": "0.1.0",
2500 "requires-python": ">=3.12",
2501 "dependencies": [
2502 "tqdm>=4,<5"
2503 ],
2504 "optional-dependencies": null
2505 },
2506 "pyproject_toml": "[PYPROJECT_TOML]"
2507 },
2508 "seeds": {
2509 "root": "[ROOT]/packages/seeds",
2510 "project": {
2511 "name": "seeds",
2512 "version": "1.0.0",
2513 "requires-python": ">=3.12",
2514 "dependencies": [
2515 "idna==3.6"
2516 ],
2517 "optional-dependencies": null
2518 },
2519 "pyproject_toml": "[PYPROJECT_TOML]"
2520 }
2521 },
2522 "required_members": {},
2523 "sources": {},
2524 "indexes": [],
2525 "pyproject_toml": {
2526 "project": {
2527 "name": "albatross",
2528 "version": "0.1.0",
2529 "requires-python": ">=3.12",
2530 "dependencies": [
2531 "tqdm>=4,<5"
2532 ],
2533 "optional-dependencies": null
2534 },
2535 "tool": {
2536 "uv": {
2537 "sources": null,
2538 "index": null,
2539 "workspace": {
2540 "members": [
2541 "packages/seeds",
2542 "packages/bird-feeder"
2543 ],
2544 "exclude": [
2545 "packages/bird-feeder"
2546 ]
2547 },
2548 "managed": null,
2549 "package": null,
2550 "default-groups": null,
2551 "dependency-groups": null,
2552 "dev-dependencies": null,
2553 "override-dependencies": null,
2554 "exclude-dependencies": null,
2555 "constraint-dependencies": null,
2556 "build-constraint-dependencies": null,
2557 "environments": null,
2558 "required-environments": null,
2559 "conflicts": null,
2560 "build-backend": null
2561 }
2562 },
2563 "dependency-groups": null
2564 }
2565 }
2566 }
2567 "#);
2568 });
2569
2570 root.child("pyproject.toml").write_str(
2572 r#"
2573 [project]
2574 name = "albatross"
2575 version = "0.1.0"
2576 requires-python = ">=3.12"
2577 dependencies = ["tqdm>=4,<5"]
2578
2579 [tool.uv.workspace]
2580 members = ["packages/seeds", "packages/bird-feeder"]
2581 exclude = ["packages"]
2582
2583 [build-system]
2584 requires = ["hatchling"]
2585 build-backend = "hatchling.build"
2586 "#,
2587 )?;
2588
2589 let (project, root_escaped) = temporary_test(root.as_ref()).await.unwrap();
2591 let filters = vec![(root_escaped.as_str(), "[ROOT]")];
2592 insta::with_settings!({filters => filters}, {
2593 assert_json_snapshot!(
2594 project,
2595 {
2596 ".workspace.packages.*.pyproject_toml" => "[PYPROJECT_TOML]"
2597 },
2598 @r#"
2599 {
2600 "project_root": "[ROOT]",
2601 "project_name": "albatross",
2602 "workspace": {
2603 "install_path": "[ROOT]",
2604 "packages": {
2605 "albatross": {
2606 "root": "[ROOT]",
2607 "project": {
2608 "name": "albatross",
2609 "version": "0.1.0",
2610 "requires-python": ">=3.12",
2611 "dependencies": [
2612 "tqdm>=4,<5"
2613 ],
2614 "optional-dependencies": null
2615 },
2616 "pyproject_toml": "[PYPROJECT_TOML]"
2617 },
2618 "bird-feeder": {
2619 "root": "[ROOT]/packages/bird-feeder",
2620 "project": {
2621 "name": "bird-feeder",
2622 "version": "1.0.0",
2623 "requires-python": ">=3.12",
2624 "dependencies": [
2625 "anyio>=4.3.0,<5"
2626 ],
2627 "optional-dependencies": null
2628 },
2629 "pyproject_toml": "[PYPROJECT_TOML]"
2630 },
2631 "seeds": {
2632 "root": "[ROOT]/packages/seeds",
2633 "project": {
2634 "name": "seeds",
2635 "version": "1.0.0",
2636 "requires-python": ">=3.12",
2637 "dependencies": [
2638 "idna==3.6"
2639 ],
2640 "optional-dependencies": null
2641 },
2642 "pyproject_toml": "[PYPROJECT_TOML]"
2643 }
2644 },
2645 "required_members": {},
2646 "sources": {},
2647 "indexes": [],
2648 "pyproject_toml": {
2649 "project": {
2650 "name": "albatross",
2651 "version": "0.1.0",
2652 "requires-python": ">=3.12",
2653 "dependencies": [
2654 "tqdm>=4,<5"
2655 ],
2656 "optional-dependencies": null
2657 },
2658 "tool": {
2659 "uv": {
2660 "sources": null,
2661 "index": null,
2662 "workspace": {
2663 "members": [
2664 "packages/seeds",
2665 "packages/bird-feeder"
2666 ],
2667 "exclude": [
2668 "packages"
2669 ]
2670 },
2671 "managed": null,
2672 "package": null,
2673 "default-groups": null,
2674 "dependency-groups": null,
2675 "dev-dependencies": null,
2676 "override-dependencies": null,
2677 "exclude-dependencies": null,
2678 "constraint-dependencies": null,
2679 "build-constraint-dependencies": null,
2680 "environments": null,
2681 "required-environments": null,
2682 "conflicts": null,
2683 "build-backend": null
2684 }
2685 },
2686 "dependency-groups": null
2687 }
2688 }
2689 }
2690 "#);
2691 });
2692
2693 root.child("pyproject.toml").write_str(
2695 r#"
2696 [project]
2697 name = "albatross"
2698 version = "0.1.0"
2699 requires-python = ">=3.12"
2700 dependencies = ["tqdm>=4,<5"]
2701
2702 [tool.uv.workspace]
2703 members = ["packages/seeds", "packages/bird-feeder"]
2704 exclude = ["packages/*"]
2705
2706 [build-system]
2707 requires = ["hatchling"]
2708 build-backend = "hatchling.build"
2709 "#,
2710 )?;
2711
2712 let (project, root_escaped) = temporary_test(root.as_ref()).await.unwrap();
2714 let filters = vec![(root_escaped.as_str(), "[ROOT]")];
2715 insta::with_settings!({filters => filters}, {
2716 assert_json_snapshot!(
2717 project,
2718 {
2719 ".workspace.packages.*.pyproject_toml" => "[PYPROJECT_TOML]"
2720 },
2721 @r#"
2722 {
2723 "project_root": "[ROOT]",
2724 "project_name": "albatross",
2725 "workspace": {
2726 "install_path": "[ROOT]",
2727 "packages": {
2728 "albatross": {
2729 "root": "[ROOT]",
2730 "project": {
2731 "name": "albatross",
2732 "version": "0.1.0",
2733 "requires-python": ">=3.12",
2734 "dependencies": [
2735 "tqdm>=4,<5"
2736 ],
2737 "optional-dependencies": null
2738 },
2739 "pyproject_toml": "[PYPROJECT_TOML]"
2740 }
2741 },
2742 "required_members": {},
2743 "sources": {},
2744 "indexes": [],
2745 "pyproject_toml": {
2746 "project": {
2747 "name": "albatross",
2748 "version": "0.1.0",
2749 "requires-python": ">=3.12",
2750 "dependencies": [
2751 "tqdm>=4,<5"
2752 ],
2753 "optional-dependencies": null
2754 },
2755 "tool": {
2756 "uv": {
2757 "sources": null,
2758 "index": null,
2759 "workspace": {
2760 "members": [
2761 "packages/seeds",
2762 "packages/bird-feeder"
2763 ],
2764 "exclude": [
2765 "packages/*"
2766 ]
2767 },
2768 "managed": null,
2769 "package": null,
2770 "default-groups": null,
2771 "dependency-groups": null,
2772 "dev-dependencies": null,
2773 "override-dependencies": null,
2774 "exclude-dependencies": null,
2775 "constraint-dependencies": null,
2776 "build-constraint-dependencies": null,
2777 "environments": null,
2778 "required-environments": null,
2779 "conflicts": null,
2780 "build-backend": null
2781 }
2782 },
2783 "dependency-groups": null
2784 }
2785 }
2786 }
2787 "#);
2788 });
2789
2790 Ok(())
2791 }
2792
2793 #[test]
2794 fn read_dependency_groups() {
2795 let toml = r#"
2796[dependency-groups]
2797foo = ["a", {include-group = "bar"}]
2798bar = ["b"]
2799"#;
2800
2801 let result = PyProjectToml::from_string(toml.to_string(), "pyproject.toml")
2802 .expect("Deserialization should succeed");
2803
2804 let groups = result
2805 .dependency_groups
2806 .expect("`dependency-groups` should be present");
2807 let foo = groups
2808 .get(&GroupName::from_str("foo").unwrap())
2809 .expect("Group `foo` should be present");
2810 assert_eq!(
2811 foo,
2812 &[
2813 DependencyGroupSpecifier::Requirement("a".to_string()),
2814 DependencyGroupSpecifier::IncludeGroup {
2815 include_group: GroupName::from_str("bar").unwrap(),
2816 }
2817 ]
2818 );
2819
2820 let bar = groups
2821 .get(&GroupName::from_str("bar").unwrap())
2822 .expect("Group `bar` should be present");
2823 assert_eq!(
2824 bar,
2825 &[DependencyGroupSpecifier::Requirement("b".to_string())]
2826 );
2827 }
2828
2829 #[tokio::test]
2830 async fn nested_workspace() -> Result<()> {
2831 let root = tempfile::TempDir::new()?;
2832 let root = ChildPath::new(root.path());
2833
2834 root.child("pyproject.toml").write_str(
2836 r#"
2837 [project]
2838 name = "albatross"
2839 version = "0.1.0"
2840 requires-python = ">=3.12"
2841 dependencies = ["tqdm>=4,<5"]
2842
2843 [tool.uv.workspace]
2844 members = ["packages/*"]
2845 "#,
2846 )?;
2847
2848 root.child("packages")
2850 .child("seeds")
2851 .child("pyproject.toml")
2852 .write_str(
2853 r#"
2854 [project]
2855 name = "seeds"
2856 version = "1.0.0"
2857 requires-python = ">=3.12"
2858 dependencies = ["idna==3.6"]
2859
2860 [tool.uv.workspace]
2861 members = ["nested_packages/*"]
2862 "#,
2863 )?;
2864
2865 let (error, root_escaped) = temporary_test(root.as_ref()).await.unwrap_err();
2866 let filters = vec![(root_escaped.as_str(), "[ROOT]")];
2867 insta::with_settings!({filters => filters}, {
2868 assert_snapshot!(
2869 error,
2870 @"Nested workspaces are not supported, but workspace member has a `tool.uv.workspace` table: [ROOT]/packages/seeds");
2871 });
2872
2873 Ok(())
2874 }
2875
2876 #[tokio::test]
2877 async fn duplicate_names() -> Result<()> {
2878 let root = tempfile::TempDir::new()?;
2879 let root = ChildPath::new(root.path());
2880
2881 root.child("pyproject.toml").write_str(
2883 r#"
2884 [project]
2885 name = "albatross"
2886 version = "0.1.0"
2887 requires-python = ">=3.12"
2888 dependencies = ["tqdm>=4,<5"]
2889
2890 [tool.uv.workspace]
2891 members = ["packages/*"]
2892 "#,
2893 )?;
2894
2895 root.child("packages")
2897 .child("seeds")
2898 .child("pyproject.toml")
2899 .write_str(
2900 r#"
2901 [project]
2902 name = "seeds"
2903 version = "1.0.0"
2904 requires-python = ">=3.12"
2905 dependencies = ["idna==3.6"]
2906
2907 [tool.uv.workspace]
2908 members = ["nested_packages/*"]
2909 "#,
2910 )?;
2911
2912 root.child("packages")
2914 .child("seeds2")
2915 .child("pyproject.toml")
2916 .write_str(
2917 r#"
2918 [project]
2919 name = "seeds"
2920 version = "1.0.0"
2921 requires-python = ">=3.12"
2922 dependencies = ["idna==3.6"]
2923
2924 [tool.uv.workspace]
2925 members = ["nested_packages/*"]
2926 "#,
2927 )?;
2928
2929 let (error, root_escaped) = temporary_test(root.as_ref()).await.unwrap_err();
2930 let filters = vec![(root_escaped.as_str(), "[ROOT]")];
2931 insta::with_settings!({filters => filters}, {
2932 assert_snapshot!(
2933 error,
2934 @"Two workspace members are both named `seeds`: `[ROOT]/packages/seeds` and `[ROOT]/packages/seeds2`");
2935 });
2936
2937 Ok(())
2938 }
2939}