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