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