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 pub fn allows_implicit_workspace(&self) -> bool {
119 matches!(self, Self::Optional)
120 }
121
122 pub 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 pub 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 pub 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 pub 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 pub fn update_member(
1401 self,
1402 pyproject_toml: PyProjectToml,
1403 ) -> Result<Option<Self>, WorkspaceError> {
1404 let Some(workspace) = self
1405 .workspace
1406 .update_member(&self.project_name, pyproject_toml)?
1407 else {
1408 return Ok(None);
1409 };
1410 Ok(Some(Self { workspace, ..self }))
1411 }
1412
1413 async fn from_project(
1415 install_path: &Path,
1416 project: &Project,
1417 project_pyproject_toml: &PyProjectToml,
1418 options: &DiscoveryOptions,
1419 cache: &WorkspaceCache,
1420 ) -> Result<Self, WorkspaceError> {
1421 let project_path = std::path::absolute(install_path)
1422 .map_err(WorkspaceError::Normalize)?
1423 .clone();
1424 let project_path = normalize_path(&project_path);
1425
1426 if project_pyproject_toml
1428 .tool
1429 .as_ref()
1430 .and_then(|tool| tool.uv.as_ref())
1431 .and_then(|uv| uv.managed)
1432 == Some(false)
1433 {
1434 debug!("Project `{}` is marked as unmanaged", project.name);
1435 return Err(WorkspaceError::NonWorkspace(project_path.to_path_buf()));
1436 }
1437
1438 let mut workspace = project_pyproject_toml
1440 .tool
1441 .as_ref()
1442 .and_then(|tool| tool.uv.as_ref())
1443 .and_then(|uv| uv.workspace.as_ref())
1444 .map(|workspace| {
1445 (
1446 project_path.to_path_buf(),
1447 workspace.clone(),
1448 project_pyproject_toml.clone(),
1449 )
1450 });
1451
1452 if workspace.is_none() {
1453 workspace = find_workspace(&project_path, options).await?;
1456 }
1457
1458 let current_project = WorkspaceMember {
1459 root: project_path.to_path_buf(),
1460 project: project.clone(),
1461 pyproject_toml: project_pyproject_toml.clone(),
1462 };
1463
1464 let Some((workspace_root, workspace_definition, workspace_pyproject_toml)) = workspace
1465 else {
1466 debug!("No workspace root found, using project root");
1469
1470 let current_project_as_members = Arc::new(BTreeMap::from_iter([(
1471 project.name.clone(),
1472 current_project,
1473 )]));
1474 let workspace_sources = BTreeMap::default();
1475 let required_members = Workspace::collect_required_members(
1476 ¤t_project_as_members,
1477 &workspace_sources,
1478 project_pyproject_toml,
1479 )?;
1480
1481 return Ok(Self {
1482 project_root: project_path.to_path_buf(),
1483 project_name: project.name.clone(),
1484 workspace: Workspace {
1485 install_path: project_path.to_path_buf(),
1486 packages: current_project_as_members,
1487 required_members,
1488 sources: workspace_sources,
1491 indexes: Vec::default(),
1492 pyproject_toml: project_pyproject_toml.clone(),
1493 },
1494 });
1495 };
1496
1497 debug!(
1498 "Found workspace root: `{}`",
1499 workspace_root.simplified_display()
1500 );
1501
1502 let workspace = Workspace::collect_members(
1503 workspace_root,
1504 workspace_definition,
1505 workspace_pyproject_toml,
1506 Some(current_project),
1507 options,
1508 cache,
1509 )
1510 .await?;
1511
1512 Ok(Self {
1513 project_root: project_path.to_path_buf(),
1514 project_name: project.name.clone(),
1515 workspace,
1516 })
1517 }
1518}
1519
1520async fn find_workspace(
1522 project_root: &Path,
1523 options: &DiscoveryOptions,
1524) -> Result<Option<(PathBuf, ToolUvWorkspace, PyProjectToml)>, WorkspaceError> {
1525 for workspace_root in project_root
1527 .ancestors()
1528 .take_while(|path| {
1529 options
1531 .stop_discovery_at
1532 .as_deref()
1533 .and_then(Path::parent)
1534 .map(|stop_discovery_at| stop_discovery_at != *path)
1535 .unwrap_or(true)
1536 })
1537 .skip(1)
1538 {
1539 let pyproject_path = workspace_root.join("pyproject.toml");
1540 if !pyproject_path.is_file() {
1541 continue;
1542 }
1543 trace!(
1544 "Found `pyproject.toml` at: `{}`",
1545 pyproject_path.simplified_display()
1546 );
1547
1548 let contents = fs_err::tokio::read_to_string(&pyproject_path).await?;
1550 let pyproject_toml = PyProjectToml::from_string(contents, &pyproject_path)
1551 .map_err(|err| WorkspaceError::Toml(pyproject_path.clone(), Box::new(err)))?;
1552
1553 return if let Some(workspace) = pyproject_toml
1554 .tool
1555 .as_ref()
1556 .and_then(|tool| tool.uv.as_ref())
1557 .and_then(|uv| uv.workspace.as_ref())
1558 {
1559 if !is_included_in_workspace(project_root, workspace_root, workspace)? {
1560 debug!(
1561 "Found workspace root `{}`, but project is not included",
1562 workspace_root.simplified_display()
1563 );
1564 return Ok(None);
1565 }
1566
1567 if is_excluded_from_workspace(project_root, workspace_root, workspace)? {
1568 debug!(
1569 "Found workspace root `{}`, but project is excluded",
1570 workspace_root.simplified_display()
1571 );
1572 return Ok(None);
1573 }
1574
1575 Ok(Some((
1577 workspace_root.to_path_buf(),
1578 workspace.clone(),
1579 pyproject_toml,
1580 )))
1581 } else if pyproject_toml.project.is_some() {
1582 debug!(
1601 "Project is contained in non-workspace project: `{}`",
1602 workspace_root.simplified_display()
1603 );
1604 Ok(None)
1605 } else {
1606 warn!(
1608 "`pyproject.toml` does not contain a `project` table: `{}`",
1609 pyproject_path.simplified_display()
1610 );
1611 Ok(None)
1612 };
1613 }
1614
1615 Ok(None)
1616}
1617
1618fn has_only_gitignored_files(path: &Path) -> bool {
1623 let walker = ignore::WalkBuilder::new(path)
1624 .hidden(false)
1625 .parents(true)
1626 .ignore(true)
1627 .git_ignore(true)
1628 .git_global(true)
1629 .git_exclude(true)
1630 .build();
1631
1632 for entry in walker {
1633 let Ok(entry) = entry else {
1634 return false;
1636 };
1637
1638 if entry.path().is_dir() {
1640 continue;
1641 }
1642
1643 return false;
1645 }
1646
1647 true
1648}
1649
1650fn is_excluded_from_workspace(
1652 project_path: &Path,
1653 workspace_root: &Path,
1654 workspace: &ToolUvWorkspace,
1655) -> Result<bool, WorkspaceError> {
1656 for exclude_glob in workspace.exclude.iter().flatten() {
1657 let normalized_glob = normalize_path(Path::new(exclude_glob.as_str()));
1659 let absolute_glob = PathBuf::from(glob::Pattern::escape(
1660 workspace_root.simplified().to_string_lossy().as_ref(),
1661 ))
1662 .join(normalized_glob.as_ref());
1663 let absolute_glob = absolute_glob.to_string_lossy();
1664 let exclude_pattern = glob::Pattern::new(&absolute_glob)
1665 .map_err(|err| WorkspaceError::Pattern(absolute_glob.to_string(), err))?;
1666 if exclude_pattern.matches_path(project_path) {
1667 return Ok(true);
1668 }
1669 }
1670 Ok(false)
1671}
1672
1673fn is_included_in_workspace(
1675 project_path: &Path,
1676 workspace_root: &Path,
1677 workspace: &ToolUvWorkspace,
1678) -> Result<bool, WorkspaceError> {
1679 for member_glob in workspace.members.iter().flatten() {
1680 let normalized_glob = normalize_path(Path::new(member_glob.as_str()));
1682 let absolute_glob = PathBuf::from(glob::Pattern::escape(
1683 workspace_root.simplified().to_string_lossy().as_ref(),
1684 ))
1685 .join(normalized_glob);
1686 let absolute_glob = absolute_glob.to_string_lossy();
1687 let include_pattern = glob::Pattern::new(&absolute_glob)
1688 .map_err(|err| WorkspaceError::Pattern(absolute_glob.to_string(), err))?;
1689 if include_pattern.matches_path(project_path) {
1690 return Ok(true);
1691 }
1692 }
1693 Ok(false)
1694}
1695
1696#[derive(Debug, Clone)]
1701pub enum VirtualProject {
1702 Project(ProjectWorkspace),
1704 NonProject(Workspace),
1706}
1707
1708impl VirtualProject {
1709 pub async fn discover(
1717 path: &Path,
1718 options: &DiscoveryOptions,
1719 cache: &WorkspaceCache,
1720 ) -> Result<Self, WorkspaceError> {
1721 assert!(
1722 path.is_absolute(),
1723 "virtual project discovery with relative path"
1724 );
1725 let project_root = path
1726 .ancestors()
1727 .take_while(|path| {
1728 options
1730 .stop_discovery_at
1731 .as_deref()
1732 .and_then(Path::parent)
1733 .map(|stop_discovery_at| stop_discovery_at != *path)
1734 .unwrap_or(true)
1735 })
1736 .find(|path| path.join("pyproject.toml").is_file())
1737 .ok_or(WorkspaceError::MissingPyprojectToml)?;
1738
1739 debug!(
1740 "Found project root: `{}`",
1741 project_root.simplified_display()
1742 );
1743
1744 let pyproject_path = project_root.join("pyproject.toml");
1746 let contents = fs_err::tokio::read_to_string(&pyproject_path).await?;
1747 let pyproject_toml = PyProjectToml::from_string(contents, &pyproject_path)
1748 .map_err(|err| WorkspaceError::Toml(pyproject_path.clone(), Box::new(err)))?;
1749
1750 if let Some(project) = pyproject_toml.project.as_ref() {
1751 let project = ProjectWorkspace::from_project(
1753 project_root,
1754 project,
1755 &pyproject_toml,
1756 options,
1757 cache,
1758 )
1759 .await?;
1760 Ok(Self::Project(project))
1761 } else if let Some(workspace) = pyproject_toml
1762 .tool
1763 .as_ref()
1764 .and_then(|tool| tool.uv.as_ref())
1765 .and_then(|uv| uv.workspace.as_ref())
1766 .filter(|_| options.project.allows_non_project_workspace())
1767 {
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 workspace.clone(),
1777 pyproject_toml,
1778 None,
1779 options,
1780 cache,
1781 )
1782 .await?;
1783
1784 Ok(Self::NonProject(workspace))
1785 } else if options.project.allows_implicit_workspace() {
1786 let project_path = std::path::absolute(project_root)
1789 .map_err(WorkspaceError::Normalize)?
1790 .clone();
1791
1792 let workspace = Workspace::collect_members(
1793 project_path,
1794 ToolUvWorkspace::default(),
1795 pyproject_toml,
1796 None,
1797 options,
1798 cache,
1799 )
1800 .await?;
1801
1802 Ok(Self::NonProject(workspace))
1803 } else {
1804 Err(WorkspaceError::MissingProject(pyproject_path))
1805 }
1806 }
1807
1808 pub async fn discover_with_package(
1810 path: &Path,
1811 options: &DiscoveryOptions,
1812 cache: &WorkspaceCache,
1813 package: PackageName,
1814 ) -> Result<Self, WorkspaceError> {
1815 let workspace = Workspace::discover(path, options, cache).await?;
1816 let project_workspace = Workspace::with_current_project(workspace.clone(), package.clone());
1817 Ok(Self::Project(project_workspace.ok_or_else(|| {
1818 WorkspaceError::NoSuchMember(package.clone(), workspace.install_path)
1819 })?))
1820 }
1821
1822 pub fn update_member(
1826 self,
1827 pyproject_toml: PyProjectToml,
1828 ) -> Result<Option<Self>, WorkspaceError> {
1829 Ok(match self {
1830 Self::Project(project) => {
1831 let Some(project) = project.update_member(pyproject_toml)? else {
1832 return Ok(None);
1833 };
1834 Some(Self::Project(project))
1835 }
1836 Self::NonProject(workspace) => {
1837 Some(Self::NonProject(Workspace {
1840 pyproject_toml,
1841 ..workspace.clone()
1842 }))
1843 }
1844 })
1845 }
1846
1847 pub fn root(&self) -> &Path {
1849 match self {
1850 Self::Project(project) => project.project_root(),
1851 Self::NonProject(workspace) => workspace.install_path(),
1852 }
1853 }
1854
1855 pub fn pyproject_toml(&self) -> &PyProjectToml {
1857 match self {
1858 Self::Project(project) => project.current_project().pyproject_toml(),
1859 Self::NonProject(workspace) => &workspace.pyproject_toml,
1860 }
1861 }
1862
1863 pub fn workspace(&self) -> &Workspace {
1865 match self {
1866 Self::Project(project) => project.workspace(),
1867 Self::NonProject(workspace) => workspace,
1868 }
1869 }
1870
1871 pub fn project_name(&self) -> Option<&PackageName> {
1873 match self {
1874 Self::Project(project) => Some(project.project_name()),
1875 Self::NonProject(_) => None,
1876 }
1877 }
1878
1879 pub fn is_non_project(&self) -> bool {
1881 matches!(self, Self::NonProject(_))
1882 }
1883}
1884
1885#[cfg(test)]
1886#[cfg(unix)] mod tests {
1888 use std::env;
1889 use std::path::Path;
1890 use std::str::FromStr;
1891
1892 use anyhow::Result;
1893 use assert_fs::fixture::ChildPath;
1894 use assert_fs::prelude::*;
1895 use insta::{assert_json_snapshot, assert_snapshot};
1896
1897 use uv_normalize::GroupName;
1898 use uv_pypi_types::DependencyGroupSpecifier;
1899
1900 use crate::pyproject::PyProjectToml;
1901 use crate::workspace::{DiscoveryOptions, ProjectWorkspace};
1902 use crate::{WorkspaceCache, WorkspaceError};
1903
1904 async fn workspace_test(folder: &str) -> (ProjectWorkspace, String) {
1905 let root_dir = env::current_dir()
1906 .unwrap()
1907 .parent()
1908 .unwrap()
1909 .parent()
1910 .unwrap()
1911 .join("test")
1912 .join("workspaces");
1913 let project = ProjectWorkspace::discover(
1914 &root_dir.join(folder),
1915 &DiscoveryOptions::default(),
1916 &WorkspaceCache::default(),
1917 )
1918 .await
1919 .unwrap();
1920 let root_escaped = regex::escape(root_dir.to_string_lossy().as_ref());
1921 (project, root_escaped)
1922 }
1923
1924 async fn temporary_test(
1925 folder: &Path,
1926 ) -> Result<(ProjectWorkspace, String), (WorkspaceError, String)> {
1927 let root_escaped = regex::escape(folder.to_string_lossy().as_ref());
1928 let project = ProjectWorkspace::discover(
1929 folder,
1930 &DiscoveryOptions::default(),
1931 &WorkspaceCache::default(),
1932 )
1933 .await
1934 .map_err(|error| (error, root_escaped.clone()))?;
1935
1936 Ok((project, root_escaped))
1937 }
1938
1939 #[tokio::test]
1940 async fn albatross_in_example() {
1941 let (project, root_escaped) =
1942 workspace_test("albatross-in-example/examples/bird-feeder").await;
1943 let filters = vec![(root_escaped.as_str(), "[ROOT]")];
1944 insta::with_settings!({filters => filters}, {
1945 assert_json_snapshot!(
1946 project,
1947 {
1948 ".workspace.packages.*.pyproject_toml" => "[PYPROJECT_TOML]"
1949 },
1950 @r#"
1951 {
1952 "project_root": "[ROOT]/albatross-in-example/examples/bird-feeder",
1953 "project_name": "bird-feeder",
1954 "workspace": {
1955 "install_path": "[ROOT]/albatross-in-example/examples/bird-feeder",
1956 "packages": {
1957 "bird-feeder": {
1958 "root": "[ROOT]/albatross-in-example/examples/bird-feeder",
1959 "project": {
1960 "name": "bird-feeder",
1961 "version": "1.0.0",
1962 "requires-python": ">=3.12",
1963 "dependencies": [
1964 "iniconfig>=2,<3"
1965 ],
1966 "optional-dependencies": null
1967 },
1968 "pyproject_toml": "[PYPROJECT_TOML]"
1969 }
1970 },
1971 "required_members": {},
1972 "sources": {},
1973 "indexes": [],
1974 "pyproject_toml": {
1975 "project": {
1976 "name": "bird-feeder",
1977 "version": "1.0.0",
1978 "requires-python": ">=3.12",
1979 "dependencies": [
1980 "iniconfig>=2,<3"
1981 ],
1982 "optional-dependencies": null
1983 },
1984 "tool": null,
1985 "dependency-groups": null
1986 }
1987 }
1988 }
1989 "#);
1990 });
1991 }
1992
1993 #[tokio::test]
1994 async fn albatross_project_in_excluded() {
1995 let (project, root_escaped) =
1996 workspace_test("albatross-project-in-excluded/excluded/bird-feeder").await;
1997 let filters = vec![(root_escaped.as_str(), "[ROOT]")];
1998 insta::with_settings!({filters => filters}, {
1999 assert_json_snapshot!(
2000 project,
2001 {
2002 ".workspace.packages.*.pyproject_toml" => "[PYPROJECT_TOML]"
2003 },
2004 @r#"
2005 {
2006 "project_root": "[ROOT]/albatross-project-in-excluded/excluded/bird-feeder",
2007 "project_name": "bird-feeder",
2008 "workspace": {
2009 "install_path": "[ROOT]/albatross-project-in-excluded/excluded/bird-feeder",
2010 "packages": {
2011 "bird-feeder": {
2012 "root": "[ROOT]/albatross-project-in-excluded/excluded/bird-feeder",
2013 "project": {
2014 "name": "bird-feeder",
2015 "version": "1.0.0",
2016 "requires-python": ">=3.12",
2017 "dependencies": [
2018 "iniconfig>=2,<3"
2019 ],
2020 "optional-dependencies": null
2021 },
2022 "pyproject_toml": "[PYPROJECT_TOML]"
2023 }
2024 },
2025 "required_members": {},
2026 "sources": {},
2027 "indexes": [],
2028 "pyproject_toml": {
2029 "project": {
2030 "name": "bird-feeder",
2031 "version": "1.0.0",
2032 "requires-python": ">=3.12",
2033 "dependencies": [
2034 "iniconfig>=2,<3"
2035 ],
2036 "optional-dependencies": null
2037 },
2038 "tool": null,
2039 "dependency-groups": null
2040 }
2041 }
2042 }
2043 "#);
2044 });
2045 }
2046
2047 #[tokio::test]
2048 async fn albatross_root_workspace() {
2049 let (project, root_escaped) = workspace_test("albatross-root-workspace").await;
2050 let filters = vec![(root_escaped.as_str(), "[ROOT]")];
2051 insta::with_settings!({filters => filters}, {
2052 assert_json_snapshot!(
2053 project,
2054 {
2055 ".workspace.packages.*.pyproject_toml" => "[PYPROJECT_TOML]"
2056 },
2057 @r#"
2058 {
2059 "project_root": "[ROOT]/albatross-root-workspace",
2060 "project_name": "albatross",
2061 "workspace": {
2062 "install_path": "[ROOT]/albatross-root-workspace",
2063 "packages": {
2064 "albatross": {
2065 "root": "[ROOT]/albatross-root-workspace",
2066 "project": {
2067 "name": "albatross",
2068 "version": "0.1.0",
2069 "requires-python": ">=3.12",
2070 "dependencies": [
2071 "bird-feeder",
2072 "iniconfig>=2,<3"
2073 ],
2074 "optional-dependencies": null
2075 },
2076 "pyproject_toml": "[PYPROJECT_TOML]"
2077 },
2078 "bird-feeder": {
2079 "root": "[ROOT]/albatross-root-workspace/packages/bird-feeder",
2080 "project": {
2081 "name": "bird-feeder",
2082 "version": "1.0.0",
2083 "requires-python": ">=3.8",
2084 "dependencies": [
2085 "iniconfig>=2,<3",
2086 "seeds"
2087 ],
2088 "optional-dependencies": null
2089 },
2090 "pyproject_toml": "[PYPROJECT_TOML]"
2091 },
2092 "seeds": {
2093 "root": "[ROOT]/albatross-root-workspace/packages/seeds",
2094 "project": {
2095 "name": "seeds",
2096 "version": "1.0.0",
2097 "requires-python": ">=3.12",
2098 "dependencies": [
2099 "idna==3.6"
2100 ],
2101 "optional-dependencies": null
2102 },
2103 "pyproject_toml": "[PYPROJECT_TOML]"
2104 }
2105 },
2106 "required_members": {
2107 "bird-feeder": null,
2108 "seeds": null
2109 },
2110 "sources": {
2111 "bird-feeder": [
2112 {
2113 "workspace": true,
2114 "editable": null,
2115 "extra": null,
2116 "group": null
2117 }
2118 ]
2119 },
2120 "indexes": [],
2121 "pyproject_toml": {
2122 "project": {
2123 "name": "albatross",
2124 "version": "0.1.0",
2125 "requires-python": ">=3.12",
2126 "dependencies": [
2127 "bird-feeder",
2128 "iniconfig>=2,<3"
2129 ],
2130 "optional-dependencies": null
2131 },
2132 "tool": {
2133 "uv": {
2134 "sources": {
2135 "bird-feeder": [
2136 {
2137 "workspace": true,
2138 "editable": null,
2139 "extra": null,
2140 "group": null
2141 }
2142 ]
2143 },
2144 "index": null,
2145 "workspace": {
2146 "members": [
2147 "packages/*"
2148 ],
2149 "exclude": null
2150 },
2151 "managed": null,
2152 "package": null,
2153 "default-groups": null,
2154 "dependency-groups": null,
2155 "dev-dependencies": null,
2156 "override-dependencies": null,
2157 "exclude-dependencies": null,
2158 "constraint-dependencies": null,
2159 "build-constraint-dependencies": null,
2160 "environments": null,
2161 "required-environments": null,
2162 "conflicts": null,
2163 "build-backend": null
2164 }
2165 },
2166 "dependency-groups": null
2167 }
2168 }
2169 }
2170 "#);
2171 });
2172 }
2173
2174 #[tokio::test]
2175 async fn albatross_virtual_workspace() {
2176 let (project, root_escaped) =
2177 workspace_test("albatross-virtual-workspace/packages/albatross").await;
2178 let filters = vec![(root_escaped.as_str(), "[ROOT]")];
2179 insta::with_settings!({filters => filters}, {
2180 assert_json_snapshot!(
2181 project,
2182 {
2183 ".workspace.packages.*.pyproject_toml" => "[PYPROJECT_TOML]"
2184 },
2185 @r#"
2186 {
2187 "project_root": "[ROOT]/albatross-virtual-workspace/packages/albatross",
2188 "project_name": "albatross",
2189 "workspace": {
2190 "install_path": "[ROOT]/albatross-virtual-workspace",
2191 "packages": {
2192 "albatross": {
2193 "root": "[ROOT]/albatross-virtual-workspace/packages/albatross",
2194 "project": {
2195 "name": "albatross",
2196 "version": "0.1.0",
2197 "requires-python": ">=3.12",
2198 "dependencies": [
2199 "bird-feeder",
2200 "iniconfig>=2,<3"
2201 ],
2202 "optional-dependencies": null
2203 },
2204 "pyproject_toml": "[PYPROJECT_TOML]"
2205 },
2206 "bird-feeder": {
2207 "root": "[ROOT]/albatross-virtual-workspace/packages/bird-feeder",
2208 "project": {
2209 "name": "bird-feeder",
2210 "version": "1.0.0",
2211 "requires-python": ">=3.12",
2212 "dependencies": [
2213 "anyio>=4.3.0,<5",
2214 "seeds"
2215 ],
2216 "optional-dependencies": null
2217 },
2218 "pyproject_toml": "[PYPROJECT_TOML]"
2219 },
2220 "seeds": {
2221 "root": "[ROOT]/albatross-virtual-workspace/packages/seeds",
2222 "project": {
2223 "name": "seeds",
2224 "version": "1.0.0",
2225 "requires-python": ">=3.12",
2226 "dependencies": [
2227 "idna==3.6"
2228 ],
2229 "optional-dependencies": null
2230 },
2231 "pyproject_toml": "[PYPROJECT_TOML]"
2232 }
2233 },
2234 "required_members": {
2235 "bird-feeder": null,
2236 "seeds": null
2237 },
2238 "sources": {},
2239 "indexes": [],
2240 "pyproject_toml": {
2241 "project": null,
2242 "tool": {
2243 "uv": {
2244 "sources": null,
2245 "index": null,
2246 "workspace": {
2247 "members": [
2248 "packages/*"
2249 ],
2250 "exclude": null
2251 },
2252 "managed": null,
2253 "package": null,
2254 "default-groups": null,
2255 "dependency-groups": null,
2256 "dev-dependencies": null,
2257 "override-dependencies": null,
2258 "exclude-dependencies": null,
2259 "constraint-dependencies": null,
2260 "build-constraint-dependencies": null,
2261 "environments": null,
2262 "required-environments": null,
2263 "conflicts": null,
2264 "build-backend": null
2265 }
2266 },
2267 "dependency-groups": null
2268 }
2269 }
2270 }
2271 "#);
2272 });
2273 }
2274
2275 #[tokio::test]
2276 async fn albatross_just_project() {
2277 let (project, root_escaped) = workspace_test("albatross-just-project").await;
2278 let filters = vec![(root_escaped.as_str(), "[ROOT]")];
2279 insta::with_settings!({filters => filters}, {
2280 assert_json_snapshot!(
2281 project,
2282 {
2283 ".workspace.packages.*.pyproject_toml" => "[PYPROJECT_TOML]"
2284 },
2285 @r#"
2286 {
2287 "project_root": "[ROOT]/albatross-just-project",
2288 "project_name": "albatross",
2289 "workspace": {
2290 "install_path": "[ROOT]/albatross-just-project",
2291 "packages": {
2292 "albatross": {
2293 "root": "[ROOT]/albatross-just-project",
2294 "project": {
2295 "name": "albatross",
2296 "version": "0.1.0",
2297 "requires-python": ">=3.12",
2298 "dependencies": [
2299 "iniconfig>=2,<3"
2300 ],
2301 "optional-dependencies": null
2302 },
2303 "pyproject_toml": "[PYPROJECT_TOML]"
2304 }
2305 },
2306 "required_members": {},
2307 "sources": {},
2308 "indexes": [],
2309 "pyproject_toml": {
2310 "project": {
2311 "name": "albatross",
2312 "version": "0.1.0",
2313 "requires-python": ">=3.12",
2314 "dependencies": [
2315 "iniconfig>=2,<3"
2316 ],
2317 "optional-dependencies": null
2318 },
2319 "tool": null,
2320 "dependency-groups": null
2321 }
2322 }
2323 }
2324 "#);
2325 });
2326 }
2327
2328 #[tokio::test]
2329 async fn exclude_package() -> Result<()> {
2330 let root = tempfile::TempDir::new()?;
2331 let root = ChildPath::new(root.path());
2332
2333 root.child("pyproject.toml").write_str(
2335 r#"
2336 [project]
2337 name = "albatross"
2338 version = "0.1.0"
2339 requires-python = ">=3.12"
2340 dependencies = ["tqdm>=4,<5"]
2341
2342 [tool.uv.workspace]
2343 members = ["packages/*"]
2344 exclude = ["packages/bird-feeder"]
2345
2346 [build-system]
2347 requires = ["hatchling"]
2348 build-backend = "hatchling.build"
2349 "#,
2350 )?;
2351 root.child("albatross").child("__init__.py").touch()?;
2352
2353 root.child("packages")
2355 .child("seeds")
2356 .child("pyproject.toml")
2357 .write_str(
2358 r#"
2359 [project]
2360 name = "seeds"
2361 version = "1.0.0"
2362 requires-python = ">=3.12"
2363 dependencies = ["idna==3.6"]
2364
2365 [build-system]
2366 requires = ["hatchling"]
2367 build-backend = "hatchling.build"
2368 "#,
2369 )?;
2370 root.child("packages")
2371 .child("seeds")
2372 .child("seeds")
2373 .child("__init__.py")
2374 .touch()?;
2375
2376 root.child("packages")
2378 .child("bird-feeder")
2379 .child("pyproject.toml")
2380 .write_str(
2381 r#"
2382 [project]
2383 name = "bird-feeder"
2384 version = "1.0.0"
2385 requires-python = ">=3.12"
2386 dependencies = ["anyio>=4.3.0,<5"]
2387
2388 [build-system]
2389 requires = ["hatchling"]
2390 build-backend = "hatchling.build"
2391 "#,
2392 )?;
2393 root.child("packages")
2394 .child("bird-feeder")
2395 .child("bird_feeder")
2396 .child("__init__.py")
2397 .touch()?;
2398
2399 let (project, root_escaped) = temporary_test(root.as_ref()).await.unwrap();
2400 let filters = vec![(root_escaped.as_str(), "[ROOT]")];
2401 insta::with_settings!({filters => filters}, {
2402 assert_json_snapshot!(
2403 project,
2404 {
2405 ".workspace.packages.*.pyproject_toml" => "[PYPROJECT_TOML]"
2406 },
2407 @r#"
2408 {
2409 "project_root": "[ROOT]",
2410 "project_name": "albatross",
2411 "workspace": {
2412 "install_path": "[ROOT]",
2413 "packages": {
2414 "albatross": {
2415 "root": "[ROOT]",
2416 "project": {
2417 "name": "albatross",
2418 "version": "0.1.0",
2419 "requires-python": ">=3.12",
2420 "dependencies": [
2421 "tqdm>=4,<5"
2422 ],
2423 "optional-dependencies": null
2424 },
2425 "pyproject_toml": "[PYPROJECT_TOML]"
2426 },
2427 "seeds": {
2428 "root": "[ROOT]/packages/seeds",
2429 "project": {
2430 "name": "seeds",
2431 "version": "1.0.0",
2432 "requires-python": ">=3.12",
2433 "dependencies": [
2434 "idna==3.6"
2435 ],
2436 "optional-dependencies": null
2437 },
2438 "pyproject_toml": "[PYPROJECT_TOML]"
2439 }
2440 },
2441 "required_members": {},
2442 "sources": {},
2443 "indexes": [],
2444 "pyproject_toml": {
2445 "project": {
2446 "name": "albatross",
2447 "version": "0.1.0",
2448 "requires-python": ">=3.12",
2449 "dependencies": [
2450 "tqdm>=4,<5"
2451 ],
2452 "optional-dependencies": null
2453 },
2454 "tool": {
2455 "uv": {
2456 "sources": null,
2457 "index": null,
2458 "workspace": {
2459 "members": [
2460 "packages/*"
2461 ],
2462 "exclude": [
2463 "packages/bird-feeder"
2464 ]
2465 },
2466 "managed": null,
2467 "package": null,
2468 "default-groups": null,
2469 "dependency-groups": null,
2470 "dev-dependencies": null,
2471 "override-dependencies": null,
2472 "exclude-dependencies": null,
2473 "constraint-dependencies": null,
2474 "build-constraint-dependencies": null,
2475 "environments": null,
2476 "required-environments": null,
2477 "conflicts": null,
2478 "build-backend": null
2479 }
2480 },
2481 "dependency-groups": null
2482 }
2483 }
2484 }
2485 "#);
2486 });
2487
2488 root.child("pyproject.toml").write_str(
2490 r#"
2491 [project]
2492 name = "albatross"
2493 version = "0.1.0"
2494 requires-python = ">=3.12"
2495 dependencies = ["tqdm>=4,<5"]
2496
2497 [tool.uv.workspace]
2498 members = ["packages/seeds", "packages/bird-feeder"]
2499 exclude = ["packages/bird-feeder"]
2500
2501 [build-system]
2502 requires = ["hatchling"]
2503 build-backend = "hatchling.build"
2504 "#,
2505 )?;
2506
2507 let (project, root_escaped) = temporary_test(root.as_ref()).await.unwrap();
2509 let filters = vec![(root_escaped.as_str(), "[ROOT]")];
2510 insta::with_settings!({filters => filters}, {
2511 assert_json_snapshot!(
2512 project,
2513 {
2514 ".workspace.packages.*.pyproject_toml" => "[PYPROJECT_TOML]"
2515 },
2516 @r#"
2517 {
2518 "project_root": "[ROOT]",
2519 "project_name": "albatross",
2520 "workspace": {
2521 "install_path": "[ROOT]",
2522 "packages": {
2523 "albatross": {
2524 "root": "[ROOT]",
2525 "project": {
2526 "name": "albatross",
2527 "version": "0.1.0",
2528 "requires-python": ">=3.12",
2529 "dependencies": [
2530 "tqdm>=4,<5"
2531 ],
2532 "optional-dependencies": null
2533 },
2534 "pyproject_toml": "[PYPROJECT_TOML]"
2535 },
2536 "seeds": {
2537 "root": "[ROOT]/packages/seeds",
2538 "project": {
2539 "name": "seeds",
2540 "version": "1.0.0",
2541 "requires-python": ">=3.12",
2542 "dependencies": [
2543 "idna==3.6"
2544 ],
2545 "optional-dependencies": null
2546 },
2547 "pyproject_toml": "[PYPROJECT_TOML]"
2548 }
2549 },
2550 "required_members": {},
2551 "sources": {},
2552 "indexes": [],
2553 "pyproject_toml": {
2554 "project": {
2555 "name": "albatross",
2556 "version": "0.1.0",
2557 "requires-python": ">=3.12",
2558 "dependencies": [
2559 "tqdm>=4,<5"
2560 ],
2561 "optional-dependencies": null
2562 },
2563 "tool": {
2564 "uv": {
2565 "sources": null,
2566 "index": null,
2567 "workspace": {
2568 "members": [
2569 "packages/seeds",
2570 "packages/bird-feeder"
2571 ],
2572 "exclude": [
2573 "packages/bird-feeder"
2574 ]
2575 },
2576 "managed": null,
2577 "package": null,
2578 "default-groups": null,
2579 "dependency-groups": null,
2580 "dev-dependencies": null,
2581 "override-dependencies": null,
2582 "exclude-dependencies": null,
2583 "constraint-dependencies": null,
2584 "build-constraint-dependencies": null,
2585 "environments": null,
2586 "required-environments": null,
2587 "conflicts": null,
2588 "build-backend": null
2589 }
2590 },
2591 "dependency-groups": null
2592 }
2593 }
2594 }
2595 "#);
2596 });
2597
2598 root.child("pyproject.toml").write_str(
2600 r#"
2601 [project]
2602 name = "albatross"
2603 version = "0.1.0"
2604 requires-python = ">=3.12"
2605 dependencies = ["tqdm>=4,<5"]
2606
2607 [tool.uv.workspace]
2608 members = ["packages/seeds", "packages/bird-feeder"]
2609 exclude = ["packages"]
2610
2611 [build-system]
2612 requires = ["hatchling"]
2613 build-backend = "hatchling.build"
2614 "#,
2615 )?;
2616
2617 let (project, root_escaped) = temporary_test(root.as_ref()).await.unwrap();
2619 let filters = vec![(root_escaped.as_str(), "[ROOT]")];
2620 insta::with_settings!({filters => filters}, {
2621 assert_json_snapshot!(
2622 project,
2623 {
2624 ".workspace.packages.*.pyproject_toml" => "[PYPROJECT_TOML]"
2625 },
2626 @r#"
2627 {
2628 "project_root": "[ROOT]",
2629 "project_name": "albatross",
2630 "workspace": {
2631 "install_path": "[ROOT]",
2632 "packages": {
2633 "albatross": {
2634 "root": "[ROOT]",
2635 "project": {
2636 "name": "albatross",
2637 "version": "0.1.0",
2638 "requires-python": ">=3.12",
2639 "dependencies": [
2640 "tqdm>=4,<5"
2641 ],
2642 "optional-dependencies": null
2643 },
2644 "pyproject_toml": "[PYPROJECT_TOML]"
2645 },
2646 "bird-feeder": {
2647 "root": "[ROOT]/packages/bird-feeder",
2648 "project": {
2649 "name": "bird-feeder",
2650 "version": "1.0.0",
2651 "requires-python": ">=3.12",
2652 "dependencies": [
2653 "anyio>=4.3.0,<5"
2654 ],
2655 "optional-dependencies": null
2656 },
2657 "pyproject_toml": "[PYPROJECT_TOML]"
2658 },
2659 "seeds": {
2660 "root": "[ROOT]/packages/seeds",
2661 "project": {
2662 "name": "seeds",
2663 "version": "1.0.0",
2664 "requires-python": ">=3.12",
2665 "dependencies": [
2666 "idna==3.6"
2667 ],
2668 "optional-dependencies": null
2669 },
2670 "pyproject_toml": "[PYPROJECT_TOML]"
2671 }
2672 },
2673 "required_members": {},
2674 "sources": {},
2675 "indexes": [],
2676 "pyproject_toml": {
2677 "project": {
2678 "name": "albatross",
2679 "version": "0.1.0",
2680 "requires-python": ">=3.12",
2681 "dependencies": [
2682 "tqdm>=4,<5"
2683 ],
2684 "optional-dependencies": null
2685 },
2686 "tool": {
2687 "uv": {
2688 "sources": null,
2689 "index": null,
2690 "workspace": {
2691 "members": [
2692 "packages/seeds",
2693 "packages/bird-feeder"
2694 ],
2695 "exclude": [
2696 "packages"
2697 ]
2698 },
2699 "managed": null,
2700 "package": null,
2701 "default-groups": null,
2702 "dependency-groups": null,
2703 "dev-dependencies": null,
2704 "override-dependencies": null,
2705 "exclude-dependencies": null,
2706 "constraint-dependencies": null,
2707 "build-constraint-dependencies": null,
2708 "environments": null,
2709 "required-environments": null,
2710 "conflicts": null,
2711 "build-backend": null
2712 }
2713 },
2714 "dependency-groups": null
2715 }
2716 }
2717 }
2718 "#);
2719 });
2720
2721 root.child("pyproject.toml").write_str(
2723 r#"
2724 [project]
2725 name = "albatross"
2726 version = "0.1.0"
2727 requires-python = ">=3.12"
2728 dependencies = ["tqdm>=4,<5"]
2729
2730 [tool.uv.workspace]
2731 members = ["packages/seeds", "packages/bird-feeder"]
2732 exclude = ["packages/*"]
2733
2734 [build-system]
2735 requires = ["hatchling"]
2736 build-backend = "hatchling.build"
2737 "#,
2738 )?;
2739
2740 let (project, root_escaped) = temporary_test(root.as_ref()).await.unwrap();
2742 let filters = vec![(root_escaped.as_str(), "[ROOT]")];
2743 insta::with_settings!({filters => filters}, {
2744 assert_json_snapshot!(
2745 project,
2746 {
2747 ".workspace.packages.*.pyproject_toml" => "[PYPROJECT_TOML]"
2748 },
2749 @r#"
2750 {
2751 "project_root": "[ROOT]",
2752 "project_name": "albatross",
2753 "workspace": {
2754 "install_path": "[ROOT]",
2755 "packages": {
2756 "albatross": {
2757 "root": "[ROOT]",
2758 "project": {
2759 "name": "albatross",
2760 "version": "0.1.0",
2761 "requires-python": ">=3.12",
2762 "dependencies": [
2763 "tqdm>=4,<5"
2764 ],
2765 "optional-dependencies": null
2766 },
2767 "pyproject_toml": "[PYPROJECT_TOML]"
2768 }
2769 },
2770 "required_members": {},
2771 "sources": {},
2772 "indexes": [],
2773 "pyproject_toml": {
2774 "project": {
2775 "name": "albatross",
2776 "version": "0.1.0",
2777 "requires-python": ">=3.12",
2778 "dependencies": [
2779 "tqdm>=4,<5"
2780 ],
2781 "optional-dependencies": null
2782 },
2783 "tool": {
2784 "uv": {
2785 "sources": null,
2786 "index": null,
2787 "workspace": {
2788 "members": [
2789 "packages/seeds",
2790 "packages/bird-feeder"
2791 ],
2792 "exclude": [
2793 "packages/*"
2794 ]
2795 },
2796 "managed": null,
2797 "package": null,
2798 "default-groups": null,
2799 "dependency-groups": null,
2800 "dev-dependencies": null,
2801 "override-dependencies": null,
2802 "exclude-dependencies": null,
2803 "constraint-dependencies": null,
2804 "build-constraint-dependencies": null,
2805 "environments": null,
2806 "required-environments": null,
2807 "conflicts": null,
2808 "build-backend": null
2809 }
2810 },
2811 "dependency-groups": null
2812 }
2813 }
2814 }
2815 "#);
2816 });
2817
2818 Ok(())
2819 }
2820
2821 #[test]
2822 fn read_dependency_groups() {
2823 let toml = r#"
2824[dependency-groups]
2825foo = ["a", {include-group = "bar"}]
2826bar = ["b"]
2827"#;
2828
2829 let result = PyProjectToml::from_string(toml.to_string(), "pyproject.toml")
2830 .expect("Deserialization should succeed");
2831
2832 let groups = result
2833 .dependency_groups
2834 .expect("`dependency-groups` should be present");
2835 let foo = groups
2836 .get(&GroupName::from_str("foo").unwrap())
2837 .expect("Group `foo` should be present");
2838 assert_eq!(
2839 foo,
2840 &[
2841 DependencyGroupSpecifier::Requirement("a".to_string()),
2842 DependencyGroupSpecifier::IncludeGroup {
2843 include_group: GroupName::from_str("bar").unwrap(),
2844 }
2845 ]
2846 );
2847
2848 let bar = groups
2849 .get(&GroupName::from_str("bar").unwrap())
2850 .expect("Group `bar` should be present");
2851 assert_eq!(
2852 bar,
2853 &[DependencyGroupSpecifier::Requirement("b".to_string())]
2854 );
2855 }
2856
2857 #[tokio::test]
2858 async fn nested_workspace() -> Result<()> {
2859 let root = tempfile::TempDir::new()?;
2860 let root = ChildPath::new(root.path());
2861
2862 root.child("pyproject.toml").write_str(
2864 r#"
2865 [project]
2866 name = "albatross"
2867 version = "0.1.0"
2868 requires-python = ">=3.12"
2869 dependencies = ["tqdm>=4,<5"]
2870
2871 [tool.uv.workspace]
2872 members = ["packages/*"]
2873 "#,
2874 )?;
2875
2876 root.child("packages")
2878 .child("seeds")
2879 .child("pyproject.toml")
2880 .write_str(
2881 r#"
2882 [project]
2883 name = "seeds"
2884 version = "1.0.0"
2885 requires-python = ">=3.12"
2886 dependencies = ["idna==3.6"]
2887
2888 [tool.uv.workspace]
2889 members = ["nested_packages/*"]
2890 "#,
2891 )?;
2892
2893 let (error, root_escaped) = temporary_test(root.as_ref()).await.unwrap_err();
2894 let filters = vec![(root_escaped.as_str(), "[ROOT]")];
2895 insta::with_settings!({filters => filters}, {
2896 assert_snapshot!(
2897 error,
2898 @"Nested workspaces are not supported, but workspace member has a `tool.uv.workspace` table: [ROOT]/packages/seeds");
2899 });
2900
2901 Ok(())
2902 }
2903
2904 #[tokio::test]
2905 async fn duplicate_names() -> Result<()> {
2906 let root = tempfile::TempDir::new()?;
2907 let root = ChildPath::new(root.path());
2908
2909 root.child("pyproject.toml").write_str(
2911 r#"
2912 [project]
2913 name = "albatross"
2914 version = "0.1.0"
2915 requires-python = ">=3.12"
2916 dependencies = ["tqdm>=4,<5"]
2917
2918 [tool.uv.workspace]
2919 members = ["packages/*"]
2920 "#,
2921 )?;
2922
2923 root.child("packages")
2925 .child("seeds")
2926 .child("pyproject.toml")
2927 .write_str(
2928 r#"
2929 [project]
2930 name = "seeds"
2931 version = "1.0.0"
2932 requires-python = ">=3.12"
2933 dependencies = ["idna==3.6"]
2934
2935 [tool.uv.workspace]
2936 members = ["nested_packages/*"]
2937 "#,
2938 )?;
2939
2940 root.child("packages")
2942 .child("seeds2")
2943 .child("pyproject.toml")
2944 .write_str(
2945 r#"
2946 [project]
2947 name = "seeds"
2948 version = "1.0.0"
2949 requires-python = ">=3.12"
2950 dependencies = ["idna==3.6"]
2951
2952 [tool.uv.workspace]
2953 members = ["nested_packages/*"]
2954 "#,
2955 )?;
2956
2957 let (error, root_escaped) = temporary_test(root.as_ref()).await.unwrap_err();
2958 let filters = vec![(root_escaped.as_str(), "[ROOT]")];
2959 insta::with_settings!({filters => filters}, {
2960 assert_snapshot!(
2961 error,
2962 @"Two workspace members are both named `seeds`: `[ROOT]/packages/seeds` and `[ROOT]/packages/seeds2`");
2963 });
2964
2965 Ok(())
2966 }
2967}