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 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)
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 debug!(
1086 "Project `{}` is marked as unmanaged; omitting from workspace members",
1087 pyproject_toml.project.as_ref().unwrap().name
1088 );
1089 continue;
1090 }
1091
1092 let Some(project) = pyproject_toml.project.clone() else {
1094 return Err(WorkspaceError::MissingProject(pyproject_path));
1095 };
1096
1097 debug!(
1098 "Adding discovered workspace member: `{}`",
1099 member_root.simplified_display()
1100 );
1101
1102 if let Some(existing) = workspace_members.insert(
1103 project.name.clone(),
1104 WorkspaceMember {
1105 root: member_root.clone(),
1106 project,
1107 pyproject_toml,
1108 },
1109 ) {
1110 return Err(WorkspaceError::DuplicatePackage {
1111 name: existing.project.name,
1112 first: existing.root.clone(),
1113 second: member_root,
1114 });
1115 }
1116 }
1117 }
1118
1119 for member in workspace_members.values() {
1121 if member.root() != workspace_root
1122 && member
1123 .pyproject_toml
1124 .tool
1125 .as_ref()
1126 .and_then(|tool| tool.uv.as_ref())
1127 .and_then(|uv| uv.workspace.as_ref())
1128 .is_some()
1129 {
1130 return Err(WorkspaceError::NestedWorkspace(member.root.clone()));
1131 }
1132 }
1133 Ok(workspace_members)
1134 }
1135}
1136
1137#[derive(Debug, Clone, PartialEq)]
1139#[cfg_attr(test, derive(serde::Serialize))]
1140pub struct WorkspaceMember {
1141 root: PathBuf,
1143 project: Project,
1146 pyproject_toml: PyProjectToml,
1148}
1149
1150impl WorkspaceMember {
1151 pub fn root(&self) -> &PathBuf {
1153 &self.root
1154 }
1155
1156 pub fn project(&self) -> &Project {
1159 &self.project
1160 }
1161
1162 pub fn pyproject_toml(&self) -> &PyProjectToml {
1164 &self.pyproject_toml
1165 }
1166}
1167
1168#[derive(Debug, Clone)]
1246#[cfg_attr(test, derive(serde::Serialize))]
1247pub struct ProjectWorkspace {
1248 project_root: PathBuf,
1250 project_name: PackageName,
1252 workspace: Workspace,
1254}
1255
1256impl ProjectWorkspace {
1257 pub async fn discover(
1262 path: &Path,
1263 options: &DiscoveryOptions,
1264 cache: &WorkspaceCache,
1265 ) -> Result<Self, WorkspaceError> {
1266 let project_root = path
1267 .ancestors()
1268 .take_while(|path| {
1269 options
1271 .stop_discovery_at
1272 .as_deref()
1273 .and_then(Path::parent)
1274 .map(|stop_discovery_at| stop_discovery_at != *path)
1275 .unwrap_or(true)
1276 })
1277 .find(|path| path.join("pyproject.toml").is_file())
1278 .ok_or(WorkspaceError::MissingPyprojectToml)?;
1279
1280 debug!(
1281 "Found project root: `{}`",
1282 project_root.simplified_display()
1283 );
1284
1285 Self::from_project_root(project_root, options, cache).await
1286 }
1287
1288 async fn from_project_root(
1290 project_root: &Path,
1291 options: &DiscoveryOptions,
1292 cache: &WorkspaceCache,
1293 ) -> Result<Self, WorkspaceError> {
1294 let pyproject_path = project_root.join("pyproject.toml");
1296 let contents = fs_err::tokio::read_to_string(&pyproject_path).await?;
1297 let pyproject_toml = PyProjectToml::from_string(contents)
1298 .map_err(|err| WorkspaceError::Toml(pyproject_path.clone(), Box::new(err)))?;
1299
1300 let project = pyproject_toml
1302 .project
1303 .clone()
1304 .ok_or(WorkspaceError::MissingProject(pyproject_path))?;
1305
1306 Self::from_project(project_root, &project, &pyproject_toml, options, cache).await
1307 }
1308
1309 pub async fn from_maybe_project_root(
1312 install_path: &Path,
1313 options: &DiscoveryOptions,
1314 cache: &WorkspaceCache,
1315 ) -> Result<Option<Self>, WorkspaceError> {
1316 let pyproject_path = install_path.join("pyproject.toml");
1318 let Ok(contents) = fs_err::tokio::read_to_string(&pyproject_path).await else {
1319 return Ok(None);
1321 };
1322 let pyproject_toml = PyProjectToml::from_string(contents)
1323 .map_err(|err| WorkspaceError::Toml(pyproject_path.clone(), Box::new(err)))?;
1324
1325 let Some(project) = pyproject_toml.project.clone() else {
1327 return Ok(None);
1329 };
1330
1331 match Self::from_project(install_path, &project, &pyproject_toml, options, cache).await {
1332 Ok(workspace) => Ok(Some(workspace)),
1333 Err(WorkspaceError::NonWorkspace(_)) => Ok(None),
1334 Err(err) => Err(err),
1335 }
1336 }
1337
1338 pub fn project_root(&self) -> &Path {
1341 &self.project_root
1342 }
1343
1344 pub fn project_name(&self) -> &PackageName {
1346 &self.project_name
1347 }
1348
1349 pub fn workspace(&self) -> &Workspace {
1351 &self.workspace
1352 }
1353
1354 pub fn current_project(&self) -> &WorkspaceMember {
1356 &self.workspace().packages[&self.project_name]
1357 }
1358
1359 pub fn with_pyproject_toml(
1363 self,
1364 pyproject_toml: PyProjectToml,
1365 ) -> Result<Option<Self>, WorkspaceError> {
1366 let Some(workspace) = self
1367 .workspace
1368 .with_pyproject_toml(&self.project_name, pyproject_toml)?
1369 else {
1370 return Ok(None);
1371 };
1372 Ok(Some(Self { workspace, ..self }))
1373 }
1374
1375 pub async fn from_project(
1377 install_path: &Path,
1378 project: &Project,
1379 project_pyproject_toml: &PyProjectToml,
1380 options: &DiscoveryOptions,
1381 cache: &WorkspaceCache,
1382 ) -> Result<Self, WorkspaceError> {
1383 let project_path = std::path::absolute(install_path)
1384 .map_err(WorkspaceError::Normalize)?
1385 .clone();
1386 let project_path = uv_fs::normalize_path(&project_path);
1388 let project_path = project_path.components().collect::<PathBuf>();
1390
1391 if project_pyproject_toml
1393 .tool
1394 .as_ref()
1395 .and_then(|tool| tool.uv.as_ref())
1396 .and_then(|uv| uv.managed)
1397 == Some(false)
1398 {
1399 debug!("Project `{}` is marked as unmanaged", project.name);
1400 return Err(WorkspaceError::NonWorkspace(project_path));
1401 }
1402
1403 let mut workspace = project_pyproject_toml
1405 .tool
1406 .as_ref()
1407 .and_then(|tool| tool.uv.as_ref())
1408 .and_then(|uv| uv.workspace.as_ref())
1409 .map(|workspace| {
1410 (
1411 project_path.clone(),
1412 workspace.clone(),
1413 project_pyproject_toml.clone(),
1414 )
1415 });
1416
1417 if workspace.is_none() {
1418 workspace = find_workspace(&project_path, options).await?;
1421 }
1422
1423 let current_project = WorkspaceMember {
1424 root: project_path.clone(),
1425 project: project.clone(),
1426 pyproject_toml: project_pyproject_toml.clone(),
1427 };
1428
1429 let Some((workspace_root, workspace_definition, workspace_pyproject_toml)) = workspace
1430 else {
1431 debug!("No workspace root found, using project root");
1434
1435 let current_project_as_members = Arc::new(BTreeMap::from_iter([(
1436 project.name.clone(),
1437 current_project,
1438 )]));
1439 let workspace_sources = BTreeMap::default();
1440 let required_members = Workspace::collect_required_members(
1441 ¤t_project_as_members,
1442 &workspace_sources,
1443 project_pyproject_toml,
1444 )?;
1445
1446 return Ok(Self {
1447 project_root: project_path.clone(),
1448 project_name: project.name.clone(),
1449 workspace: Workspace {
1450 install_path: project_path.clone(),
1451 packages: current_project_as_members,
1452 required_members,
1453 sources: workspace_sources,
1456 indexes: Vec::default(),
1457 pyproject_toml: project_pyproject_toml.clone(),
1458 },
1459 });
1460 };
1461
1462 debug!(
1463 "Found workspace root: `{}`",
1464 workspace_root.simplified_display()
1465 );
1466
1467 let workspace = Workspace::collect_members(
1468 workspace_root,
1469 workspace_definition,
1470 workspace_pyproject_toml,
1471 Some(current_project),
1472 options,
1473 cache,
1474 )
1475 .await?;
1476
1477 Ok(Self {
1478 project_root: project_path,
1479 project_name: project.name.clone(),
1480 workspace,
1481 })
1482 }
1483}
1484
1485async fn find_workspace(
1487 project_root: &Path,
1488 options: &DiscoveryOptions,
1489) -> Result<Option<(PathBuf, ToolUvWorkspace, PyProjectToml)>, WorkspaceError> {
1490 for workspace_root in project_root
1492 .ancestors()
1493 .take_while(|path| {
1494 options
1496 .stop_discovery_at
1497 .as_deref()
1498 .and_then(Path::parent)
1499 .map(|stop_discovery_at| stop_discovery_at != *path)
1500 .unwrap_or(true)
1501 })
1502 .skip(1)
1503 {
1504 let pyproject_path = workspace_root.join("pyproject.toml");
1505 if !pyproject_path.is_file() {
1506 continue;
1507 }
1508 trace!(
1509 "Found `pyproject.toml` at: `{}`",
1510 pyproject_path.simplified_display()
1511 );
1512
1513 let contents = fs_err::tokio::read_to_string(&pyproject_path).await?;
1515 let pyproject_toml = PyProjectToml::from_string(contents)
1516 .map_err(|err| WorkspaceError::Toml(pyproject_path.clone(), Box::new(err)))?;
1517
1518 return if let Some(workspace) = pyproject_toml
1519 .tool
1520 .as_ref()
1521 .and_then(|tool| tool.uv.as_ref())
1522 .and_then(|uv| uv.workspace.as_ref())
1523 {
1524 if !is_included_in_workspace(project_root, workspace_root, workspace)? {
1525 debug!(
1526 "Found workspace root `{}`, but project is not included",
1527 workspace_root.simplified_display()
1528 );
1529 return Ok(None);
1530 }
1531
1532 if is_excluded_from_workspace(project_root, workspace_root, workspace)? {
1533 debug!(
1534 "Found workspace root `{}`, but project is excluded",
1535 workspace_root.simplified_display()
1536 );
1537 return Ok(None);
1538 }
1539
1540 Ok(Some((
1542 workspace_root.to_path_buf(),
1543 workspace.clone(),
1544 pyproject_toml,
1545 )))
1546 } else if pyproject_toml.project.is_some() {
1547 debug!(
1566 "Project is contained in non-workspace project: `{}`",
1567 workspace_root.simplified_display()
1568 );
1569 Ok(None)
1570 } else {
1571 warn!(
1573 "`pyproject.toml` does not contain a `project` table: `{}`",
1574 pyproject_path.simplified_display()
1575 );
1576 Ok(None)
1577 };
1578 }
1579
1580 Ok(None)
1581}
1582
1583fn is_excluded_from_workspace(
1585 project_path: &Path,
1586 workspace_root: &Path,
1587 workspace: &ToolUvWorkspace,
1588) -> Result<bool, WorkspaceError> {
1589 for exclude_glob in workspace.exclude.iter().flatten() {
1590 let normalized_glob = uv_fs::normalize_path(Path::new(exclude_glob.as_str()));
1592 let absolute_glob = PathBuf::from(glob::Pattern::escape(
1593 workspace_root.simplified().to_string_lossy().as_ref(),
1594 ))
1595 .join(normalized_glob.as_ref());
1596 let absolute_glob = absolute_glob.to_string_lossy();
1597 let exclude_pattern = glob::Pattern::new(&absolute_glob)
1598 .map_err(|err| WorkspaceError::Pattern(absolute_glob.to_string(), err))?;
1599 if exclude_pattern.matches_path(project_path) {
1600 return Ok(true);
1601 }
1602 }
1603 Ok(false)
1604}
1605
1606fn is_included_in_workspace(
1608 project_path: &Path,
1609 workspace_root: &Path,
1610 workspace: &ToolUvWorkspace,
1611) -> Result<bool, WorkspaceError> {
1612 for member_glob in workspace.members.iter().flatten() {
1613 let normalized_glob = uv_fs::normalize_path(Path::new(member_glob.as_str()));
1615 let absolute_glob = PathBuf::from(glob::Pattern::escape(
1616 workspace_root.simplified().to_string_lossy().as_ref(),
1617 ))
1618 .join(normalized_glob.as_ref());
1619 let absolute_glob = absolute_glob.to_string_lossy();
1620 let include_pattern = glob::Pattern::new(&absolute_glob)
1621 .map_err(|err| WorkspaceError::Pattern(absolute_glob.to_string(), err))?;
1622 if include_pattern.matches_path(project_path) {
1623 return Ok(true);
1624 }
1625 }
1626 Ok(false)
1627}
1628
1629#[derive(Debug, Clone)]
1634pub enum VirtualProject {
1635 Project(ProjectWorkspace),
1637 NonProject(Workspace),
1639}
1640
1641impl VirtualProject {
1642 pub async fn discover(
1650 path: &Path,
1651 options: &DiscoveryOptions,
1652 cache: &WorkspaceCache,
1653 ) -> Result<Self, WorkspaceError> {
1654 assert!(
1655 path.is_absolute(),
1656 "virtual project discovery with relative path"
1657 );
1658 let project_root = path
1659 .ancestors()
1660 .take_while(|path| {
1661 options
1663 .stop_discovery_at
1664 .as_deref()
1665 .and_then(Path::parent)
1666 .map(|stop_discovery_at| stop_discovery_at != *path)
1667 .unwrap_or(true)
1668 })
1669 .find(|path| path.join("pyproject.toml").is_file())
1670 .ok_or(WorkspaceError::MissingPyprojectToml)?;
1671
1672 debug!(
1673 "Found project root: `{}`",
1674 project_root.simplified_display()
1675 );
1676
1677 let pyproject_path = project_root.join("pyproject.toml");
1679 let contents = fs_err::tokio::read_to_string(&pyproject_path).await?;
1680 let pyproject_toml = PyProjectToml::from_string(contents)
1681 .map_err(|err| WorkspaceError::Toml(pyproject_path.clone(), Box::new(err)))?;
1682
1683 if let Some(project) = pyproject_toml.project.as_ref() {
1684 let project = ProjectWorkspace::from_project(
1686 project_root,
1687 project,
1688 &pyproject_toml,
1689 options,
1690 cache,
1691 )
1692 .await?;
1693 Ok(Self::Project(project))
1694 } else if let Some(workspace) = pyproject_toml
1695 .tool
1696 .as_ref()
1697 .and_then(|tool| tool.uv.as_ref())
1698 .and_then(|uv| uv.workspace.as_ref())
1699 .filter(|_| options.project.allows_legacy_workspace())
1700 {
1701 let project_path = std::path::absolute(project_root)
1704 .map_err(WorkspaceError::Normalize)?
1705 .clone();
1706
1707 let workspace = Workspace::collect_members(
1708 project_path,
1709 workspace.clone(),
1710 pyproject_toml,
1711 None,
1712 options,
1713 cache,
1714 )
1715 .await?;
1716
1717 Ok(Self::NonProject(workspace))
1718 } else if options.project.allows_implicit_workspace() {
1719 let project_path = std::path::absolute(project_root)
1722 .map_err(WorkspaceError::Normalize)?
1723 .clone();
1724
1725 let workspace = Workspace::collect_members(
1726 project_path,
1727 ToolUvWorkspace::default(),
1728 pyproject_toml,
1729 None,
1730 options,
1731 cache,
1732 )
1733 .await?;
1734
1735 Ok(Self::NonProject(workspace))
1736 } else {
1737 Err(WorkspaceError::MissingProject(pyproject_path))
1738 }
1739 }
1740
1741 pub fn with_pyproject_toml(
1745 self,
1746 pyproject_toml: PyProjectToml,
1747 ) -> Result<Option<Self>, WorkspaceError> {
1748 Ok(match self {
1749 Self::Project(project) => {
1750 let Some(project) = project.with_pyproject_toml(pyproject_toml)? else {
1751 return Ok(None);
1752 };
1753 Some(Self::Project(project))
1754 }
1755 Self::NonProject(workspace) => {
1756 Some(Self::NonProject(Workspace {
1759 pyproject_toml,
1760 ..workspace.clone()
1761 }))
1762 }
1763 })
1764 }
1765
1766 pub fn root(&self) -> &Path {
1768 match self {
1769 Self::Project(project) => project.project_root(),
1770 Self::NonProject(workspace) => workspace.install_path(),
1771 }
1772 }
1773
1774 pub fn pyproject_toml(&self) -> &PyProjectToml {
1776 match self {
1777 Self::Project(project) => project.current_project().pyproject_toml(),
1778 Self::NonProject(workspace) => &workspace.pyproject_toml,
1779 }
1780 }
1781
1782 pub fn workspace(&self) -> &Workspace {
1784 match self {
1785 Self::Project(project) => project.workspace(),
1786 Self::NonProject(workspace) => workspace,
1787 }
1788 }
1789
1790 pub fn project_name(&self) -> Option<&PackageName> {
1792 match self {
1793 Self::Project(project) => Some(project.project_name()),
1794 Self::NonProject(_) => None,
1795 }
1796 }
1797
1798 pub fn is_non_project(&self) -> bool {
1800 matches!(self, Self::NonProject(_))
1801 }
1802}
1803
1804#[cfg(test)]
1805#[cfg(unix)] mod tests {
1807 use std::env;
1808 use std::path::Path;
1809 use std::str::FromStr;
1810
1811 use anyhow::Result;
1812 use assert_fs::fixture::ChildPath;
1813 use assert_fs::prelude::*;
1814 use insta::{assert_json_snapshot, assert_snapshot};
1815
1816 use uv_normalize::GroupName;
1817 use uv_pypi_types::DependencyGroupSpecifier;
1818
1819 use crate::pyproject::PyProjectToml;
1820 use crate::workspace::{DiscoveryOptions, ProjectWorkspace};
1821 use crate::{WorkspaceCache, WorkspaceError};
1822
1823 async fn workspace_test(folder: &str) -> (ProjectWorkspace, String) {
1824 let root_dir = env::current_dir()
1825 .unwrap()
1826 .parent()
1827 .unwrap()
1828 .parent()
1829 .unwrap()
1830 .join("scripts")
1831 .join("workspaces");
1832 let project = ProjectWorkspace::discover(
1833 &root_dir.join(folder),
1834 &DiscoveryOptions::default(),
1835 &WorkspaceCache::default(),
1836 )
1837 .await
1838 .unwrap();
1839 let root_escaped = regex::escape(root_dir.to_string_lossy().as_ref());
1840 (project, root_escaped)
1841 }
1842
1843 async fn temporary_test(
1844 folder: &Path,
1845 ) -> Result<(ProjectWorkspace, String), (WorkspaceError, String)> {
1846 let root_escaped = regex::escape(folder.to_string_lossy().as_ref());
1847 let project = ProjectWorkspace::discover(
1848 folder,
1849 &DiscoveryOptions::default(),
1850 &WorkspaceCache::default(),
1851 )
1852 .await
1853 .map_err(|error| (error, root_escaped.clone()))?;
1854
1855 Ok((project, root_escaped))
1856 }
1857
1858 #[tokio::test]
1859 async fn albatross_in_example() {
1860 let (project, root_escaped) =
1861 workspace_test("albatross-in-example/examples/bird-feeder").await;
1862 let filters = vec![(root_escaped.as_str(), "[ROOT]")];
1863 insta::with_settings!({filters => filters}, {
1864 assert_json_snapshot!(
1865 project,
1866 {
1867 ".workspace.packages.*.pyproject_toml" => "[PYPROJECT_TOML]"
1868 },
1869 @r#"
1870 {
1871 "project_root": "[ROOT]/albatross-in-example/examples/bird-feeder",
1872 "project_name": "bird-feeder",
1873 "workspace": {
1874 "install_path": "[ROOT]/albatross-in-example/examples/bird-feeder",
1875 "packages": {
1876 "bird-feeder": {
1877 "root": "[ROOT]/albatross-in-example/examples/bird-feeder",
1878 "project": {
1879 "name": "bird-feeder",
1880 "version": "1.0.0",
1881 "requires-python": ">=3.12",
1882 "dependencies": [
1883 "iniconfig>=2,<3"
1884 ],
1885 "optional-dependencies": null
1886 },
1887 "pyproject_toml": "[PYPROJECT_TOML]"
1888 }
1889 },
1890 "required_members": {},
1891 "sources": {},
1892 "indexes": [],
1893 "pyproject_toml": {
1894 "project": {
1895 "name": "bird-feeder",
1896 "version": "1.0.0",
1897 "requires-python": ">=3.12",
1898 "dependencies": [
1899 "iniconfig>=2,<3"
1900 ],
1901 "optional-dependencies": null
1902 },
1903 "tool": null,
1904 "dependency-groups": null
1905 }
1906 }
1907 }
1908 "#);
1909 });
1910 }
1911
1912 #[tokio::test]
1913 async fn albatross_project_in_excluded() {
1914 let (project, root_escaped) =
1915 workspace_test("albatross-project-in-excluded/excluded/bird-feeder").await;
1916 let filters = vec![(root_escaped.as_str(), "[ROOT]")];
1917 insta::with_settings!({filters => filters}, {
1918 assert_json_snapshot!(
1919 project,
1920 {
1921 ".workspace.packages.*.pyproject_toml" => "[PYPROJECT_TOML]"
1922 },
1923 @r#"
1924 {
1925 "project_root": "[ROOT]/albatross-project-in-excluded/excluded/bird-feeder",
1926 "project_name": "bird-feeder",
1927 "workspace": {
1928 "install_path": "[ROOT]/albatross-project-in-excluded/excluded/bird-feeder",
1929 "packages": {
1930 "bird-feeder": {
1931 "root": "[ROOT]/albatross-project-in-excluded/excluded/bird-feeder",
1932 "project": {
1933 "name": "bird-feeder",
1934 "version": "1.0.0",
1935 "requires-python": ">=3.12",
1936 "dependencies": [
1937 "iniconfig>=2,<3"
1938 ],
1939 "optional-dependencies": null
1940 },
1941 "pyproject_toml": "[PYPROJECT_TOML]"
1942 }
1943 },
1944 "required_members": {},
1945 "sources": {},
1946 "indexes": [],
1947 "pyproject_toml": {
1948 "project": {
1949 "name": "bird-feeder",
1950 "version": "1.0.0",
1951 "requires-python": ">=3.12",
1952 "dependencies": [
1953 "iniconfig>=2,<3"
1954 ],
1955 "optional-dependencies": null
1956 },
1957 "tool": null,
1958 "dependency-groups": null
1959 }
1960 }
1961 }
1962 "#);
1963 });
1964 }
1965
1966 #[tokio::test]
1967 async fn albatross_root_workspace() {
1968 let (project, root_escaped) = workspace_test("albatross-root-workspace").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-root-workspace",
1979 "project_name": "albatross",
1980 "workspace": {
1981 "install_path": "[ROOT]/albatross-root-workspace",
1982 "packages": {
1983 "albatross": {
1984 "root": "[ROOT]/albatross-root-workspace",
1985 "project": {
1986 "name": "albatross",
1987 "version": "0.1.0",
1988 "requires-python": ">=3.12",
1989 "dependencies": [
1990 "bird-feeder",
1991 "iniconfig>=2,<3"
1992 ],
1993 "optional-dependencies": null
1994 },
1995 "pyproject_toml": "[PYPROJECT_TOML]"
1996 },
1997 "bird-feeder": {
1998 "root": "[ROOT]/albatross-root-workspace/packages/bird-feeder",
1999 "project": {
2000 "name": "bird-feeder",
2001 "version": "1.0.0",
2002 "requires-python": ">=3.8",
2003 "dependencies": [
2004 "iniconfig>=2,<3",
2005 "seeds"
2006 ],
2007 "optional-dependencies": null
2008 },
2009 "pyproject_toml": "[PYPROJECT_TOML]"
2010 },
2011 "seeds": {
2012 "root": "[ROOT]/albatross-root-workspace/packages/seeds",
2013 "project": {
2014 "name": "seeds",
2015 "version": "1.0.0",
2016 "requires-python": ">=3.12",
2017 "dependencies": [
2018 "idna==3.6"
2019 ],
2020 "optional-dependencies": null
2021 },
2022 "pyproject_toml": "[PYPROJECT_TOML]"
2023 }
2024 },
2025 "required_members": {
2026 "bird-feeder": null,
2027 "seeds": null
2028 },
2029 "sources": {
2030 "bird-feeder": [
2031 {
2032 "workspace": true,
2033 "editable": null,
2034 "extra": null,
2035 "group": null
2036 }
2037 ]
2038 },
2039 "indexes": [],
2040 "pyproject_toml": {
2041 "project": {
2042 "name": "albatross",
2043 "version": "0.1.0",
2044 "requires-python": ">=3.12",
2045 "dependencies": [
2046 "bird-feeder",
2047 "iniconfig>=2,<3"
2048 ],
2049 "optional-dependencies": null
2050 },
2051 "tool": {
2052 "uv": {
2053 "sources": {
2054 "bird-feeder": [
2055 {
2056 "workspace": true,
2057 "editable": null,
2058 "extra": null,
2059 "group": null
2060 }
2061 ]
2062 },
2063 "index": null,
2064 "workspace": {
2065 "members": [
2066 "packages/*"
2067 ],
2068 "exclude": null
2069 },
2070 "managed": null,
2071 "package": null,
2072 "default-groups": null,
2073 "dependency-groups": null,
2074 "dev-dependencies": null,
2075 "override-dependencies": null,
2076 "exclude-dependencies": null,
2077 "constraint-dependencies": null,
2078 "build-constraint-dependencies": null,
2079 "environments": null,
2080 "required-environments": null,
2081 "conflicts": null,
2082 "build-backend": null
2083 }
2084 },
2085 "dependency-groups": null
2086 }
2087 }
2088 }
2089 "#);
2090 });
2091 }
2092
2093 #[tokio::test]
2094 async fn albatross_virtual_workspace() {
2095 let (project, root_escaped) =
2096 workspace_test("albatross-virtual-workspace/packages/albatross").await;
2097 let filters = vec![(root_escaped.as_str(), "[ROOT]")];
2098 insta::with_settings!({filters => filters}, {
2099 assert_json_snapshot!(
2100 project,
2101 {
2102 ".workspace.packages.*.pyproject_toml" => "[PYPROJECT_TOML]"
2103 },
2104 @r#"
2105 {
2106 "project_root": "[ROOT]/albatross-virtual-workspace/packages/albatross",
2107 "project_name": "albatross",
2108 "workspace": {
2109 "install_path": "[ROOT]/albatross-virtual-workspace",
2110 "packages": {
2111 "albatross": {
2112 "root": "[ROOT]/albatross-virtual-workspace/packages/albatross",
2113 "project": {
2114 "name": "albatross",
2115 "version": "0.1.0",
2116 "requires-python": ">=3.12",
2117 "dependencies": [
2118 "bird-feeder",
2119 "iniconfig>=2,<3"
2120 ],
2121 "optional-dependencies": null
2122 },
2123 "pyproject_toml": "[PYPROJECT_TOML]"
2124 },
2125 "bird-feeder": {
2126 "root": "[ROOT]/albatross-virtual-workspace/packages/bird-feeder",
2127 "project": {
2128 "name": "bird-feeder",
2129 "version": "1.0.0",
2130 "requires-python": ">=3.12",
2131 "dependencies": [
2132 "anyio>=4.3.0,<5",
2133 "seeds"
2134 ],
2135 "optional-dependencies": null
2136 },
2137 "pyproject_toml": "[PYPROJECT_TOML]"
2138 },
2139 "seeds": {
2140 "root": "[ROOT]/albatross-virtual-workspace/packages/seeds",
2141 "project": {
2142 "name": "seeds",
2143 "version": "1.0.0",
2144 "requires-python": ">=3.12",
2145 "dependencies": [
2146 "idna==3.6"
2147 ],
2148 "optional-dependencies": null
2149 },
2150 "pyproject_toml": "[PYPROJECT_TOML]"
2151 }
2152 },
2153 "required_members": {
2154 "bird-feeder": null,
2155 "seeds": null
2156 },
2157 "sources": {},
2158 "indexes": [],
2159 "pyproject_toml": {
2160 "project": null,
2161 "tool": {
2162 "uv": {
2163 "sources": null,
2164 "index": null,
2165 "workspace": {
2166 "members": [
2167 "packages/*"
2168 ],
2169 "exclude": null
2170 },
2171 "managed": null,
2172 "package": null,
2173 "default-groups": null,
2174 "dependency-groups": null,
2175 "dev-dependencies": null,
2176 "override-dependencies": null,
2177 "exclude-dependencies": null,
2178 "constraint-dependencies": null,
2179 "build-constraint-dependencies": null,
2180 "environments": null,
2181 "required-environments": null,
2182 "conflicts": null,
2183 "build-backend": null
2184 }
2185 },
2186 "dependency-groups": null
2187 }
2188 }
2189 }
2190 "#);
2191 });
2192 }
2193
2194 #[tokio::test]
2195 async fn albatross_just_project() {
2196 let (project, root_escaped) = workspace_test("albatross-just-project").await;
2197 let filters = vec![(root_escaped.as_str(), "[ROOT]")];
2198 insta::with_settings!({filters => filters}, {
2199 assert_json_snapshot!(
2200 project,
2201 {
2202 ".workspace.packages.*.pyproject_toml" => "[PYPROJECT_TOML]"
2203 },
2204 @r#"
2205 {
2206 "project_root": "[ROOT]/albatross-just-project",
2207 "project_name": "albatross",
2208 "workspace": {
2209 "install_path": "[ROOT]/albatross-just-project",
2210 "packages": {
2211 "albatross": {
2212 "root": "[ROOT]/albatross-just-project",
2213 "project": {
2214 "name": "albatross",
2215 "version": "0.1.0",
2216 "requires-python": ">=3.12",
2217 "dependencies": [
2218 "iniconfig>=2,<3"
2219 ],
2220 "optional-dependencies": null
2221 },
2222 "pyproject_toml": "[PYPROJECT_TOML]"
2223 }
2224 },
2225 "required_members": {},
2226 "sources": {},
2227 "indexes": [],
2228 "pyproject_toml": {
2229 "project": {
2230 "name": "albatross",
2231 "version": "0.1.0",
2232 "requires-python": ">=3.12",
2233 "dependencies": [
2234 "iniconfig>=2,<3"
2235 ],
2236 "optional-dependencies": null
2237 },
2238 "tool": null,
2239 "dependency-groups": null
2240 }
2241 }
2242 }
2243 "#);
2244 });
2245 }
2246
2247 #[tokio::test]
2248 async fn exclude_package() -> Result<()> {
2249 let root = tempfile::TempDir::new()?;
2250 let root = ChildPath::new(root.path());
2251
2252 root.child("pyproject.toml").write_str(
2254 r#"
2255 [project]
2256 name = "albatross"
2257 version = "0.1.0"
2258 requires-python = ">=3.12"
2259 dependencies = ["tqdm>=4,<5"]
2260
2261 [tool.uv.workspace]
2262 members = ["packages/*"]
2263 exclude = ["packages/bird-feeder"]
2264
2265 [build-system]
2266 requires = ["hatchling"]
2267 build-backend = "hatchling.build"
2268 "#,
2269 )?;
2270 root.child("albatross").child("__init__.py").touch()?;
2271
2272 root.child("packages")
2274 .child("seeds")
2275 .child("pyproject.toml")
2276 .write_str(
2277 r#"
2278 [project]
2279 name = "seeds"
2280 version = "1.0.0"
2281 requires-python = ">=3.12"
2282 dependencies = ["idna==3.6"]
2283
2284 [build-system]
2285 requires = ["hatchling"]
2286 build-backend = "hatchling.build"
2287 "#,
2288 )?;
2289 root.child("packages")
2290 .child("seeds")
2291 .child("seeds")
2292 .child("__init__.py")
2293 .touch()?;
2294
2295 root.child("packages")
2297 .child("bird-feeder")
2298 .child("pyproject.toml")
2299 .write_str(
2300 r#"
2301 [project]
2302 name = "bird-feeder"
2303 version = "1.0.0"
2304 requires-python = ">=3.12"
2305 dependencies = ["anyio>=4.3.0,<5"]
2306
2307 [build-system]
2308 requires = ["hatchling"]
2309 build-backend = "hatchling.build"
2310 "#,
2311 )?;
2312 root.child("packages")
2313 .child("bird-feeder")
2314 .child("bird_feeder")
2315 .child("__init__.py")
2316 .touch()?;
2317
2318 let (project, root_escaped) = temporary_test(root.as_ref()).await.unwrap();
2319 let filters = vec![(root_escaped.as_str(), "[ROOT]")];
2320 insta::with_settings!({filters => filters}, {
2321 assert_json_snapshot!(
2322 project,
2323 {
2324 ".workspace.packages.*.pyproject_toml" => "[PYPROJECT_TOML]"
2325 },
2326 @r#"
2327 {
2328 "project_root": "[ROOT]",
2329 "project_name": "albatross",
2330 "workspace": {
2331 "install_path": "[ROOT]",
2332 "packages": {
2333 "albatross": {
2334 "root": "[ROOT]",
2335 "project": {
2336 "name": "albatross",
2337 "version": "0.1.0",
2338 "requires-python": ">=3.12",
2339 "dependencies": [
2340 "tqdm>=4,<5"
2341 ],
2342 "optional-dependencies": null
2343 },
2344 "pyproject_toml": "[PYPROJECT_TOML]"
2345 },
2346 "seeds": {
2347 "root": "[ROOT]/packages/seeds",
2348 "project": {
2349 "name": "seeds",
2350 "version": "1.0.0",
2351 "requires-python": ">=3.12",
2352 "dependencies": [
2353 "idna==3.6"
2354 ],
2355 "optional-dependencies": null
2356 },
2357 "pyproject_toml": "[PYPROJECT_TOML]"
2358 }
2359 },
2360 "required_members": {},
2361 "sources": {},
2362 "indexes": [],
2363 "pyproject_toml": {
2364 "project": {
2365 "name": "albatross",
2366 "version": "0.1.0",
2367 "requires-python": ">=3.12",
2368 "dependencies": [
2369 "tqdm>=4,<5"
2370 ],
2371 "optional-dependencies": null
2372 },
2373 "tool": {
2374 "uv": {
2375 "sources": null,
2376 "index": null,
2377 "workspace": {
2378 "members": [
2379 "packages/*"
2380 ],
2381 "exclude": [
2382 "packages/bird-feeder"
2383 ]
2384 },
2385 "managed": null,
2386 "package": null,
2387 "default-groups": null,
2388 "dependency-groups": null,
2389 "dev-dependencies": null,
2390 "override-dependencies": null,
2391 "exclude-dependencies": null,
2392 "constraint-dependencies": null,
2393 "build-constraint-dependencies": null,
2394 "environments": null,
2395 "required-environments": null,
2396 "conflicts": null,
2397 "build-backend": null
2398 }
2399 },
2400 "dependency-groups": null
2401 }
2402 }
2403 }
2404 "#);
2405 });
2406
2407 root.child("pyproject.toml").write_str(
2409 r#"
2410 [project]
2411 name = "albatross"
2412 version = "0.1.0"
2413 requires-python = ">=3.12"
2414 dependencies = ["tqdm>=4,<5"]
2415
2416 [tool.uv.workspace]
2417 members = ["packages/seeds", "packages/bird-feeder"]
2418 exclude = ["packages/bird-feeder"]
2419
2420 [build-system]
2421 requires = ["hatchling"]
2422 build-backend = "hatchling.build"
2423 "#,
2424 )?;
2425
2426 let (project, root_escaped) = temporary_test(root.as_ref()).await.unwrap();
2428 let filters = vec![(root_escaped.as_str(), "[ROOT]")];
2429 insta::with_settings!({filters => filters}, {
2430 assert_json_snapshot!(
2431 project,
2432 {
2433 ".workspace.packages.*.pyproject_toml" => "[PYPROJECT_TOML]"
2434 },
2435 @r#"
2436 {
2437 "project_root": "[ROOT]",
2438 "project_name": "albatross",
2439 "workspace": {
2440 "install_path": "[ROOT]",
2441 "packages": {
2442 "albatross": {
2443 "root": "[ROOT]",
2444 "project": {
2445 "name": "albatross",
2446 "version": "0.1.0",
2447 "requires-python": ">=3.12",
2448 "dependencies": [
2449 "tqdm>=4,<5"
2450 ],
2451 "optional-dependencies": null
2452 },
2453 "pyproject_toml": "[PYPROJECT_TOML]"
2454 },
2455 "seeds": {
2456 "root": "[ROOT]/packages/seeds",
2457 "project": {
2458 "name": "seeds",
2459 "version": "1.0.0",
2460 "requires-python": ">=3.12",
2461 "dependencies": [
2462 "idna==3.6"
2463 ],
2464 "optional-dependencies": null
2465 },
2466 "pyproject_toml": "[PYPROJECT_TOML]"
2467 }
2468 },
2469 "required_members": {},
2470 "sources": {},
2471 "indexes": [],
2472 "pyproject_toml": {
2473 "project": {
2474 "name": "albatross",
2475 "version": "0.1.0",
2476 "requires-python": ">=3.12",
2477 "dependencies": [
2478 "tqdm>=4,<5"
2479 ],
2480 "optional-dependencies": null
2481 },
2482 "tool": {
2483 "uv": {
2484 "sources": null,
2485 "index": null,
2486 "workspace": {
2487 "members": [
2488 "packages/seeds",
2489 "packages/bird-feeder"
2490 ],
2491 "exclude": [
2492 "packages/bird-feeder"
2493 ]
2494 },
2495 "managed": null,
2496 "package": null,
2497 "default-groups": null,
2498 "dependency-groups": null,
2499 "dev-dependencies": null,
2500 "override-dependencies": null,
2501 "exclude-dependencies": null,
2502 "constraint-dependencies": null,
2503 "build-constraint-dependencies": null,
2504 "environments": null,
2505 "required-environments": null,
2506 "conflicts": null,
2507 "build-backend": null
2508 }
2509 },
2510 "dependency-groups": null
2511 }
2512 }
2513 }
2514 "#);
2515 });
2516
2517 root.child("pyproject.toml").write_str(
2519 r#"
2520 [project]
2521 name = "albatross"
2522 version = "0.1.0"
2523 requires-python = ">=3.12"
2524 dependencies = ["tqdm>=4,<5"]
2525
2526 [tool.uv.workspace]
2527 members = ["packages/seeds", "packages/bird-feeder"]
2528 exclude = ["packages"]
2529
2530 [build-system]
2531 requires = ["hatchling"]
2532 build-backend = "hatchling.build"
2533 "#,
2534 )?;
2535
2536 let (project, root_escaped) = temporary_test(root.as_ref()).await.unwrap();
2538 let filters = vec![(root_escaped.as_str(), "[ROOT]")];
2539 insta::with_settings!({filters => filters}, {
2540 assert_json_snapshot!(
2541 project,
2542 {
2543 ".workspace.packages.*.pyproject_toml" => "[PYPROJECT_TOML]"
2544 },
2545 @r#"
2546 {
2547 "project_root": "[ROOT]",
2548 "project_name": "albatross",
2549 "workspace": {
2550 "install_path": "[ROOT]",
2551 "packages": {
2552 "albatross": {
2553 "root": "[ROOT]",
2554 "project": {
2555 "name": "albatross",
2556 "version": "0.1.0",
2557 "requires-python": ">=3.12",
2558 "dependencies": [
2559 "tqdm>=4,<5"
2560 ],
2561 "optional-dependencies": null
2562 },
2563 "pyproject_toml": "[PYPROJECT_TOML]"
2564 },
2565 "bird-feeder": {
2566 "root": "[ROOT]/packages/bird-feeder",
2567 "project": {
2568 "name": "bird-feeder",
2569 "version": "1.0.0",
2570 "requires-python": ">=3.12",
2571 "dependencies": [
2572 "anyio>=4.3.0,<5"
2573 ],
2574 "optional-dependencies": null
2575 },
2576 "pyproject_toml": "[PYPROJECT_TOML]"
2577 },
2578 "seeds": {
2579 "root": "[ROOT]/packages/seeds",
2580 "project": {
2581 "name": "seeds",
2582 "version": "1.0.0",
2583 "requires-python": ">=3.12",
2584 "dependencies": [
2585 "idna==3.6"
2586 ],
2587 "optional-dependencies": null
2588 },
2589 "pyproject_toml": "[PYPROJECT_TOML]"
2590 }
2591 },
2592 "required_members": {},
2593 "sources": {},
2594 "indexes": [],
2595 "pyproject_toml": {
2596 "project": {
2597 "name": "albatross",
2598 "version": "0.1.0",
2599 "requires-python": ">=3.12",
2600 "dependencies": [
2601 "tqdm>=4,<5"
2602 ],
2603 "optional-dependencies": null
2604 },
2605 "tool": {
2606 "uv": {
2607 "sources": null,
2608 "index": null,
2609 "workspace": {
2610 "members": [
2611 "packages/seeds",
2612 "packages/bird-feeder"
2613 ],
2614 "exclude": [
2615 "packages"
2616 ]
2617 },
2618 "managed": null,
2619 "package": null,
2620 "default-groups": null,
2621 "dependency-groups": null,
2622 "dev-dependencies": null,
2623 "override-dependencies": null,
2624 "exclude-dependencies": null,
2625 "constraint-dependencies": null,
2626 "build-constraint-dependencies": null,
2627 "environments": null,
2628 "required-environments": null,
2629 "conflicts": null,
2630 "build-backend": null
2631 }
2632 },
2633 "dependency-groups": null
2634 }
2635 }
2636 }
2637 "#);
2638 });
2639
2640 root.child("pyproject.toml").write_str(
2642 r#"
2643 [project]
2644 name = "albatross"
2645 version = "0.1.0"
2646 requires-python = ">=3.12"
2647 dependencies = ["tqdm>=4,<5"]
2648
2649 [tool.uv.workspace]
2650 members = ["packages/seeds", "packages/bird-feeder"]
2651 exclude = ["packages/*"]
2652
2653 [build-system]
2654 requires = ["hatchling"]
2655 build-backend = "hatchling.build"
2656 "#,
2657 )?;
2658
2659 let (project, root_escaped) = temporary_test(root.as_ref()).await.unwrap();
2661 let filters = vec![(root_escaped.as_str(), "[ROOT]")];
2662 insta::with_settings!({filters => filters}, {
2663 assert_json_snapshot!(
2664 project,
2665 {
2666 ".workspace.packages.*.pyproject_toml" => "[PYPROJECT_TOML]"
2667 },
2668 @r#"
2669 {
2670 "project_root": "[ROOT]",
2671 "project_name": "albatross",
2672 "workspace": {
2673 "install_path": "[ROOT]",
2674 "packages": {
2675 "albatross": {
2676 "root": "[ROOT]",
2677 "project": {
2678 "name": "albatross",
2679 "version": "0.1.0",
2680 "requires-python": ">=3.12",
2681 "dependencies": [
2682 "tqdm>=4,<5"
2683 ],
2684 "optional-dependencies": null
2685 },
2686 "pyproject_toml": "[PYPROJECT_TOML]"
2687 }
2688 },
2689 "required_members": {},
2690 "sources": {},
2691 "indexes": [],
2692 "pyproject_toml": {
2693 "project": {
2694 "name": "albatross",
2695 "version": "0.1.0",
2696 "requires-python": ">=3.12",
2697 "dependencies": [
2698 "tqdm>=4,<5"
2699 ],
2700 "optional-dependencies": null
2701 },
2702 "tool": {
2703 "uv": {
2704 "sources": null,
2705 "index": null,
2706 "workspace": {
2707 "members": [
2708 "packages/seeds",
2709 "packages/bird-feeder"
2710 ],
2711 "exclude": [
2712 "packages/*"
2713 ]
2714 },
2715 "managed": null,
2716 "package": null,
2717 "default-groups": null,
2718 "dependency-groups": null,
2719 "dev-dependencies": null,
2720 "override-dependencies": null,
2721 "exclude-dependencies": null,
2722 "constraint-dependencies": null,
2723 "build-constraint-dependencies": null,
2724 "environments": null,
2725 "required-environments": null,
2726 "conflicts": null,
2727 "build-backend": null
2728 }
2729 },
2730 "dependency-groups": null
2731 }
2732 }
2733 }
2734 "#);
2735 });
2736
2737 Ok(())
2738 }
2739
2740 #[test]
2741 fn read_dependency_groups() {
2742 let toml = r#"
2743[dependency-groups]
2744foo = ["a", {include-group = "bar"}]
2745bar = ["b"]
2746"#;
2747
2748 let result =
2749 PyProjectToml::from_string(toml.to_string()).expect("Deserialization should succeed");
2750
2751 let groups = result
2752 .dependency_groups
2753 .expect("`dependency-groups` should be present");
2754 let foo = groups
2755 .get(&GroupName::from_str("foo").unwrap())
2756 .expect("Group `foo` should be present");
2757 assert_eq!(
2758 foo,
2759 &[
2760 DependencyGroupSpecifier::Requirement("a".to_string()),
2761 DependencyGroupSpecifier::IncludeGroup {
2762 include_group: GroupName::from_str("bar").unwrap(),
2763 }
2764 ]
2765 );
2766
2767 let bar = groups
2768 .get(&GroupName::from_str("bar").unwrap())
2769 .expect("Group `bar` should be present");
2770 assert_eq!(
2771 bar,
2772 &[DependencyGroupSpecifier::Requirement("b".to_string())]
2773 );
2774 }
2775
2776 #[tokio::test]
2777 async fn nested_workspace() -> Result<()> {
2778 let root = tempfile::TempDir::new()?;
2779 let root = ChildPath::new(root.path());
2780
2781 root.child("pyproject.toml").write_str(
2783 r#"
2784 [project]
2785 name = "albatross"
2786 version = "0.1.0"
2787 requires-python = ">=3.12"
2788 dependencies = ["tqdm>=4,<5"]
2789
2790 [tool.uv.workspace]
2791 members = ["packages/*"]
2792 "#,
2793 )?;
2794
2795 root.child("packages")
2797 .child("seeds")
2798 .child("pyproject.toml")
2799 .write_str(
2800 r#"
2801 [project]
2802 name = "seeds"
2803 version = "1.0.0"
2804 requires-python = ">=3.12"
2805 dependencies = ["idna==3.6"]
2806
2807 [tool.uv.workspace]
2808 members = ["nested_packages/*"]
2809 "#,
2810 )?;
2811
2812 let (error, root_escaped) = temporary_test(root.as_ref()).await.unwrap_err();
2813 let filters = vec![(root_escaped.as_str(), "[ROOT]")];
2814 insta::with_settings!({filters => filters}, {
2815 assert_snapshot!(
2816 error,
2817 @"Nested workspaces are not supported, but workspace member (`[ROOT]/packages/seeds`) has a `uv.workspace` table");
2818 });
2819
2820 Ok(())
2821 }
2822
2823 #[tokio::test]
2824 async fn duplicate_names() -> Result<()> {
2825 let root = tempfile::TempDir::new()?;
2826 let root = ChildPath::new(root.path());
2827
2828 root.child("pyproject.toml").write_str(
2830 r#"
2831 [project]
2832 name = "albatross"
2833 version = "0.1.0"
2834 requires-python = ">=3.12"
2835 dependencies = ["tqdm>=4,<5"]
2836
2837 [tool.uv.workspace]
2838 members = ["packages/*"]
2839 "#,
2840 )?;
2841
2842 root.child("packages")
2844 .child("seeds")
2845 .child("pyproject.toml")
2846 .write_str(
2847 r#"
2848 [project]
2849 name = "seeds"
2850 version = "1.0.0"
2851 requires-python = ">=3.12"
2852 dependencies = ["idna==3.6"]
2853
2854 [tool.uv.workspace]
2855 members = ["nested_packages/*"]
2856 "#,
2857 )?;
2858
2859 root.child("packages")
2861 .child("seeds2")
2862 .child("pyproject.toml")
2863 .write_str(
2864 r#"
2865 [project]
2866 name = "seeds"
2867 version = "1.0.0"
2868 requires-python = ">=3.12"
2869 dependencies = ["idna==3.6"]
2870
2871 [tool.uv.workspace]
2872 members = ["nested_packages/*"]
2873 "#,
2874 )?;
2875
2876 let (error, root_escaped) = temporary_test(root.as_ref()).await.unwrap_err();
2877 let filters = vec![(root_escaped.as_str(), "[ROOT]")];
2878 insta::with_settings!({filters => filters}, {
2879 assert_snapshot!(
2880 error,
2881 @"Two workspace members are both named `seeds`: `[ROOT]/packages/seeds` and `[ROOT]/packages/seeds2`");
2882 });
2883
2884 Ok(())
2885 }
2886}