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