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