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