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