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