1use std::collections::{BTreeMap, BTreeSet};
4use std::error::Error;
5use std::fmt;
6use std::fmt::Display;
7use std::hash::BuildHasherDefault;
8use std::path::{Path, PathBuf};
9use std::sync::Arc;
10
11use glob::{GlobError, PatternError, glob};
12use itertools::Itertools;
13use rustc_hash::{FxHashSet, FxHasher};
14use tracing::{debug, trace, warn};
15
16use uv_cache::Cache;
17use uv_configuration::{DependencyGroupsWithDefaults, ExcludeDependency};
18use uv_distribution_types::{Index, Requirement, RequirementSource};
19use uv_fs::{CWD, Simplified, normalize_path};
20use uv_normalize::{DEV_DEPENDENCIES, GroupName, PackageName};
21use uv_once_map::OnceMap;
22use uv_pep440::VersionSpecifiers;
23use uv_pep508::{MarkerTree, VerbatimUrl};
24use uv_pypi_types::{ConflictError, Conflicts, SupportedEnvironments, VerbatimParsedUrl};
25use uv_static::EnvVars;
26use uv_warnings::warn_user_once;
27
28use crate::dependency_groups::{DependencyGroupError, FlatDependencyGroup, FlatDependencyGroups};
29use crate::pyproject::{
30 OverrideDependency, Project, PyProjectToml, PyprojectTomlError, Source, Sources, ToolUvSources,
31 ToolUvWorkspace, WorkspaceReference,
32};
33
34#[derive(Debug)]
36pub enum ProjectEnvironmentSelection {
37 Default,
39 Override(PathBuf),
41 Active(PathBuf),
43}
44
45impl ProjectEnvironmentSelection {
46 pub fn is_default(&self) -> bool {
48 matches!(self, Self::Default)
49 }
50
51 pub fn explicit_path(&self) -> Option<&Path> {
53 match self {
54 Self::Default => None,
55 Self::Override(path) | Self::Active(path) => Some(path),
56 }
57 }
58}
59
60type WorkspaceMembers = Arc<BTreeMap<PackageName, WorkspaceMember>>;
61type FxOnceMap<K, V> = OnceMap<K, V, BuildHasherDefault<FxHasher>>;
62type CachedWorkspaceResult = Result<Arc<Workspace>, WorkspaceError>;
63
64#[derive(Debug, Default, Clone)]
78pub struct WorkspaceCache {
79 workspaces: Arc<FxOnceMap<PathBuf, CachedWorkspaceResult>>,
80}
81
82impl WorkspaceCache {
83 fn insert(&self, result: CachedWorkspaceResult, install_path: &Path) {
88 match result {
89 Ok(workspace) => {
90 for package in workspace.packages.values() {
91 if has_intermediate_pyproject(&workspace.install_path, &package.root) {
96 continue;
97 }
98 self.workspaces
99 .done(package.root.clone(), Ok(workspace.clone()));
100 }
101 self.workspaces
102 .done(workspace.install_path.clone(), Ok(workspace));
103 }
104 Err(err) => {
105 self.workspaces.done(install_path.to_path_buf(), Err(err));
106 }
107 }
108 }
109
110 async fn register_or_wait(&self, workspace_root: &PathBuf) -> Option<CachedWorkspaceResult> {
115 self.workspaces.register_or_wait(workspace_root).await
116 }
117
118 fn get(
123 &self,
124 path: &Path,
125 member_discovery: &MemberDiscovery,
126 ) -> Option<CachedWorkspaceResult> {
127 match member_discovery {
128 MemberDiscovery::All => self.workspaces.get(path),
129 MemberDiscovery::Existing => match self.workspaces.get(path) {
130 Some(Ok(workspace)) => Some(Ok(workspace)),
131 Some(Err(_)) | None => None,
132 },
133 MemberDiscovery::None | MemberDiscovery::Ignore(_) => None,
134 }
135 }
136
137 fn invalidate_workspace(&self, workspace: &Workspace) {
143 if let Some(Ok(workspace)) = self.workspaces.remove(workspace.install_path()) {
144 for member in workspace.packages.values() {
145 self.workspaces.remove(&member.root);
146 }
147 }
148 }
149}
150
151fn has_intermediate_pyproject(workspace_root: &Path, project_dir: &Path) -> bool {
154 if project_dir == workspace_root {
155 return false;
156 }
157
158 let Ok(_) = project_dir.strip_prefix(workspace_root) else {
159 return false;
160 };
161
162 project_dir
163 .ancestors()
164 .skip(1)
165 .take_while(|ancestor| *ancestor != workspace_root)
166 .any(|ancestor| ancestor.join("pyproject.toml").is_file())
167}
168
169#[derive(Debug, Clone)]
170pub struct WorkspaceError(Arc<WorkspaceErrorKind>);
171
172impl AsRef<WorkspaceErrorKind> for WorkspaceError {
173 fn as_ref(&self) -> &WorkspaceErrorKind {
174 &self.0
175 }
176}
177
178impl<T> From<T> for WorkspaceError
179where
180 T: Into<WorkspaceErrorKind>,
181{
182 fn from(error: T) -> Self {
183 Self(Arc::new(error.into()))
184 }
185}
186
187impl Display for WorkspaceError {
188 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
189 Display::fmt(&self.0, f)
190 }
191}
192
193impl Error for WorkspaceError {
194 fn source(&self) -> Option<&(dyn Error + 'static)> {
195 self.0.source()
196 }
197}
198
199#[derive(thiserror::Error, Debug)]
200pub enum WorkspaceErrorKind {
201 #[error("No `pyproject.toml` found in current directory or any parent directory")]
203 MissingPyprojectToml,
204 #[error("Workspace member `{}` is missing a `pyproject.toml` (matches: `{}`)", _0.simplified_display(), _1)]
205 MissingPyprojectTomlMember(PathBuf, String),
206 #[error("No `project` table found in: {}", _0.simplified_display())]
207 MissingProject(PathBuf),
208 #[error("No workspace found for: {}", _0.simplified_display())]
209 MissingWorkspace(PathBuf),
210 #[error("The project is marked as unmanaged: {}", _0.simplified_display())]
211 NonWorkspace(PathBuf),
212 #[error("Nested workspaces are not supported, but workspace member has a `tool.uv.workspace` table: {}", _0.simplified_display())]
213 NestedWorkspace(PathBuf),
214 #[error("The workspace does not have a member {}: {}", _0, _1.simplified_display())]
215 NoSuchMember(PackageName, PathBuf),
216 #[error("Two workspace members are both named `{name}`: `{}` and `{}`", first.simplified_display(), second.simplified_display())]
217 DuplicatePackage {
218 name: PackageName,
219 first: PathBuf,
220 second: PathBuf,
221 },
222 #[error("pyproject.toml section is declared as dynamic, but must be static: `{0}`")]
223 DynamicNotAllowed(&'static str),
224 #[error(
225 "Workspace member `{}` was requested as both `editable = true` and `editable = false`",
226 _0
227 )]
228 EditableConflict(PackageName),
229 #[error("Failed to find directories for glob: `{0}`")]
230 Pattern(String, #[source] PatternError),
231 #[error("Directory walking failed for `tool.uv.workspace.members` glob: `{0}`")]
233 GlobWalk(String, #[source] GlobError),
234 #[error(transparent)]
235 Io(#[from] std::io::Error),
236 #[error("Failed to parse: `{}`", _0.user_display())]
237 Toml(PathBuf, #[source] Box<PyprojectTomlError>),
238 #[error(transparent)]
239 Conflicts(#[from] ConflictError),
240 #[error("Failed to normalize workspace member path")]
243 Normalize(#[source] std::io::Error),
244}
245
246#[derive(Debug, Default, Clone, Hash, PartialEq, Eq)]
247pub enum MemberDiscovery {
248 #[default]
250 All,
251 Existing,
253 None,
255 Ignore(BTreeSet<PathBuf>),
257}
258
259#[derive(Debug, Default, Clone, Hash, PartialEq, Eq)]
260pub struct DiscoveryOptions {
261 pub stop_discovery_at: Option<PathBuf>,
267 pub members: MemberDiscovery,
269}
270
271pub type RequiresPythonSources = BTreeMap<(PackageName, Option<GroupName>), VersionSpecifiers>;
272
273pub type Editability = Option<bool>;
274
275#[derive(Debug, Clone)]
277#[cfg_attr(test, derive(serde::Serialize))]
278pub struct Workspace {
279 install_path: PathBuf,
284 packages: WorkspaceMembers,
286 required_members: BTreeMap<PackageName, Editability>,
289 sources: BTreeMap<PackageName, Sources>,
293 indexes: Vec<Index>,
297 pyproject_toml: PyProjectToml,
299}
300
301impl Workspace {
302 pub async fn discover(
324 path: &Path,
325 options: &DiscoveryOptions,
326 cache: &Cache,
327 workspace_cache: &WorkspaceCache,
328 ) -> Result<Arc<Self>, WorkspaceError> {
329 let path = std::path::absolute(path)
330 .map_err(WorkspaceErrorKind::Normalize)?
331 .clone();
332 let path = normalize_path(&path);
333
334 let project_path = path
335 .ancestors()
336 .find(|path| path.join("pyproject.toml").is_file())
337 .ok_or(WorkspaceErrorKind::MissingPyprojectToml)?
338 .to_path_buf();
339
340 if let Some(workspace) = workspace_cache.get(&project_path, &options.members) {
346 return workspace;
347 }
348
349 let pyproject_path = project_path.join("pyproject.toml");
350 let contents = fs_err::tokio::read_to_string(&pyproject_path).await?;
351 let pyproject_toml = PyProjectToml::from_string(contents, &pyproject_path)
352 .map_err(|err| WorkspaceErrorKind::Toml(pyproject_path.clone(), Box::new(err)))?;
353
354 if pyproject_toml
356 .tool
357 .as_ref()
358 .and_then(|tool| tool.uv.as_ref())
359 .and_then(|uv| uv.managed)
360 == Some(false)
361 {
362 debug!(
363 "Project `{}` is marked as unmanaged",
364 project_path.simplified_display()
365 );
366 return Err(WorkspaceError::from(WorkspaceErrorKind::NonWorkspace(
367 project_path,
368 )));
369 }
370
371 let explicit_root = pyproject_toml
373 .tool
374 .as_ref()
375 .and_then(|tool| tool.uv.as_ref())
376 .and_then(|uv| uv.workspace.as_ref())
377 .map(|workspace| {
378 (
379 project_path.clone(),
380 workspace.clone(),
381 pyproject_toml.clone(),
382 )
383 });
384
385 let (workspace_root, workspace_definition, workspace_pyproject_toml) =
386 if let Some(workspace) = explicit_root {
387 workspace
389 } else if pyproject_toml.project.is_none() {
390 return Err(WorkspaceError::from(WorkspaceErrorKind::MissingProject(
392 pyproject_path,
393 )));
394 } else if let Some(workspace) = find_workspace(&project_path, options, cache).await? {
395 workspace
397 } else {
398 (
400 project_path.clone(),
401 ToolUvWorkspace::default(),
402 pyproject_toml.clone(),
403 )
404 };
405
406 if options.members == MemberDiscovery::All {
407 if let Some(workspace) = workspace_cache.register_or_wait(&workspace_root).await {
413 return workspace;
414 }
415 }
416
417 debug!(
418 "Found workspace root: `{}`",
419 workspace_root.simplified_display()
420 );
421
422 let current_project = pyproject_toml
425 .project
426 .clone()
427 .map(|project| WorkspaceMember {
428 root: project_path,
429 project,
430 pyproject_toml,
431 });
432
433 let result = Self::build(
434 workspace_root.clone(),
435 workspace_definition,
436 workspace_pyproject_toml,
437 current_project,
438 options,
439 cache,
440 )
441 .await;
442 if options.members == MemberDiscovery::All {
443 workspace_cache.insert(result.clone(), &workspace_root);
444 }
445 result
446 }
447
448 fn with_current_project(
452 self: Arc<Self>,
453 package_name: PackageName,
454 ) -> Option<ProjectWorkspace> {
455 let member = self.packages.get(&package_name)?;
456 Some(ProjectWorkspace {
457 project_root: member.root().clone(),
458 project_name: package_name,
459 workspace: self,
460 })
461 }
462
463 fn update_member(
469 self: Arc<Self>,
470 package_name: &PackageName,
471 pyproject_toml: PyProjectToml,
472 ) -> Result<Option<Arc<Self>>, WorkspaceError> {
473 debug_assert_eq!(
474 Arc::strong_count(&self),
475 1,
476 "cannot modify workspace still in use",
477 );
478
479 let slf = Arc::unwrap_or_clone(self);
480 let mut packages = slf.packages;
481
482 let Some(member) = Arc::make_mut(&mut packages).get_mut(package_name) else {
483 return Ok(None);
484 };
485
486 if member.root == slf.install_path {
487 let workspace_pyproject_toml = pyproject_toml.clone();
490
491 let workspace_sources = workspace_pyproject_toml
493 .tool
494 .clone()
495 .and_then(|tool| tool.uv)
496 .and_then(|uv| uv.sources)
497 .map(ToolUvSources::into_inner)
498 .unwrap_or_default();
499
500 member.pyproject_toml = pyproject_toml;
502
503 let required_members = Self::collect_required_members(
505 &packages,
506 &workspace_sources,
507 &workspace_pyproject_toml,
508 )?;
509
510 let workspace = Self {
511 pyproject_toml: workspace_pyproject_toml,
512 sources: workspace_sources,
513 packages,
514 required_members,
515 ..slf
516 };
517 Ok(Some(Arc::new(workspace)))
518 } else {
519 member.pyproject_toml = pyproject_toml;
521
522 let required_members =
524 Self::collect_required_members(&packages, &slf.sources, &slf.pyproject_toml)?;
525
526 let workspace = Self {
527 packages,
528 required_members,
529 ..slf
530 };
531 Ok(Some(Arc::new(workspace)))
532 }
533 }
534
535 pub fn is_non_project(&self) -> bool {
537 !self
538 .packages
539 .values()
540 .any(|member| *member.root() == self.install_path)
541 }
542
543 pub fn members_requirements(&self) -> impl Iterator<Item = Requirement> + '_ {
545 self.packages.iter().filter_map(|(name, member)| {
546 let url = VerbatimUrl::from_absolute_path(&member.root).expect("path is valid URL");
547 Some(Requirement {
548 name: member.pyproject_toml.project.as_ref()?.name.clone(),
549 extras: Box::new([]),
550 groups: Box::new([]),
551 marker: MarkerTree::TRUE,
552 source: if member
553 .pyproject_toml()
554 .is_package(!self.is_required_member(name))
555 {
556 RequirementSource::Directory {
557 install_path: member.root.clone().into_boxed_path(),
558 editable: Some(
559 self.required_members
560 .get(name)
561 .copied()
562 .flatten()
563 .unwrap_or(true),
564 ),
565 r#virtual: Some(false),
566 url,
567 }
568 } else {
569 RequirementSource::Directory {
570 install_path: member.root.clone().into_boxed_path(),
571 editable: Some(false),
572 r#virtual: Some(true),
573 url,
574 }
575 },
576 origin: None,
577 })
578 })
579 }
580
581 pub fn required_members(&self) -> &BTreeMap<PackageName, Editability> {
583 &self.required_members
584 }
585
586 fn collect_required_members(
593 packages: &BTreeMap<PackageName, WorkspaceMember>,
594 sources: &BTreeMap<PackageName, Sources>,
595 pyproject_toml: &PyProjectToml,
596 ) -> Result<BTreeMap<PackageName, Editability>, WorkspaceError> {
597 let mut required_members = BTreeMap::new();
598
599 for (package, sources) in sources
600 .iter()
601 .filter(|(name, _)| {
602 pyproject_toml
603 .project
604 .as_ref()
605 .is_none_or(|project| project.name != **name)
606 })
607 .chain(
608 packages
609 .iter()
610 .filter_map(|(name, member)| {
611 member
612 .pyproject_toml
613 .tool
614 .as_ref()
615 .and_then(|tool| tool.uv.as_ref())
616 .and_then(|uv| uv.sources.as_ref())
617 .map(ToolUvSources::inner)
618 .map(move |sources| {
619 sources
620 .iter()
621 .filter(move |(source_name, _)| name != *source_name)
622 })
623 })
624 .flatten(),
625 )
626 {
627 for source in sources.iter() {
628 let Source::Workspace {
629 workspace: WorkspaceReference::Bool(true),
630 editable,
631 ..
632 } = &source
633 else {
634 continue;
635 };
636 let existing = required_members.insert(package.clone(), *editable);
637 if let Some(Some(existing)) = existing {
638 if let Some(editable) = editable {
639 if existing != *editable {
641 return Err(WorkspaceError::from(
642 WorkspaceErrorKind::EditableConflict(package.clone()),
643 ));
644 }
645 }
646 }
647 }
648 }
649
650 Ok(required_members)
651 }
652
653 fn is_required_member(&self, name: &PackageName) -> bool {
655 self.required_members().contains_key(name)
656 }
657
658 pub fn group_requirements(&self) -> impl Iterator<Item = Requirement> + '_ {
660 self.packages.iter().filter_map(|(name, member)| {
661 let url = VerbatimUrl::from_absolute_path(&member.root).expect("path is valid URL");
662
663 let groups = {
664 let mut groups = member
665 .pyproject_toml
666 .dependency_groups
667 .as_ref()
668 .map(|groups| groups.keys().cloned().collect::<Vec<_>>())
669 .unwrap_or_default();
670 if member
671 .pyproject_toml
672 .tool
673 .as_ref()
674 .and_then(|tool| tool.uv.as_ref())
675 .and_then(|uv| uv.dev_dependencies.as_ref())
676 .is_some()
677 {
678 groups.push(DEV_DEPENDENCIES.clone());
679 groups.sort_unstable();
680 }
681 groups
682 };
683 if groups.is_empty() {
684 return None;
685 }
686
687 let value = self.required_members.get(name);
688 let is_required_member = value.is_some();
689 let editability = value.copied().flatten();
690
691 Some(Requirement {
692 name: member.pyproject_toml.project.as_ref()?.name.clone(),
693 extras: Box::new([]),
694 groups: groups.into_boxed_slice(),
695 marker: MarkerTree::TRUE,
696 source: if member.pyproject_toml().is_package(!is_required_member) {
697 RequirementSource::Directory {
698 install_path: member.root.clone().into_boxed_path(),
699 editable: Some(editability.unwrap_or(true)),
700 r#virtual: Some(false),
701 url,
702 }
703 } else {
704 RequirementSource::Directory {
705 install_path: member.root.clone().into_boxed_path(),
706 editable: Some(false),
707 r#virtual: Some(true),
708 url,
709 }
710 },
711 origin: None,
712 })
713 })
714 }
715
716 pub fn environments(&self) -> Option<&SupportedEnvironments> {
718 self.pyproject_toml
719 .tool
720 .as_ref()
721 .and_then(|tool| tool.uv.as_ref())
722 .and_then(|uv| uv.environments.as_ref())
723 }
724
725 pub fn required_environments(&self) -> Option<&SupportedEnvironments> {
727 self.pyproject_toml
728 .tool
729 .as_ref()
730 .and_then(|tool| tool.uv.as_ref())
731 .and_then(|uv| uv.required_environments.as_ref())
732 }
733
734 pub fn conflicts(&self) -> Result<Conflicts, WorkspaceError> {
736 let mut conflicting = Conflicts::empty();
737 if self.is_non_project()
738 && let Some(root_conflicts) = self
739 .pyproject_toml
740 .tool
741 .as_ref()
742 .and_then(|tool| tool.uv.as_ref())
743 .and_then(|uv| uv.conflicts.as_ref())
744 {
745 let mut root_conflicts = root_conflicts.to_conflicts()?;
746 conflicting.append(&mut root_conflicts);
747 }
748 for member in self.packages.values() {
749 conflicting.append(&mut member.pyproject_toml.conflicts()?);
750 }
751 Ok(conflicting)
752 }
753
754 pub fn requires_python(
756 &self,
757 groups: &DependencyGroupsWithDefaults,
758 ) -> Result<RequiresPythonSources, DependencyGroupError> {
759 let mut requires = RequiresPythonSources::new();
760 for (name, member) in self.packages() {
761 let top_requires = member
767 .pyproject_toml()
768 .project
769 .as_ref()
770 .and_then(|project| project.requires_python.as_ref())
771 .map(|requires_python| ((name.to_owned(), None), requires_python.clone()));
772 requires.extend(top_requires);
773
774 let dependency_groups =
777 FlatDependencyGroups::from_pyproject_toml(member.root(), &member.pyproject_toml)?;
778 let group_requires =
779 dependency_groups
780 .into_iter()
781 .filter_map(move |(group_name, flat_group)| {
782 if groups.contains(&group_name) {
783 flat_group.requires_python.map(|requires_python| {
784 ((name.to_owned(), Some(group_name)), requires_python)
785 })
786 } else {
787 None
788 }
789 });
790 requires.extend(group_requires);
791 }
792 Ok(requires)
793 }
794
795 pub fn requirements(&self) -> Vec<uv_pep508::Requirement<VerbatimParsedUrl>> {
800 Vec::new()
801 }
802
803 pub fn workspace_dependency_groups(
811 &self,
812 ) -> Result<BTreeMap<GroupName, FlatDependencyGroup>, DependencyGroupError> {
813 if self
814 .packages
815 .values()
816 .any(|member| *member.root() == self.install_path)
817 {
818 Ok(BTreeMap::default())
821 } else {
822 let dependency_groups = FlatDependencyGroups::from_pyproject_toml(
824 &self.install_path,
825 &self.pyproject_toml,
826 )?;
827 Ok(dependency_groups.into_inner())
828 }
829 }
830
831 pub fn overrides(&self) -> Vec<OverrideDependency> {
833 let Some(overrides) = self
834 .pyproject_toml
835 .tool
836 .as_ref()
837 .and_then(|tool| tool.uv.as_ref())
838 .and_then(|uv| uv.override_dependencies.as_ref())
839 else {
840 return vec![];
841 };
842 overrides.clone()
843 }
844
845 pub fn exclude_dependencies(&self) -> Vec<ExcludeDependency> {
847 let Some(excludes) = self
848 .pyproject_toml
849 .tool
850 .as_ref()
851 .and_then(|tool| tool.uv.as_ref())
852 .and_then(|uv| uv.exclude_dependencies.as_ref())
853 else {
854 return vec![];
855 };
856 excludes.clone()
857 }
858
859 pub fn constraints(&self) -> Vec<uv_pep508::Requirement<VerbatimParsedUrl>> {
861 let Some(constraints) = self
862 .pyproject_toml
863 .tool
864 .as_ref()
865 .and_then(|tool| tool.uv.as_ref())
866 .and_then(|uv| uv.constraint_dependencies.as_ref())
867 else {
868 return vec![];
869 };
870 constraints.clone()
871 }
872
873 pub fn build_constraints(&self) -> Vec<uv_pep508::Requirement<VerbatimParsedUrl>> {
875 let Some(build_constraints) = self
876 .pyproject_toml
877 .tool
878 .as_ref()
879 .and_then(|tool| tool.uv.as_ref())
880 .and_then(|uv| uv.build_constraint_dependencies.as_ref())
881 else {
882 return vec![];
883 };
884 build_constraints.clone()
885 }
886
887 pub fn install_path(&self) -> &PathBuf {
890 &self.install_path
891 }
892
893 pub fn environment_selection(&self, active: Option<bool>) -> ProjectEnvironmentSelection {
902 fn from_project_environment_variable(workspace: &Workspace) -> Option<PathBuf> {
904 let value = std::env::var_os(EnvVars::UV_PROJECT_ENVIRONMENT)?;
905
906 if value.is_empty() {
907 return None;
908 }
909
910 let path = PathBuf::from(value);
911 if path.is_absolute() {
912 return Some(path);
913 }
914
915 Some(workspace.install_path.join(path))
917 }
918
919 fn from_virtual_env_variable() -> Option<PathBuf> {
921 let value = std::env::var_os(EnvVars::VIRTUAL_ENV)?;
922
923 if value.is_empty() {
924 return None;
925 }
926
927 let path = PathBuf::from(value);
928 if path.is_absolute() {
929 return Some(path);
930 }
931
932 Some(CWD.join(path))
935 }
936
937 let selection = from_project_environment_variable(self)
938 .map(ProjectEnvironmentSelection::Override)
939 .unwrap_or(ProjectEnvironmentSelection::Default);
940 let project_environment_path = selection
941 .explicit_path()
942 .map_or_else(|| self.install_path.join(".venv"), Path::to_path_buf);
943
944 if let Some(from_virtual_env) = from_virtual_env_variable() {
946 let matches_project =
947 uv_fs::is_same_file_allow_missing(&from_virtual_env, &project_environment_path)
948 .unwrap_or(false);
949 match active {
950 Some(true) => {
951 if !matches_project {
952 debug!(
953 "Using active virtual environment `{}` instead of project environment `{}`",
954 from_virtual_env.user_display(),
955 project_environment_path.user_display()
956 );
957 }
958 return ProjectEnvironmentSelection::Active(from_virtual_env);
959 }
960 Some(false) => {}
961 None if !matches_project => {
962 warn_user_once!(
963 "`VIRTUAL_ENV={}` does not match the project environment path `{}` and will be ignored; use `--active` to target the active environment instead",
964 from_virtual_env.user_display(),
965 project_environment_path.user_display()
966 );
967 }
968 None => {}
969 }
970 } else {
971 if active.unwrap_or_default() {
972 debug!(
973 "Use of the active virtual environment was requested, but `VIRTUAL_ENV` is not set"
974 );
975 }
976 }
977
978 selection
979 }
980
981 pub fn packages(&self) -> &BTreeMap<PackageName, WorkspaceMember> {
983 &self.packages
984 }
985
986 pub fn sources(&self) -> &BTreeMap<PackageName, Sources> {
988 &self.sources
989 }
990
991 pub fn indexes(&self) -> &[Index] {
993 &self.indexes
994 }
995
996 pub fn pyproject_toml(&self) -> &PyProjectToml {
998 &self.pyproject_toml
999 }
1000
1001 pub fn excludes(&self, project_path: &Path) -> Result<bool, WorkspaceError> {
1003 if let Some(workspace) = self
1004 .pyproject_toml
1005 .tool
1006 .as_ref()
1007 .and_then(|tool| tool.uv.as_ref())
1008 .and_then(|uv| uv.workspace.as_ref())
1009 {
1010 is_excluded_from_workspace(project_path, &self.install_path, workspace)
1011 } else {
1012 Ok(false)
1013 }
1014 }
1015
1016 pub fn includes(&self, project_path: &Path) -> Result<bool, WorkspaceError> {
1018 if let Some(workspace) = self
1019 .pyproject_toml
1020 .tool
1021 .as_ref()
1022 .and_then(|tool| tool.uv.as_ref())
1023 .and_then(|uv| uv.workspace.as_ref())
1024 {
1025 is_included_in_workspace(project_path, &self.install_path, workspace)
1026 } else {
1027 Ok(false)
1028 }
1029 }
1030
1031 async fn build(
1033 workspace_root: PathBuf,
1034 workspace_definition: ToolUvWorkspace,
1035 workspace_pyproject_toml: PyProjectToml,
1036 current_project: Option<WorkspaceMember>,
1037 options: &DiscoveryOptions,
1038 cache: &Cache,
1039 ) -> Result<Arc<Self>, WorkspaceError> {
1040 trace!(
1041 "Discovering workspace members for: `{}`",
1042 &workspace_root.simplified_display()
1043 );
1044 let workspace_members = Self::collect_members_only(
1045 &workspace_root,
1046 &workspace_definition,
1047 &workspace_pyproject_toml,
1048 options,
1049 cache,
1050 )
1051 .await?;
1052 let mut workspace_members = Arc::new(workspace_members);
1053
1054 if let Some(root_member) = current_project
1056 && !workspace_members.contains_key(&root_member.project.name)
1057 {
1058 assert!(matches!(
1059 options.members,
1060 MemberDiscovery::None | MemberDiscovery::Ignore(_)
1061 ));
1062 debug!(
1063 "Adding current workspace member: `{}`",
1064 root_member.root.simplified_display()
1065 );
1066
1067 Arc::make_mut(&mut workspace_members)
1068 .insert(root_member.project.name.clone(), root_member);
1069 }
1070
1071 let workspace_sources = workspace_pyproject_toml
1072 .tool
1073 .clone()
1074 .and_then(|tool| tool.uv)
1075 .and_then(|uv| uv.sources)
1076 .map(ToolUvSources::into_inner)
1077 .unwrap_or_default();
1078
1079 let workspace_indexes = workspace_pyproject_toml
1080 .tool
1081 .clone()
1082 .and_then(|tool| tool.uv)
1083 .and_then(|uv| uv.index)
1084 .unwrap_or_default();
1085
1086 let required_members = Self::collect_required_members(
1087 &workspace_members,
1088 &workspace_sources,
1089 &workspace_pyproject_toml,
1090 )?;
1091
1092 let dev_dependencies_members = workspace_members
1093 .values()
1094 .filter_map(|member| {
1095 member
1096 .pyproject_toml
1097 .tool
1098 .as_ref()
1099 .and_then(|tool| tool.uv.as_ref())
1100 .and_then(|uv| uv.dev_dependencies.as_ref())
1101 .map(|_| format!("`{}`", member.root().join("pyproject.toml").user_display()))
1102 })
1103 .join(", ");
1104 if !dev_dependencies_members.is_empty() {
1105 warn_user_once!(
1106 "The `tool.uv.dev-dependencies` field (used in {}) is deprecated and will be removed in a future release; use `dependency-groups.dev` instead",
1107 dev_dependencies_members
1108 );
1109 }
1110
1111 let workspace = Self {
1112 install_path: workspace_root,
1113 packages: workspace_members,
1114 required_members,
1115 sources: workspace_sources,
1116 indexes: workspace_indexes,
1117 pyproject_toml: workspace_pyproject_toml,
1118 };
1119 Ok(Arc::new(workspace))
1120 }
1121
1122 async fn collect_members_only(
1123 workspace_root: &PathBuf,
1124 workspace_definition: &ToolUvWorkspace,
1125 workspace_pyproject_toml: &PyProjectToml,
1126 options: &DiscoveryOptions,
1127 cache: &Cache,
1128 ) -> Result<BTreeMap<PackageName, WorkspaceMember>, WorkspaceError> {
1129 let mut workspace_members = BTreeMap::new();
1130 let mut seen = FxHashSet::default();
1132
1133 let external_cache_root = options
1134 .stop_discovery_at
1135 .is_none()
1136 .then(|| {
1137 let cache_root = if cache.root().is_absolute() {
1139 cache.root().to_path_buf()
1140 } else {
1141 CWD.join(cache.root())
1142 };
1143 normalize_path(&cache_root).into_owned()
1144 })
1145 .filter(|cache_root| !workspace_root.starts_with(cache_root));
1146
1147 if let Some(project) = &workspace_pyproject_toml.project {
1150 debug!(
1151 "Adding root workspace member: `{}`",
1152 workspace_root.simplified_display()
1153 );
1154
1155 seen.insert(workspace_root.clone());
1156 workspace_members.insert(
1157 project.name.clone(),
1158 WorkspaceMember {
1159 root: workspace_root.clone(),
1160 project: project.clone(),
1161 pyproject_toml: workspace_pyproject_toml.clone(),
1162 },
1163 );
1164 }
1165
1166 for member_glob in workspace_definition.clone().members.unwrap_or_default() {
1168 let normalized_glob = normalize_path(Path::new(member_glob.as_str()));
1170 let absolute_glob = PathBuf::from(glob::Pattern::escape(
1171 workspace_root.simplified().to_string_lossy().as_ref(),
1172 ))
1173 .join(normalized_glob.as_ref())
1174 .to_string_lossy()
1175 .to_string();
1176 for member_root in glob(&absolute_glob)
1177 .map_err(|err| WorkspaceErrorKind::Pattern(absolute_glob.clone(), err))?
1178 {
1179 let member_root = member_root
1180 .map_err(|err| WorkspaceErrorKind::GlobWalk(absolute_glob.clone(), err))?;
1181 if external_cache_root
1182 .as_ref()
1183 .is_some_and(|cache_root| member_root.starts_with(cache_root))
1184 {
1185 debug!(
1186 "Ignoring cache directory while discovering workspace members: `{}`",
1187 member_root.simplified_display()
1188 );
1189 continue;
1190 }
1191 if !seen.insert(member_root.clone()) {
1192 continue;
1193 }
1194 let member_root = std::path::absolute(&member_root)
1195 .map_err(WorkspaceErrorKind::Normalize)?
1196 .clone();
1197
1198 let skip = match &options.members {
1200 MemberDiscovery::All | MemberDiscovery::Existing => false,
1201 MemberDiscovery::None => true,
1202 MemberDiscovery::Ignore(ignore) => ignore.contains(member_root.as_path()),
1203 };
1204 if skip {
1205 debug!(
1206 "Ignoring workspace member: `{}`",
1207 member_root.simplified_display()
1208 );
1209 continue;
1210 }
1211
1212 if is_excluded_from_workspace(&member_root, workspace_root, workspace_definition)? {
1214 debug!(
1215 "Ignoring workspace member: `{}`",
1216 member_root.simplified_display()
1217 );
1218 continue;
1219 }
1220
1221 trace!(
1222 "Processing workspace member: `{}`",
1223 member_root.user_display()
1224 );
1225
1226 let pyproject_path = member_root.join("pyproject.toml");
1228 let contents = match fs_err::tokio::read_to_string(&pyproject_path).await {
1229 Ok(contents) => contents,
1230 Err(err) => {
1231 let metadata = match fs_err::metadata(&member_root) {
1232 Ok(metadata) => metadata,
1233 Err(err)
1234 if matches!(options.members, MemberDiscovery::Existing)
1235 && err.kind() == std::io::ErrorKind::NotFound =>
1236 {
1237 debug!(
1238 "Ignoring missing workspace member: `{}`",
1239 member_root.simplified_display()
1240 );
1241 continue;
1242 }
1243 Err(err) => return Err(err.into()),
1244 };
1245 if !metadata.is_dir() {
1246 warn!(
1247 "Ignoring non-directory workspace member: `{}`",
1248 member_root.simplified_display()
1249 );
1250 continue;
1251 }
1252
1253 if err.kind() == std::io::ErrorKind::NotFound {
1255 if member_root
1257 .file_name()
1258 .is_some_and(|name| name.as_encoded_bytes().starts_with(b"."))
1259 {
1260 debug!(
1261 "Ignoring hidden workspace member: `{}`",
1262 member_root.simplified_display()
1263 );
1264 continue;
1265 }
1266
1267 if has_only_gitignored_files(&member_root) {
1270 debug!(
1271 "Ignoring workspace member with only gitignored files: `{}`",
1272 member_root.simplified_display()
1273 );
1274 continue;
1275 }
1276
1277 if matches!(options.members, MemberDiscovery::Existing) {
1278 debug!(
1279 "Ignoring missing workspace member: `{}`",
1280 member_root.simplified_display()
1281 );
1282 continue;
1283 }
1284
1285 return Err(WorkspaceError::from(
1286 WorkspaceErrorKind::MissingPyprojectTomlMember(
1287 member_root,
1288 member_glob.to_string(),
1289 ),
1290 ));
1291 }
1292
1293 return Err(err.into());
1294 }
1295 };
1296 let pyproject_toml = PyProjectToml::from_string(contents, &pyproject_path)
1297 .map_err(|err| {
1298 WorkspaceErrorKind::Toml(pyproject_path.clone(), Box::new(err))
1299 })?;
1300
1301 if pyproject_toml
1303 .tool
1304 .as_ref()
1305 .and_then(|tool| tool.uv.as_ref())
1306 .and_then(|uv| uv.managed)
1307 == Some(false)
1308 {
1309 if let Some(project) = pyproject_toml.project.as_ref() {
1310 debug!(
1311 "Project `{}` is marked as unmanaged; omitting from workspace members",
1312 project.name
1313 );
1314 } else {
1315 debug!(
1316 "Workspace member at `{}` is marked as unmanaged; omitting from workspace members",
1317 member_root.simplified_display()
1318 );
1319 }
1320 continue;
1321 }
1322
1323 let Some(project) = pyproject_toml.project.clone() else {
1325 return Err(WorkspaceError::from(WorkspaceErrorKind::MissingProject(
1326 pyproject_path,
1327 )));
1328 };
1329
1330 debug!(
1331 "Adding discovered workspace member: `{}`",
1332 member_root.simplified_display()
1333 );
1334
1335 if let Some(existing) = workspace_members.insert(
1336 project.name.clone(),
1337 WorkspaceMember {
1338 root: member_root.clone(),
1339 project,
1340 pyproject_toml,
1341 },
1342 ) {
1343 return Err(WorkspaceError::from(WorkspaceErrorKind::DuplicatePackage {
1344 name: existing.project.name,
1345 first: existing.root.clone(),
1346 second: member_root,
1347 }));
1348 }
1349 }
1350 }
1351
1352 for member in workspace_members.values() {
1354 if member.root() != workspace_root
1355 && member
1356 .pyproject_toml
1357 .tool
1358 .as_ref()
1359 .and_then(|tool| tool.uv.as_ref())
1360 .and_then(|uv| uv.workspace.as_ref())
1361 .is_some()
1362 {
1363 return Err(WorkspaceError::from(WorkspaceErrorKind::NestedWorkspace(
1364 member.root.clone(),
1365 )));
1366 }
1367 }
1368 Ok(workspace_members)
1369 }
1370}
1371
1372#[derive(Debug, Clone, PartialEq)]
1374#[cfg_attr(test, derive(serde::Serialize))]
1375pub struct WorkspaceMember {
1376 root: PathBuf,
1378 project: Project,
1381 pyproject_toml: PyProjectToml,
1383}
1384
1385impl WorkspaceMember {
1386 pub fn root(&self) -> &PathBuf {
1388 &self.root
1389 }
1390
1391 pub fn project(&self) -> &Project {
1394 &self.project
1395 }
1396
1397 pub fn pyproject_toml(&self) -> &PyProjectToml {
1399 &self.pyproject_toml
1400 }
1401}
1402
1403#[derive(Debug, Clone)]
1481#[cfg_attr(test, derive(serde::Serialize))]
1482pub struct ProjectWorkspace {
1483 project_root: PathBuf,
1485 project_name: PackageName,
1487 workspace: Arc<Workspace>,
1489}
1490
1491impl ProjectWorkspace {
1492 fn from_cache(
1493 project_root: &Path,
1494 options: &DiscoveryOptions,
1495 cache: &WorkspaceCache,
1496 ) -> Result<Option<Self>, WorkspaceError> {
1497 let workspace = match cache.get(project_root, &options.members) {
1498 Some(Ok(workspace)) => workspace,
1499 Some(Err(error)) => return Err(error),
1500 None => return Ok(None),
1501 };
1502 let Some((project_name, _member)) = workspace
1503 .packages
1504 .iter()
1505 .find(|(_project_name, member)| member.root() == project_root)
1506 else {
1507 return Ok(None);
1508 };
1509
1510 Ok(Some(Self {
1511 project_root: project_root.to_path_buf(),
1512 project_name: project_name.clone(),
1513 workspace,
1514 }))
1515 }
1516
1517 pub async fn discover(
1522 path: &Path,
1523 options: &DiscoveryOptions,
1524 cache: &Cache,
1525 workspace_cache: &WorkspaceCache,
1526 ) -> Result<Self, WorkspaceError> {
1527 assert!(
1528 path.is_absolute(),
1529 "project workspace discovery with relative path"
1530 );
1531 let project_root = path
1532 .ancestors()
1533 .take_while(|path| {
1534 options
1536 .stop_discovery_at
1537 .as_deref()
1538 .and_then(Path::parent)
1539 .is_none_or(|stop_discovery_at| stop_discovery_at != *path)
1540 })
1541 .find(|path| path.join("pyproject.toml").is_file())
1542 .ok_or_else(|| WorkspaceErrorKind::MissingPyprojectToml)?;
1543
1544 debug!(
1545 "Found project root: `{}`",
1546 project_root.simplified_display()
1547 );
1548
1549 Self::from_project_root(project_root, options, cache, workspace_cache).await
1550 }
1551
1552 async fn from_project_root(
1554 project_root: &Path,
1555 options: &DiscoveryOptions,
1556 cache: &Cache,
1557 workspace_cache: &WorkspaceCache,
1558 ) -> Result<Self, WorkspaceError> {
1559 if let Some(project) = Self::from_cache(project_root, options, workspace_cache)? {
1560 return Ok(project);
1561 }
1562
1563 let pyproject_path = project_root.join("pyproject.toml");
1565
1566 let contents = fs_err::tokio::read_to_string(&pyproject_path).await?;
1567 let pyproject_toml = PyProjectToml::from_string(contents, &pyproject_path)
1568 .map_err(|err| WorkspaceErrorKind::Toml(pyproject_path.clone(), Box::new(err)))?;
1569
1570 let project = pyproject_toml
1572 .project
1573 .clone()
1574 .ok_or_else(|| WorkspaceErrorKind::MissingProject(pyproject_path))?;
1575
1576 Self::from_project(
1577 project_root,
1578 &project,
1579 &pyproject_toml,
1580 options,
1581 cache,
1582 workspace_cache,
1583 )
1584 .await
1585 }
1586
1587 pub async fn from_maybe_project_root(
1590 project_root: &Path,
1591 options: &DiscoveryOptions,
1592 cache: &Cache,
1593 workspace_cache: &WorkspaceCache,
1594 ) -> Result<Option<Self>, WorkspaceError> {
1595 if let Some(project) = Self::from_cache(project_root, options, workspace_cache)? {
1596 return Ok(Some(project));
1597 }
1598
1599 let pyproject_path = project_root.join("pyproject.toml");
1601 let Ok(contents) = fs_err::tokio::read_to_string(&pyproject_path).await else {
1602 return Ok(None);
1604 };
1605 let pyproject_toml = PyProjectToml::from_string(contents, &pyproject_path)
1606 .map_err(|err| WorkspaceErrorKind::Toml(pyproject_path.clone(), Box::new(err)))?;
1607
1608 let Some(project) = pyproject_toml.project.clone() else {
1610 return Ok(None);
1612 };
1613
1614 match Self::from_project(
1615 project_root,
1616 &project,
1617 &pyproject_toml,
1618 options,
1619 cache,
1620 workspace_cache,
1621 )
1622 .await
1623 {
1624 Ok(workspace) => Ok(Some(workspace)),
1625 Err(error) if matches!(error.as_ref(), WorkspaceErrorKind::NonWorkspace(_)) => Ok(None),
1626 Err(err) => Err(err),
1627 }
1628 }
1629
1630 pub fn project_root(&self) -> &Path {
1633 &self.project_root
1634 }
1635
1636 pub fn project_name(&self) -> &PackageName {
1638 &self.project_name
1639 }
1640
1641 pub fn workspace(&self) -> &Workspace {
1643 &self.workspace
1644 }
1645
1646 pub fn current_project(&self) -> &WorkspaceMember {
1648 &self.workspace().packages[&self.project_name]
1649 }
1650
1651 fn update_member(self, pyproject_toml: PyProjectToml) -> Result<Option<Self>, WorkspaceError> {
1655 let Some(workspace) =
1656 Workspace::update_member(self.workspace, &self.project_name, pyproject_toml)?
1657 else {
1658 return Ok(None);
1659 };
1660 Ok(Some(Self { workspace, ..self }))
1661 }
1662
1663 async fn from_project(
1665 install_path: &Path,
1666 project: &Project,
1667 project_pyproject_toml: &PyProjectToml,
1668 options: &DiscoveryOptions,
1669 cache: &Cache,
1670 workspace_cache: &WorkspaceCache,
1671 ) -> Result<Self, WorkspaceError> {
1672 let project_path = std::path::absolute(install_path)
1673 .map_err(WorkspaceErrorKind::Normalize)?
1674 .clone();
1675 let project_path = normalize_path(&project_path);
1676
1677 if project_pyproject_toml
1679 .tool
1680 .as_ref()
1681 .and_then(|tool| tool.uv.as_ref())
1682 .and_then(|uv| uv.managed)
1683 == Some(false)
1684 {
1685 debug!("Project `{}` is marked as unmanaged", project.name);
1686 return Err(WorkspaceError::from(WorkspaceErrorKind::NonWorkspace(
1687 project_path.to_path_buf(),
1688 )));
1689 }
1690
1691 if let Some(project) = Self::from_cache(&project_path, options, workspace_cache)? {
1692 return Ok(project);
1693 }
1694
1695 let mut workspace = project_pyproject_toml
1697 .tool
1698 .as_ref()
1699 .and_then(|tool| tool.uv.as_ref())
1700 .and_then(|uv| uv.workspace.as_ref())
1701 .map(|workspace| {
1702 (
1703 project_path.to_path_buf(),
1704 workspace.clone(),
1705 project_pyproject_toml.clone(),
1706 )
1707 });
1708
1709 if workspace.is_none() {
1710 workspace = find_workspace(&project_path, options, cache).await?;
1713 }
1714
1715 let current_project = WorkspaceMember {
1716 root: project_path.to_path_buf(),
1717 project: project.clone(),
1718 pyproject_toml: project_pyproject_toml.clone(),
1719 };
1720
1721 let Some((workspace_root, workspace_definition, workspace_pyproject_toml)) = workspace
1722 else {
1723 debug!("No workspace root found, using project root");
1726
1727 let current_project_as_members = Arc::new(BTreeMap::from_iter([(
1728 project.name.clone(),
1729 current_project,
1730 )]));
1731 let workspace_sources = BTreeMap::default();
1732 let required_members = Workspace::collect_required_members(
1733 ¤t_project_as_members,
1734 &workspace_sources,
1735 project_pyproject_toml,
1736 )?;
1737
1738 let workspace = Workspace {
1739 install_path: project_path.to_path_buf(),
1740 packages: current_project_as_members,
1741 required_members,
1742 sources: workspace_sources,
1745 indexes: Vec::default(),
1746 pyproject_toml: project_pyproject_toml.clone(),
1747 };
1748 let workspace = Arc::new(workspace);
1749 if options.members == MemberDiscovery::All {
1750 workspace_cache.insert(Ok(workspace.clone()), &project_path);
1751 }
1752 return Ok(Self {
1753 project_root: project_path.to_path_buf(),
1754 project_name: project.name.clone(),
1755 workspace,
1756 });
1757 };
1758
1759 if options.members == MemberDiscovery::All {
1760 if let Some(workspace) = workspace_cache.register_or_wait(&workspace_root).await {
1762 return workspace.map(|workspace| Self {
1763 project_root: project_path.to_path_buf(),
1764 project_name: project.name.clone(),
1765 workspace,
1766 });
1767 }
1768 }
1769
1770 debug!(
1771 "Found workspace root: `{}`",
1772 workspace_root.simplified_display()
1773 );
1774
1775 let result = Workspace::build(
1776 workspace_root.clone(),
1777 workspace_definition,
1778 workspace_pyproject_toml,
1779 Some(current_project),
1780 options,
1781 cache,
1782 )
1783 .await;
1784 if options.members == MemberDiscovery::All {
1785 workspace_cache.insert(result.clone(), &workspace_root);
1786 }
1787
1788 Ok(Self {
1789 project_root: project_path.to_path_buf(),
1790 project_name: project.name.clone(),
1791 workspace: result?,
1792 })
1793 }
1794}
1795
1796async fn find_workspace(
1798 project_root: &Path,
1799 options: &DiscoveryOptions,
1800 cache: &Cache,
1801) -> Result<Option<(PathBuf, ToolUvWorkspace, PyProjectToml)>, WorkspaceError> {
1802 let external_cache_root = if options.stop_discovery_at.is_none() {
1803 let cache_root = if cache.root().is_absolute() {
1805 cache.root().to_path_buf()
1806 } else {
1807 CWD.join(cache.root())
1808 };
1809 Some(normalize_path(&cache_root).into_owned())
1810 } else {
1811 None
1812 };
1813 if let Some(cache_root) = external_cache_root
1815 && project_root.starts_with(cache_root)
1816 {
1817 debug!(
1818 "Project is contained in cache directory: `{}`",
1819 project_root.simplified_display()
1820 );
1821 return Ok(None);
1822 }
1823
1824 for workspace_root in project_root
1826 .ancestors()
1827 .take_while(|path| {
1828 options
1830 .stop_discovery_at
1831 .as_deref()
1832 .and_then(Path::parent)
1833 .is_none_or(|stop_discovery_at| stop_discovery_at != *path)
1834 })
1835 .skip(1)
1836 {
1837 let pyproject_path = workspace_root.join("pyproject.toml");
1838 if !pyproject_path.is_file() {
1839 continue;
1840 }
1841 trace!(
1842 "Found `pyproject.toml` at: `{}`",
1843 pyproject_path.simplified_display()
1844 );
1845
1846 let contents = fs_err::tokio::read_to_string(&pyproject_path).await?;
1848 let pyproject_toml = PyProjectToml::from_string(contents, &pyproject_path)
1849 .map_err(|err| WorkspaceErrorKind::Toml(pyproject_path.clone(), Box::new(err)))?;
1850
1851 return if let Some(workspace) = pyproject_toml
1852 .tool
1853 .as_ref()
1854 .and_then(|tool| tool.uv.as_ref())
1855 .and_then(|uv| uv.workspace.as_ref())
1856 {
1857 if !is_included_in_workspace(project_root, workspace_root, workspace)? {
1858 debug!(
1859 "Found workspace root `{}`, but project is not included",
1860 workspace_root.simplified_display()
1861 );
1862 return Ok(None);
1863 }
1864
1865 if is_excluded_from_workspace(project_root, workspace_root, workspace)? {
1866 debug!(
1867 "Found workspace root `{}`, but project is excluded",
1868 workspace_root.simplified_display()
1869 );
1870 return Ok(None);
1871 }
1872
1873 Ok(Some((
1875 workspace_root.to_path_buf(),
1876 workspace.clone(),
1877 pyproject_toml,
1878 )))
1879 } else if pyproject_toml.project.is_some() {
1880 debug!(
1899 "Project is contained in non-workspace project: `{}`",
1900 workspace_root.simplified_display()
1901 );
1902 Ok(None)
1903 } else {
1904 warn!(
1906 "`pyproject.toml` does not contain a `project` table: `{}`",
1907 pyproject_path.simplified_display()
1908 );
1909 Ok(None)
1910 };
1911 }
1912
1913 Ok(None)
1914}
1915
1916fn has_only_gitignored_files(path: &Path) -> bool {
1921 let walker = ignore::WalkBuilder::new(path)
1922 .hidden(false)
1923 .parents(true)
1924 .ignore(true)
1925 .git_ignore(true)
1926 .git_global(true)
1927 .git_exclude(true)
1928 .build();
1929
1930 for entry in walker {
1931 let Ok(entry) = entry else {
1932 return false;
1934 };
1935
1936 if entry.path().is_dir() {
1938 continue;
1939 }
1940
1941 return false;
1943 }
1944
1945 true
1946}
1947
1948fn is_excluded_from_workspace(
1950 project_path: &Path,
1951 workspace_root: &Path,
1952 workspace: &ToolUvWorkspace,
1953) -> Result<bool, WorkspaceError> {
1954 for exclude_glob in workspace.exclude.iter().flatten() {
1955 let normalized_glob = normalize_path(Path::new(exclude_glob.as_str()));
1957 let absolute_glob = PathBuf::from(glob::Pattern::escape(
1958 workspace_root.simplified().to_string_lossy().as_ref(),
1959 ))
1960 .join(normalized_glob.as_ref());
1961 let absolute_glob = absolute_glob.to_string_lossy();
1962 let exclude_pattern = glob::Pattern::new(&absolute_glob)
1963 .map_err(|err| WorkspaceErrorKind::Pattern(absolute_glob.to_string(), err))?;
1964 if exclude_pattern.matches_path(project_path) {
1965 return Ok(true);
1966 }
1967 }
1968 Ok(false)
1969}
1970
1971fn is_included_in_workspace(
1973 project_path: &Path,
1974 workspace_root: &Path,
1975 workspace: &ToolUvWorkspace,
1976) -> Result<bool, WorkspaceError> {
1977 for member_glob in workspace.members.iter().flatten() {
1978 let normalized_glob = normalize_path(Path::new(member_glob.as_str()));
1980 let absolute_glob = PathBuf::from(glob::Pattern::escape(
1981 workspace_root.simplified().to_string_lossy().as_ref(),
1982 ))
1983 .join(normalized_glob);
1984 let absolute_glob = absolute_glob.to_string_lossy();
1985 let include_pattern = glob::Pattern::new(&absolute_glob)
1986 .map_err(|err| WorkspaceErrorKind::Pattern(absolute_glob.to_string(), err))?;
1987 if include_pattern.matches_path(project_path) {
1988 return Ok(true);
1989 }
1990 }
1991 Ok(false)
1992}
1993
1994#[derive(Debug, Clone)]
1999pub enum VirtualProject {
2000 Project(ProjectWorkspace),
2002 NonProject(Arc<Workspace>),
2004}
2005
2006impl VirtualProject {
2007 pub async fn discover(
2015 path: &Path,
2016 options: &DiscoveryOptions,
2017 cache: &Cache,
2018 workspace_cache: &WorkspaceCache,
2019 ) -> Result<Self, WorkspaceError> {
2020 assert!(
2021 path.is_absolute(),
2022 "virtual project discovery with relative path"
2023 );
2024 let project_root = path
2025 .ancestors()
2026 .take_while(|path| {
2027 options
2029 .stop_discovery_at
2030 .as_deref()
2031 .and_then(Path::parent)
2032 .is_none_or(|stop_discovery_at| stop_discovery_at != *path)
2033 })
2034 .find(|path| path.join("pyproject.toml").is_file())
2035 .ok_or(WorkspaceErrorKind::MissingPyprojectToml)?;
2036
2037 debug!(
2038 "Found project root: `{}`",
2039 project_root.simplified_display()
2040 );
2041
2042 if let Some(workspace) = workspace_cache.get(project_root, &options.members) {
2044 let workspace = workspace?;
2045 let virtual_project = if let Some((project_name, _member)) = workspace
2046 .packages
2047 .iter()
2048 .find(|(_package_name, member)| member.root == project_root)
2049 {
2050 Self::Project(ProjectWorkspace {
2051 project_root: project_root.to_path_buf(),
2052 project_name: project_name.clone(),
2053 workspace,
2054 })
2055 } else {
2056 Self::NonProject(workspace.clone())
2057 };
2058 return Ok(virtual_project);
2059 }
2060
2061 let pyproject_path = project_root.join("pyproject.toml");
2063 let contents = fs_err::tokio::read_to_string(&pyproject_path).await?;
2064 let pyproject_toml = PyProjectToml::from_string(contents, &pyproject_path)
2065 .map_err(|err| WorkspaceErrorKind::Toml(pyproject_path.clone(), Box::new(err)))?;
2066
2067 if let Some(project) = pyproject_toml.project.as_ref() {
2068 let project = ProjectWorkspace::from_project(
2070 project_root,
2071 project,
2072 &pyproject_toml,
2073 options,
2074 cache,
2075 workspace_cache,
2076 )
2077 .await?;
2078 Ok(Self::Project(project))
2079 } else if let Some(workspace) = pyproject_toml
2080 .tool
2081 .as_ref()
2082 .and_then(|tool| tool.uv.as_ref())
2083 .and_then(|uv| uv.workspace.as_ref())
2084 {
2085 let project_path = std::path::absolute(project_root)
2088 .map_err(WorkspaceErrorKind::Normalize)?
2089 .clone();
2090
2091 let result = Workspace::build(
2092 project_path.clone(),
2093 workspace.clone(),
2094 pyproject_toml,
2095 None,
2096 options,
2097 cache,
2098 )
2099 .await;
2100 if options.members == MemberDiscovery::All {
2101 workspace_cache.insert(result.clone(), &project_path);
2102 }
2103 Ok(Self::NonProject(result?))
2104 } else {
2105 let project_path = std::path::absolute(project_root)
2108 .map_err(WorkspaceErrorKind::Normalize)?
2109 .clone();
2110
2111 let result = Workspace::build(
2112 project_path.clone(),
2113 ToolUvWorkspace::default(),
2114 pyproject_toml,
2115 None,
2116 options,
2117 cache,
2118 )
2119 .await;
2120 if options.members == MemberDiscovery::All {
2121 workspace_cache.insert(result.clone(), &project_path);
2122 }
2123 Ok(Self::NonProject(result?))
2124 }
2125 }
2126
2127 pub async fn discover_with_package(
2129 path: &Path,
2130 options: &DiscoveryOptions,
2131 cache: &Cache,
2132 workspace_cache: &WorkspaceCache,
2133 package: PackageName,
2134 ) -> Result<Self, WorkspaceError> {
2135 let workspace = Workspace::discover(path, options, cache, workspace_cache).await?;
2136 let Some(project_workspace) =
2137 Workspace::with_current_project(workspace.clone(), package.clone())
2138 else {
2139 return Err(WorkspaceError::from(WorkspaceErrorKind::NoSuchMember(
2140 package,
2141 workspace.install_path.clone(),
2142 )));
2143 };
2144 Ok(Self::Project(project_workspace))
2145 }
2146
2147 pub fn update_member(
2156 self,
2157 pyproject_toml: PyProjectToml,
2158 workspace_cache: &WorkspaceCache,
2159 ) -> Result<Option<Self>, WorkspaceError> {
2160 workspace_cache.invalidate_workspace(self.workspace());
2162 Ok(match self {
2163 Self::Project(project) => {
2164 let Some(project) = project.update_member(pyproject_toml)? else {
2165 return Ok(None);
2166 };
2167 Some(Self::Project(project))
2168 }
2169 Self::NonProject(workspace) => {
2170 debug_assert_eq!(
2171 Arc::strong_count(&workspace),
2172 1,
2173 "cannot modify workspace still in use",
2174 );
2175
2176 let workspace = Arc::unwrap_or_clone(workspace);
2177 let workspace = Workspace {
2180 pyproject_toml,
2181 ..workspace
2182 };
2183 Some(Self::NonProject(Arc::new(workspace)))
2184 }
2185 })
2186 }
2187
2188 #[must_use]
2193 pub fn clone_detach(&self) -> Self {
2194 match self {
2195 Self::Project(project) => Self::Project(ProjectWorkspace {
2196 project_root: project.project_root.clone(),
2197 project_name: project.project_name.clone(),
2198 workspace: Arc::new((*project.workspace).clone()),
2199 }),
2200 Self::NonProject(workspace) => Self::NonProject(Arc::new((**workspace).clone())),
2201 }
2202 }
2203
2204 pub fn root(&self) -> &Path {
2206 match self {
2207 Self::Project(project) => project.project_root(),
2208 Self::NonProject(workspace) => workspace.install_path(),
2209 }
2210 }
2211
2212 pub fn pyproject_toml(&self) -> &PyProjectToml {
2214 match self {
2215 Self::Project(project) => project.current_project().pyproject_toml(),
2216 Self::NonProject(workspace) => &workspace.pyproject_toml,
2217 }
2218 }
2219
2220 pub fn workspace(&self) -> &Workspace {
2222 match self {
2223 Self::Project(project) => project.workspace(),
2224 Self::NonProject(workspace) => workspace,
2225 }
2226 }
2227
2228 pub fn project_name(&self) -> Option<&PackageName> {
2230 match self {
2231 Self::Project(project) => Some(project.project_name()),
2232 Self::NonProject(_) => None,
2233 }
2234 }
2235
2236 pub fn is_non_project(&self) -> bool {
2238 matches!(self, Self::NonProject(_))
2239 }
2240}
2241
2242#[cfg(test)]
2243#[cfg(unix)] mod tests {
2245 use std::collections::BTreeMap;
2246 use std::env;
2247 use std::path::Path;
2248 use std::str::FromStr;
2249 use std::sync::Arc;
2250
2251 use anyhow::Result;
2252 use assert_fs::fixture::ChildPath;
2253 use assert_fs::prelude::*;
2254 use insta::{assert_json_snapshot, assert_snapshot};
2255
2256 use uv_cache::Cache;
2257 use uv_normalize::{GroupName, PackageName};
2258 use uv_pypi_types::DependencyGroupSpecifier;
2259
2260 use crate::pyproject::PyProjectToml;
2261 use crate::workspace::{DiscoveryOptions, MemberDiscovery, ProjectWorkspace, Workspace};
2262 use crate::{WorkspaceCache, WorkspaceError};
2263
2264 async fn workspace_test(folder: &str) -> (ProjectWorkspace, String) {
2265 let root_dir = env::current_dir()
2266 .unwrap()
2267 .parent()
2268 .unwrap()
2269 .parent()
2270 .unwrap()
2271 .join("test")
2272 .join("workspaces");
2273 let cache = Cache::from_path(root_dir.join(".uv_cache"));
2274 let project = ProjectWorkspace::discover(
2275 &root_dir.join(folder),
2276 &DiscoveryOptions::default(),
2277 &cache,
2278 &WorkspaceCache::default(),
2279 )
2280 .await
2281 .unwrap();
2282 let root_escaped = regex::escape(root_dir.to_string_lossy().as_ref());
2283 (project, root_escaped)
2284 }
2285
2286 async fn temporary_test(
2287 folder: &Path,
2288 ) -> Result<(ProjectWorkspace, String), (WorkspaceError, String)> {
2289 let root_escaped = regex::escape(folder.to_string_lossy().as_ref());
2290 let cache = Cache::from_path(env::temp_dir().join("uv-workspace-cache"));
2291 let project = ProjectWorkspace::discover(
2292 folder,
2293 &DiscoveryOptions::default(),
2294 &cache,
2295 &WorkspaceCache::default(),
2296 )
2297 .await
2298 .map_err(|error| (error, root_escaped.clone()))?;
2299
2300 Ok((project, root_escaped))
2301 }
2302
2303 #[tokio::test]
2304 async fn albatross_in_example() {
2305 let (project, root_escaped) =
2306 workspace_test("albatross-in-example/examples/bird-feeder").await;
2307 let filters = vec![(root_escaped.as_str(), "[ROOT]")];
2308 insta::with_settings!({filters => filters}, {
2309 assert_json_snapshot!(
2310 project,
2311 {
2312 ".workspace.packages.*.pyproject_toml" => "[PYPROJECT_TOML]"
2313 },
2314 @r#"
2315 {
2316 "project_root": "[ROOT]/albatross-in-example/examples/bird-feeder",
2317 "project_name": "bird-feeder",
2318 "workspace": {
2319 "install_path": "[ROOT]/albatross-in-example/examples/bird-feeder",
2320 "packages": {
2321 "bird-feeder": {
2322 "root": "[ROOT]/albatross-in-example/examples/bird-feeder",
2323 "project": {
2324 "name": "bird-feeder",
2325 "version": "1.0.0",
2326 "requires-python": ">=3.12",
2327 "dependencies": [
2328 "iniconfig>=2,<3"
2329 ],
2330 "optional-dependencies": null
2331 },
2332 "pyproject_toml": "[PYPROJECT_TOML]"
2333 }
2334 },
2335 "required_members": {},
2336 "sources": {},
2337 "indexes": [],
2338 "pyproject_toml": {
2339 "project": {
2340 "name": "bird-feeder",
2341 "version": "1.0.0",
2342 "requires-python": ">=3.12",
2343 "dependencies": [
2344 "iniconfig>=2,<3"
2345 ],
2346 "optional-dependencies": null
2347 },
2348 "tool": null,
2349 "dependency-groups": null
2350 }
2351 }
2352 }
2353 "#);
2354 });
2355 }
2356
2357 #[tokio::test]
2358 async fn albatross_project_in_excluded() {
2359 let (project, root_escaped) =
2360 workspace_test("albatross-project-in-excluded/excluded/bird-feeder").await;
2361 let filters = vec![(root_escaped.as_str(), "[ROOT]")];
2362 insta::with_settings!({filters => filters}, {
2363 assert_json_snapshot!(
2364 project,
2365 {
2366 ".workspace.packages.*.pyproject_toml" => "[PYPROJECT_TOML]"
2367 },
2368 @r#"
2369 {
2370 "project_root": "[ROOT]/albatross-project-in-excluded/excluded/bird-feeder",
2371 "project_name": "bird-feeder",
2372 "workspace": {
2373 "install_path": "[ROOT]/albatross-project-in-excluded/excluded/bird-feeder",
2374 "packages": {
2375 "bird-feeder": {
2376 "root": "[ROOT]/albatross-project-in-excluded/excluded/bird-feeder",
2377 "project": {
2378 "name": "bird-feeder",
2379 "version": "1.0.0",
2380 "requires-python": ">=3.12",
2381 "dependencies": [
2382 "iniconfig>=2,<3"
2383 ],
2384 "optional-dependencies": null
2385 },
2386 "pyproject_toml": "[PYPROJECT_TOML]"
2387 }
2388 },
2389 "required_members": {},
2390 "sources": {},
2391 "indexes": [],
2392 "pyproject_toml": {
2393 "project": {
2394 "name": "bird-feeder",
2395 "version": "1.0.0",
2396 "requires-python": ">=3.12",
2397 "dependencies": [
2398 "iniconfig>=2,<3"
2399 ],
2400 "optional-dependencies": null
2401 },
2402 "tool": null,
2403 "dependency-groups": null
2404 }
2405 }
2406 }
2407 "#);
2408 });
2409 }
2410
2411 #[tokio::test]
2412 async fn albatross_root_workspace() {
2413 let (project, root_escaped) = workspace_test("albatross-root-workspace").await;
2414 let filters = vec![(root_escaped.as_str(), "[ROOT]")];
2415 insta::with_settings!({filters => filters}, {
2416 assert_json_snapshot!(
2417 project,
2418 {
2419 ".workspace.packages.*.pyproject_toml" => "[PYPROJECT_TOML]"
2420 },
2421 @r#"
2422 {
2423 "project_root": "[ROOT]/albatross-root-workspace",
2424 "project_name": "albatross",
2425 "workspace": {
2426 "install_path": "[ROOT]/albatross-root-workspace",
2427 "packages": {
2428 "albatross": {
2429 "root": "[ROOT]/albatross-root-workspace",
2430 "project": {
2431 "name": "albatross",
2432 "version": "0.1.0",
2433 "requires-python": ">=3.12",
2434 "dependencies": [
2435 "bird-feeder",
2436 "iniconfig>=2,<3"
2437 ],
2438 "optional-dependencies": null
2439 },
2440 "pyproject_toml": "[PYPROJECT_TOML]"
2441 },
2442 "bird-feeder": {
2443 "root": "[ROOT]/albatross-root-workspace/packages/bird-feeder",
2444 "project": {
2445 "name": "bird-feeder",
2446 "version": "1.0.0",
2447 "requires-python": ">=3.8",
2448 "dependencies": [
2449 "iniconfig>=2,<3",
2450 "seeds"
2451 ],
2452 "optional-dependencies": null
2453 },
2454 "pyproject_toml": "[PYPROJECT_TOML]"
2455 },
2456 "seeds": {
2457 "root": "[ROOT]/albatross-root-workspace/packages/seeds",
2458 "project": {
2459 "name": "seeds",
2460 "version": "1.0.0",
2461 "requires-python": ">=3.12",
2462 "dependencies": [
2463 "idna==3.6"
2464 ],
2465 "optional-dependencies": null
2466 },
2467 "pyproject_toml": "[PYPROJECT_TOML]"
2468 }
2469 },
2470 "required_members": {
2471 "bird-feeder": null,
2472 "seeds": null
2473 },
2474 "sources": {
2475 "bird-feeder": [
2476 {
2477 "workspace": true,
2478 "editable": null,
2479 "extra": null,
2480 "group": null
2481 }
2482 ]
2483 },
2484 "indexes": [],
2485 "pyproject_toml": {
2486 "project": {
2487 "name": "albatross",
2488 "version": "0.1.0",
2489 "requires-python": ">=3.12",
2490 "dependencies": [
2491 "bird-feeder",
2492 "iniconfig>=2,<3"
2493 ],
2494 "optional-dependencies": null
2495 },
2496 "tool": {
2497 "uv": {
2498 "sources": {
2499 "bird-feeder": [
2500 {
2501 "workspace": true,
2502 "editable": null,
2503 "extra": null,
2504 "group": null
2505 }
2506 ]
2507 },
2508 "index": null,
2509 "workspace": {
2510 "members": [
2511 "packages/*"
2512 ],
2513 "exclude": null
2514 },
2515 "managed": null,
2516 "package": null,
2517 "default-groups": null,
2518 "dependency-groups": null,
2519 "dev-dependencies": null,
2520 "override-dependencies": null,
2521 "exclude-dependencies": null,
2522 "constraint-dependencies": null,
2523 "build-constraint-dependencies": null,
2524 "environments": null,
2525 "required-environments": null,
2526 "conflicts": null,
2527 "build-backend": null
2528 }
2529 },
2530 "dependency-groups": null
2531 }
2532 }
2533 }
2534 "#);
2535 });
2536 }
2537
2538 #[tokio::test]
2539 async fn albatross_virtual_workspace() {
2540 let (project, root_escaped) =
2541 workspace_test("albatross-virtual-workspace/packages/albatross").await;
2542 let filters = vec![(root_escaped.as_str(), "[ROOT]")];
2543 insta::with_settings!({filters => filters}, {
2544 assert_json_snapshot!(
2545 project,
2546 {
2547 ".workspace.packages.*.pyproject_toml" => "[PYPROJECT_TOML]"
2548 },
2549 @r#"
2550 {
2551 "project_root": "[ROOT]/albatross-virtual-workspace/packages/albatross",
2552 "project_name": "albatross",
2553 "workspace": {
2554 "install_path": "[ROOT]/albatross-virtual-workspace",
2555 "packages": {
2556 "albatross": {
2557 "root": "[ROOT]/albatross-virtual-workspace/packages/albatross",
2558 "project": {
2559 "name": "albatross",
2560 "version": "0.1.0",
2561 "requires-python": ">=3.12",
2562 "dependencies": [
2563 "bird-feeder",
2564 "iniconfig>=2,<3"
2565 ],
2566 "optional-dependencies": null
2567 },
2568 "pyproject_toml": "[PYPROJECT_TOML]"
2569 },
2570 "bird-feeder": {
2571 "root": "[ROOT]/albatross-virtual-workspace/packages/bird-feeder",
2572 "project": {
2573 "name": "bird-feeder",
2574 "version": "1.0.0",
2575 "requires-python": ">=3.12",
2576 "dependencies": [
2577 "anyio>=4.3.0,<5",
2578 "seeds"
2579 ],
2580 "optional-dependencies": null
2581 },
2582 "pyproject_toml": "[PYPROJECT_TOML]"
2583 },
2584 "seeds": {
2585 "root": "[ROOT]/albatross-virtual-workspace/packages/seeds",
2586 "project": {
2587 "name": "seeds",
2588 "version": "1.0.0",
2589 "requires-python": ">=3.12",
2590 "dependencies": [
2591 "idna==3.6"
2592 ],
2593 "optional-dependencies": null
2594 },
2595 "pyproject_toml": "[PYPROJECT_TOML]"
2596 }
2597 },
2598 "required_members": {
2599 "bird-feeder": null,
2600 "seeds": null
2601 },
2602 "sources": {},
2603 "indexes": [],
2604 "pyproject_toml": {
2605 "project": null,
2606 "tool": {
2607 "uv": {
2608 "sources": null,
2609 "index": null,
2610 "workspace": {
2611 "members": [
2612 "packages/*"
2613 ],
2614 "exclude": null
2615 },
2616 "managed": null,
2617 "package": null,
2618 "default-groups": null,
2619 "dependency-groups": null,
2620 "dev-dependencies": null,
2621 "override-dependencies": null,
2622 "exclude-dependencies": null,
2623 "constraint-dependencies": null,
2624 "build-constraint-dependencies": null,
2625 "environments": null,
2626 "required-environments": null,
2627 "conflicts": null,
2628 "build-backend": null
2629 }
2630 },
2631 "dependency-groups": null
2632 }
2633 }
2634 }
2635 "#);
2636 });
2637 }
2638
2639 #[tokio::test]
2640 async fn workspace_cache_reuses_workspace_for_member() -> Result<()> {
2641 let root = tempfile::TempDir::new()?;
2642 let root = ChildPath::new(root.path());
2643
2644 root.child("pyproject.toml").write_str(
2645 r#"
2646 [project]
2647 name = "albatross"
2648 version = "0.1.0"
2649 requires-python = ">=3.12"
2650
2651 [tool.uv.workspace]
2652 members = ["packages/*"]
2653 "#,
2654 )?;
2655
2656 root.child("packages")
2657 .child("seeds")
2658 .child("pyproject.toml")
2659 .write_str(
2660 r#"
2661 [project]
2662 name = "seeds"
2663 version = "1.0.0"
2664 requires-python = ">=3.12"
2665 "#,
2666 )?;
2667
2668 let cache = Cache::from_path(env::temp_dir().join("uv-workspace-cache"));
2669 let workspace_cache = WorkspaceCache::default();
2670 let root_workspace = Workspace::discover(
2671 root.as_ref(),
2672 &DiscoveryOptions::default(),
2673 &cache,
2674 &workspace_cache,
2675 )
2676 .await?;
2677 let member_workspace = Workspace::discover(
2678 root.child("packages").child("seeds").as_ref(),
2679 &DiscoveryOptions::default(),
2680 &cache,
2681 &workspace_cache,
2682 )
2683 .await?;
2684
2685 assert!(Arc::ptr_eq(&root_workspace, &member_workspace));
2686
2687 root.child("pyproject.toml")
2688 .write_str("not valid toml >.<")?;
2689 let member_project = ProjectWorkspace::from_maybe_project_root(
2690 root.child("packages").child("seeds").as_ref(),
2691 &DiscoveryOptions::default(),
2692 &cache,
2693 &workspace_cache,
2694 )
2695 .await?
2696 .expect("cached workspace member ignores invalid change in the meantime");
2697
2698 assert!(Arc::ptr_eq(&root_workspace, &member_project.workspace));
2699
2700 Ok(())
2701 }
2702
2703 #[tokio::test]
2704 async fn workspace_cache_does_not_store_partial_discovery() -> Result<()> {
2705 let root = tempfile::TempDir::new()?;
2706 let root = ChildPath::new(root.path());
2707
2708 root.child("pyproject.toml").write_str(
2709 r#"
2710 [project]
2711 name = "albatross"
2712 version = "0.1.0"
2713 requires-python = ">=3.12"
2714
2715 [tool.uv.workspace]
2716 members = ["packages/*"]
2717 "#,
2718 )?;
2719
2720 root.child("packages")
2721 .child("seeds")
2722 .child("pyproject.toml")
2723 .write_str(
2724 r#"
2725 [project]
2726 name = "seeds"
2727 version = "1.0.0"
2728 requires-python = ">=3.12"
2729 "#,
2730 )?;
2731
2732 let cache = Cache::from_path(env::temp_dir().join("uv-workspace-cache"));
2733 let workspace_cache = WorkspaceCache::default();
2734 let partial_options = DiscoveryOptions {
2735 members: MemberDiscovery::None,
2736 ..DiscoveryOptions::default()
2737 };
2738 let partial_project =
2739 ProjectWorkspace::discover(root.as_ref(), &partial_options, &cache, &workspace_cache)
2740 .await?;
2741
2742 assert_eq!(partial_project.workspace().packages().len(), 1);
2743
2744 let member_project = ProjectWorkspace::discover(
2745 root.child("packages").child("seeds").as_ref(),
2746 &DiscoveryOptions::default(),
2747 &cache,
2748 &workspace_cache,
2749 )
2750 .await?;
2751 let seeds = PackageName::from_str("seeds")?;
2752
2753 assert_eq!(member_project.project_name(), &seeds);
2754 assert_eq!(member_project.workspace().packages().len(), 2);
2755 assert!(member_project.workspace().packages().contains_key(&seeds));
2756
2757 Ok(())
2758 }
2759
2760 #[tokio::test]
2761 async fn albatross_just_project() {
2762 let (project, root_escaped) = workspace_test("albatross-just-project").await;
2763 let filters = vec![(root_escaped.as_str(), "[ROOT]")];
2764 insta::with_settings!({filters => filters}, {
2765 assert_json_snapshot!(
2766 project,
2767 {
2768 ".workspace.packages.*.pyproject_toml" => "[PYPROJECT_TOML]"
2769 },
2770 @r#"
2771 {
2772 "project_root": "[ROOT]/albatross-just-project",
2773 "project_name": "albatross",
2774 "workspace": {
2775 "install_path": "[ROOT]/albatross-just-project",
2776 "packages": {
2777 "albatross": {
2778 "root": "[ROOT]/albatross-just-project",
2779 "project": {
2780 "name": "albatross",
2781 "version": "0.1.0",
2782 "requires-python": ">=3.12",
2783 "dependencies": [
2784 "iniconfig>=2,<3"
2785 ],
2786 "optional-dependencies": null
2787 },
2788 "pyproject_toml": "[PYPROJECT_TOML]"
2789 }
2790 },
2791 "required_members": {},
2792 "sources": {},
2793 "indexes": [],
2794 "pyproject_toml": {
2795 "project": {
2796 "name": "albatross",
2797 "version": "0.1.0",
2798 "requires-python": ">=3.12",
2799 "dependencies": [
2800 "iniconfig>=2,<3"
2801 ],
2802 "optional-dependencies": null
2803 },
2804 "tool": null,
2805 "dependency-groups": null
2806 }
2807 }
2808 }
2809 "#);
2810 });
2811 }
2812
2813 #[tokio::test]
2814 async fn exclude_package() -> Result<()> {
2815 let root = tempfile::TempDir::new()?;
2816 let root = ChildPath::new(root.path());
2817
2818 root.child("pyproject.toml").write_str(
2820 r#"
2821 [project]
2822 name = "albatross"
2823 version = "0.1.0"
2824 requires-python = ">=3.12"
2825 dependencies = ["tqdm>=4,<5"]
2826
2827 [tool.uv.workspace]
2828 members = ["packages/*"]
2829 exclude = ["packages/bird-feeder"]
2830
2831 [build-system]
2832 requires = ["hatchling"]
2833 build-backend = "hatchling.build"
2834 "#,
2835 )?;
2836 root.child("albatross").child("__init__.py").touch()?;
2837
2838 root.child("packages")
2840 .child("seeds")
2841 .child("pyproject.toml")
2842 .write_str(
2843 r#"
2844 [project]
2845 name = "seeds"
2846 version = "1.0.0"
2847 requires-python = ">=3.12"
2848 dependencies = ["idna==3.6"]
2849
2850 [build-system]
2851 requires = ["hatchling"]
2852 build-backend = "hatchling.build"
2853 "#,
2854 )?;
2855 root.child("packages")
2856 .child("seeds")
2857 .child("seeds")
2858 .child("__init__.py")
2859 .touch()?;
2860
2861 root.child("packages")
2863 .child("bird-feeder")
2864 .child("pyproject.toml")
2865 .write_str(
2866 r#"
2867 [project]
2868 name = "bird-feeder"
2869 version = "1.0.0"
2870 requires-python = ">=3.12"
2871 dependencies = ["anyio>=4.3.0,<5"]
2872
2873 [build-system]
2874 requires = ["hatchling"]
2875 build-backend = "hatchling.build"
2876 "#,
2877 )?;
2878 root.child("packages")
2879 .child("bird-feeder")
2880 .child("bird_feeder")
2881 .child("__init__.py")
2882 .touch()?;
2883
2884 let (project, root_escaped) = temporary_test(root.as_ref()).await.unwrap();
2885 let filters = vec![(root_escaped.as_str(), "[ROOT]")];
2886 insta::with_settings!({filters => filters}, {
2887 assert_json_snapshot!(
2888 project,
2889 {
2890 ".workspace.packages.*.pyproject_toml" => "[PYPROJECT_TOML]"
2891 },
2892 @r#"
2893 {
2894 "project_root": "[ROOT]",
2895 "project_name": "albatross",
2896 "workspace": {
2897 "install_path": "[ROOT]",
2898 "packages": {
2899 "albatross": {
2900 "root": "[ROOT]",
2901 "project": {
2902 "name": "albatross",
2903 "version": "0.1.0",
2904 "requires-python": ">=3.12",
2905 "dependencies": [
2906 "tqdm>=4,<5"
2907 ],
2908 "optional-dependencies": null
2909 },
2910 "pyproject_toml": "[PYPROJECT_TOML]"
2911 },
2912 "seeds": {
2913 "root": "[ROOT]/packages/seeds",
2914 "project": {
2915 "name": "seeds",
2916 "version": "1.0.0",
2917 "requires-python": ">=3.12",
2918 "dependencies": [
2919 "idna==3.6"
2920 ],
2921 "optional-dependencies": null
2922 },
2923 "pyproject_toml": "[PYPROJECT_TOML]"
2924 }
2925 },
2926 "required_members": {},
2927 "sources": {},
2928 "indexes": [],
2929 "pyproject_toml": {
2930 "project": {
2931 "name": "albatross",
2932 "version": "0.1.0",
2933 "requires-python": ">=3.12",
2934 "dependencies": [
2935 "tqdm>=4,<5"
2936 ],
2937 "optional-dependencies": null
2938 },
2939 "tool": {
2940 "uv": {
2941 "sources": null,
2942 "index": null,
2943 "workspace": {
2944 "members": [
2945 "packages/*"
2946 ],
2947 "exclude": [
2948 "packages/bird-feeder"
2949 ]
2950 },
2951 "managed": null,
2952 "package": null,
2953 "default-groups": null,
2954 "dependency-groups": null,
2955 "dev-dependencies": null,
2956 "override-dependencies": null,
2957 "exclude-dependencies": null,
2958 "constraint-dependencies": null,
2959 "build-constraint-dependencies": null,
2960 "environments": null,
2961 "required-environments": null,
2962 "conflicts": null,
2963 "build-backend": null
2964 }
2965 },
2966 "dependency-groups": null
2967 }
2968 }
2969 }
2970 "#);
2971 });
2972
2973 root.child("pyproject.toml").write_str(
2975 r#"
2976 [project]
2977 name = "albatross"
2978 version = "0.1.0"
2979 requires-python = ">=3.12"
2980 dependencies = ["tqdm>=4,<5"]
2981
2982 [tool.uv.workspace]
2983 members = ["packages/seeds", "packages/bird-feeder"]
2984 exclude = ["packages/bird-feeder"]
2985
2986 [build-system]
2987 requires = ["hatchling"]
2988 build-backend = "hatchling.build"
2989 "#,
2990 )?;
2991
2992 let (project, root_escaped) = temporary_test(root.as_ref()).await.unwrap();
2994 let filters = vec![(root_escaped.as_str(), "[ROOT]")];
2995 insta::with_settings!({filters => filters}, {
2996 assert_json_snapshot!(
2997 project,
2998 {
2999 ".workspace.packages.*.pyproject_toml" => "[PYPROJECT_TOML]"
3000 },
3001 @r#"
3002 {
3003 "project_root": "[ROOT]",
3004 "project_name": "albatross",
3005 "workspace": {
3006 "install_path": "[ROOT]",
3007 "packages": {
3008 "albatross": {
3009 "root": "[ROOT]",
3010 "project": {
3011 "name": "albatross",
3012 "version": "0.1.0",
3013 "requires-python": ">=3.12",
3014 "dependencies": [
3015 "tqdm>=4,<5"
3016 ],
3017 "optional-dependencies": null
3018 },
3019 "pyproject_toml": "[PYPROJECT_TOML]"
3020 },
3021 "seeds": {
3022 "root": "[ROOT]/packages/seeds",
3023 "project": {
3024 "name": "seeds",
3025 "version": "1.0.0",
3026 "requires-python": ">=3.12",
3027 "dependencies": [
3028 "idna==3.6"
3029 ],
3030 "optional-dependencies": null
3031 },
3032 "pyproject_toml": "[PYPROJECT_TOML]"
3033 }
3034 },
3035 "required_members": {},
3036 "sources": {},
3037 "indexes": [],
3038 "pyproject_toml": {
3039 "project": {
3040 "name": "albatross",
3041 "version": "0.1.0",
3042 "requires-python": ">=3.12",
3043 "dependencies": [
3044 "tqdm>=4,<5"
3045 ],
3046 "optional-dependencies": null
3047 },
3048 "tool": {
3049 "uv": {
3050 "sources": null,
3051 "index": null,
3052 "workspace": {
3053 "members": [
3054 "packages/seeds",
3055 "packages/bird-feeder"
3056 ],
3057 "exclude": [
3058 "packages/bird-feeder"
3059 ]
3060 },
3061 "managed": null,
3062 "package": null,
3063 "default-groups": null,
3064 "dependency-groups": null,
3065 "dev-dependencies": null,
3066 "override-dependencies": null,
3067 "exclude-dependencies": null,
3068 "constraint-dependencies": null,
3069 "build-constraint-dependencies": null,
3070 "environments": null,
3071 "required-environments": null,
3072 "conflicts": null,
3073 "build-backend": null
3074 }
3075 },
3076 "dependency-groups": null
3077 }
3078 }
3079 }
3080 "#);
3081 });
3082
3083 root.child("pyproject.toml").write_str(
3085 r#"
3086 [project]
3087 name = "albatross"
3088 version = "0.1.0"
3089 requires-python = ">=3.12"
3090 dependencies = ["tqdm>=4,<5"]
3091
3092 [tool.uv.workspace]
3093 members = ["packages/seeds", "packages/bird-feeder"]
3094 exclude = ["packages"]
3095
3096 [build-system]
3097 requires = ["hatchling"]
3098 build-backend = "hatchling.build"
3099 "#,
3100 )?;
3101
3102 let (project, root_escaped) = temporary_test(root.as_ref()).await.unwrap();
3104 let filters = vec![(root_escaped.as_str(), "[ROOT]")];
3105 insta::with_settings!({filters => filters}, {
3106 assert_json_snapshot!(
3107 project,
3108 {
3109 ".workspace.packages.*.pyproject_toml" => "[PYPROJECT_TOML]"
3110 },
3111 @r#"
3112 {
3113 "project_root": "[ROOT]",
3114 "project_name": "albatross",
3115 "workspace": {
3116 "install_path": "[ROOT]",
3117 "packages": {
3118 "albatross": {
3119 "root": "[ROOT]",
3120 "project": {
3121 "name": "albatross",
3122 "version": "0.1.0",
3123 "requires-python": ">=3.12",
3124 "dependencies": [
3125 "tqdm>=4,<5"
3126 ],
3127 "optional-dependencies": null
3128 },
3129 "pyproject_toml": "[PYPROJECT_TOML]"
3130 },
3131 "bird-feeder": {
3132 "root": "[ROOT]/packages/bird-feeder",
3133 "project": {
3134 "name": "bird-feeder",
3135 "version": "1.0.0",
3136 "requires-python": ">=3.12",
3137 "dependencies": [
3138 "anyio>=4.3.0,<5"
3139 ],
3140 "optional-dependencies": null
3141 },
3142 "pyproject_toml": "[PYPROJECT_TOML]"
3143 },
3144 "seeds": {
3145 "root": "[ROOT]/packages/seeds",
3146 "project": {
3147 "name": "seeds",
3148 "version": "1.0.0",
3149 "requires-python": ">=3.12",
3150 "dependencies": [
3151 "idna==3.6"
3152 ],
3153 "optional-dependencies": null
3154 },
3155 "pyproject_toml": "[PYPROJECT_TOML]"
3156 }
3157 },
3158 "required_members": {},
3159 "sources": {},
3160 "indexes": [],
3161 "pyproject_toml": {
3162 "project": {
3163 "name": "albatross",
3164 "version": "0.1.0",
3165 "requires-python": ">=3.12",
3166 "dependencies": [
3167 "tqdm>=4,<5"
3168 ],
3169 "optional-dependencies": null
3170 },
3171 "tool": {
3172 "uv": {
3173 "sources": null,
3174 "index": null,
3175 "workspace": {
3176 "members": [
3177 "packages/seeds",
3178 "packages/bird-feeder"
3179 ],
3180 "exclude": [
3181 "packages"
3182 ]
3183 },
3184 "managed": null,
3185 "package": null,
3186 "default-groups": null,
3187 "dependency-groups": null,
3188 "dev-dependencies": null,
3189 "override-dependencies": null,
3190 "exclude-dependencies": null,
3191 "constraint-dependencies": null,
3192 "build-constraint-dependencies": null,
3193 "environments": null,
3194 "required-environments": null,
3195 "conflicts": null,
3196 "build-backend": null
3197 }
3198 },
3199 "dependency-groups": null
3200 }
3201 }
3202 }
3203 "#);
3204 });
3205
3206 root.child("pyproject.toml").write_str(
3208 r#"
3209 [project]
3210 name = "albatross"
3211 version = "0.1.0"
3212 requires-python = ">=3.12"
3213 dependencies = ["tqdm>=4,<5"]
3214
3215 [tool.uv.workspace]
3216 members = ["packages/seeds", "packages/bird-feeder"]
3217 exclude = ["packages/*"]
3218
3219 [build-system]
3220 requires = ["hatchling"]
3221 build-backend = "hatchling.build"
3222 "#,
3223 )?;
3224
3225 let (project, root_escaped) = temporary_test(root.as_ref()).await.unwrap();
3227 let filters = vec![(root_escaped.as_str(), "[ROOT]")];
3228 insta::with_settings!({filters => filters}, {
3229 assert_json_snapshot!(
3230 project,
3231 {
3232 ".workspace.packages.*.pyproject_toml" => "[PYPROJECT_TOML]"
3233 },
3234 @r#"
3235 {
3236 "project_root": "[ROOT]",
3237 "project_name": "albatross",
3238 "workspace": {
3239 "install_path": "[ROOT]",
3240 "packages": {
3241 "albatross": {
3242 "root": "[ROOT]",
3243 "project": {
3244 "name": "albatross",
3245 "version": "0.1.0",
3246 "requires-python": ">=3.12",
3247 "dependencies": [
3248 "tqdm>=4,<5"
3249 ],
3250 "optional-dependencies": null
3251 },
3252 "pyproject_toml": "[PYPROJECT_TOML]"
3253 }
3254 },
3255 "required_members": {},
3256 "sources": {},
3257 "indexes": [],
3258 "pyproject_toml": {
3259 "project": {
3260 "name": "albatross",
3261 "version": "0.1.0",
3262 "requires-python": ">=3.12",
3263 "dependencies": [
3264 "tqdm>=4,<5"
3265 ],
3266 "optional-dependencies": null
3267 },
3268 "tool": {
3269 "uv": {
3270 "sources": null,
3271 "index": null,
3272 "workspace": {
3273 "members": [
3274 "packages/seeds",
3275 "packages/bird-feeder"
3276 ],
3277 "exclude": [
3278 "packages/*"
3279 ]
3280 },
3281 "managed": null,
3282 "package": null,
3283 "default-groups": null,
3284 "dependency-groups": null,
3285 "dev-dependencies": null,
3286 "override-dependencies": null,
3287 "exclude-dependencies": null,
3288 "constraint-dependencies": null,
3289 "build-constraint-dependencies": null,
3290 "environments": null,
3291 "required-environments": null,
3292 "conflicts": null,
3293 "build-backend": null
3294 }
3295 },
3296 "dependency-groups": null
3297 }
3298 }
3299 }
3300 "#);
3301 });
3302
3303 Ok(())
3304 }
3305
3306 #[test]
3307 fn read_dependency_groups() {
3308 let toml = r#"
3309[dependency-groups]
3310foo = ["a", {include-group = "bar"}]
3311bar = ["b"]
3312future = [{include-group = "bar", unknown = "value"}]
3313"#;
3314
3315 let result = PyProjectToml::from_string(toml.to_string(), "pyproject.toml")
3316 .expect("Deserialization should succeed");
3317
3318 let groups = result
3319 .dependency_groups
3320 .expect("`dependency-groups` should be present");
3321 let foo = groups
3322 .get(&GroupName::from_str("foo").unwrap())
3323 .expect("Group `foo` should be present");
3324 assert_eq!(
3325 foo,
3326 &[
3327 DependencyGroupSpecifier::Requirement("a".to_string()),
3328 DependencyGroupSpecifier::IncludeGroup {
3329 include_group: GroupName::from_str("bar").unwrap(),
3330 }
3331 ]
3332 );
3333
3334 let bar = groups
3335 .get(&GroupName::from_str("bar").unwrap())
3336 .expect("Group `bar` should be present");
3337 assert_eq!(
3338 bar,
3339 &[DependencyGroupSpecifier::Requirement("b".to_string())]
3340 );
3341
3342 let future = groups
3343 .get(&GroupName::from_str("future").unwrap())
3344 .expect("Group `future` should be present");
3345 assert_eq!(
3346 future,
3347 &[DependencyGroupSpecifier::Object(BTreeMap::from([
3348 ("include-group".to_string(), "bar".to_string()),
3349 ("unknown".to_string(), "value".to_string()),
3350 ]))]
3351 );
3352 }
3353
3354 #[test]
3355 fn reject_colliding_optional_dependency_names() {
3356 let err = PyProjectToml::from_string(
3357 r#"
3358[project]
3359name = "example"
3360version = "1.0.0"
3361
3362[project.optional-dependencies]
3363foo-bar = ["anyio"]
3364foo_bar = ["iniconfig"]
3365"#
3366 .to_string(),
3367 "pyproject.toml",
3368 )
3369 .unwrap_err();
3370
3371 assert_snapshot!(err.to_string(), @r#"
3372 TOML parse error at line 6, column 1
3373 |
3374 6 | [project.optional-dependencies]
3375 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
3376 duplicate normalized extra name `foo-bar`
3377 "#);
3378 }
3379
3380 #[tokio::test]
3381 async fn nested_workspace() -> Result<()> {
3382 let root = tempfile::TempDir::new()?;
3383 let root = ChildPath::new(root.path());
3384
3385 root.child("pyproject.toml").write_str(
3387 r#"
3388 [project]
3389 name = "albatross"
3390 version = "0.1.0"
3391 requires-python = ">=3.12"
3392 dependencies = ["tqdm>=4,<5"]
3393
3394 [tool.uv.workspace]
3395 members = ["packages/*"]
3396 "#,
3397 )?;
3398
3399 root.child("packages")
3401 .child("seeds")
3402 .child("pyproject.toml")
3403 .write_str(
3404 r#"
3405 [project]
3406 name = "seeds"
3407 version = "1.0.0"
3408 requires-python = ">=3.12"
3409 dependencies = ["idna==3.6"]
3410
3411 [tool.uv.workspace]
3412 members = ["nested_packages/*"]
3413 "#,
3414 )?;
3415
3416 let (error, root_escaped) = temporary_test(root.as_ref()).await.unwrap_err();
3417 let filters = vec![(root_escaped.as_str(), "[ROOT]")];
3418 insta::with_settings!({filters => filters}, {
3419 assert_snapshot!(
3420 error,
3421 @"Nested workspaces are not supported, but workspace member has a `tool.uv.workspace` table: [ROOT]/packages/seeds");
3422 });
3423
3424 Ok(())
3425 }
3426
3427 #[tokio::test]
3428 async fn duplicate_names() -> Result<()> {
3429 let root = tempfile::TempDir::new()?;
3430 let root = ChildPath::new(root.path());
3431
3432 root.child("pyproject.toml").write_str(
3434 r#"
3435 [project]
3436 name = "albatross"
3437 version = "0.1.0"
3438 requires-python = ">=3.12"
3439 dependencies = ["tqdm>=4,<5"]
3440
3441 [tool.uv.workspace]
3442 members = ["packages/*"]
3443 "#,
3444 )?;
3445
3446 root.child("packages")
3448 .child("seeds")
3449 .child("pyproject.toml")
3450 .write_str(
3451 r#"
3452 [project]
3453 name = "seeds"
3454 version = "1.0.0"
3455 requires-python = ">=3.12"
3456 dependencies = ["idna==3.6"]
3457
3458 [tool.uv.workspace]
3459 members = ["nested_packages/*"]
3460 "#,
3461 )?;
3462
3463 root.child("packages")
3465 .child("seeds2")
3466 .child("pyproject.toml")
3467 .write_str(
3468 r#"
3469 [project]
3470 name = "seeds"
3471 version = "1.0.0"
3472 requires-python = ">=3.12"
3473 dependencies = ["idna==3.6"]
3474
3475 [tool.uv.workspace]
3476 members = ["nested_packages/*"]
3477 "#,
3478 )?;
3479
3480 let (error, root_escaped) = temporary_test(root.as_ref()).await.unwrap_err();
3481 let filters = vec![(root_escaped.as_str(), "[ROOT]")];
3482 insta::with_settings!({filters => filters}, {
3483 assert_snapshot!(
3484 error,
3485 @"Two workspace members are both named `seeds`: `[ROOT]/packages/seeds` and `[ROOT]/packages/seeds2`");
3486 });
3487
3488 Ok(())
3489 }
3490}