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)]
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 { editable, .. } = &source else {
629 continue;
630 };
631 let existing = required_members.insert(package.clone(), *editable);
632 if let Some(Some(existing)) = existing {
633 if let Some(editable) = editable {
634 if existing != *editable {
636 return Err(WorkspaceError::from(
637 WorkspaceErrorKind::EditableConflict(package.clone()),
638 ));
639 }
640 }
641 }
642 }
643 }
644
645 Ok(required_members)
646 }
647
648 fn is_required_member(&self, name: &PackageName) -> bool {
650 self.required_members().contains_key(name)
651 }
652
653 pub fn group_requirements(&self) -> impl Iterator<Item = Requirement> + '_ {
655 self.packages.iter().filter_map(|(name, member)| {
656 let url = VerbatimUrl::from_absolute_path(&member.root).expect("path is valid URL");
657
658 let groups = {
659 let mut groups = member
660 .pyproject_toml
661 .dependency_groups
662 .as_ref()
663 .map(|groups| groups.keys().cloned().collect::<Vec<_>>())
664 .unwrap_or_default();
665 if member
666 .pyproject_toml
667 .tool
668 .as_ref()
669 .and_then(|tool| tool.uv.as_ref())
670 .and_then(|uv| uv.dev_dependencies.as_ref())
671 .is_some()
672 {
673 groups.push(DEV_DEPENDENCIES.clone());
674 groups.sort_unstable();
675 }
676 groups
677 };
678 if groups.is_empty() {
679 return None;
680 }
681
682 let value = self.required_members.get(name);
683 let is_required_member = value.is_some();
684 let editability = value.copied().flatten();
685
686 Some(Requirement {
687 name: member.pyproject_toml.project.as_ref()?.name.clone(),
688 extras: Box::new([]),
689 groups: groups.into_boxed_slice(),
690 marker: MarkerTree::TRUE,
691 source: if member.pyproject_toml().is_package(!is_required_member) {
692 RequirementSource::Directory {
693 install_path: member.root.clone().into_boxed_path(),
694 editable: Some(editability.unwrap_or(true)),
695 r#virtual: Some(false),
696 url,
697 }
698 } else {
699 RequirementSource::Directory {
700 install_path: member.root.clone().into_boxed_path(),
701 editable: Some(false),
702 r#virtual: Some(true),
703 url,
704 }
705 },
706 origin: None,
707 })
708 })
709 }
710
711 pub fn environments(&self) -> Option<&SupportedEnvironments> {
713 self.pyproject_toml
714 .tool
715 .as_ref()
716 .and_then(|tool| tool.uv.as_ref())
717 .and_then(|uv| uv.environments.as_ref())
718 }
719
720 pub fn required_environments(&self) -> Option<&SupportedEnvironments> {
722 self.pyproject_toml
723 .tool
724 .as_ref()
725 .and_then(|tool| tool.uv.as_ref())
726 .and_then(|uv| uv.required_environments.as_ref())
727 }
728
729 pub fn conflicts(&self) -> Result<Conflicts, WorkspaceError> {
731 let mut conflicting = Conflicts::empty();
732 if self.is_non_project()
733 && let Some(root_conflicts) = self
734 .pyproject_toml
735 .tool
736 .as_ref()
737 .and_then(|tool| tool.uv.as_ref())
738 .and_then(|uv| uv.conflicts.as_ref())
739 {
740 let mut root_conflicts = root_conflicts.to_conflicts()?;
741 conflicting.append(&mut root_conflicts);
742 }
743 for member in self.packages.values() {
744 conflicting.append(&mut member.pyproject_toml.conflicts()?);
745 }
746 Ok(conflicting)
747 }
748
749 pub fn requires_python(
751 &self,
752 groups: &DependencyGroupsWithDefaults,
753 ) -> Result<RequiresPythonSources, DependencyGroupError> {
754 let mut requires = RequiresPythonSources::new();
755 for (name, member) in self.packages() {
756 let top_requires = member
762 .pyproject_toml()
763 .project
764 .as_ref()
765 .and_then(|project| project.requires_python.as_ref())
766 .map(|requires_python| ((name.to_owned(), None), requires_python.clone()));
767 requires.extend(top_requires);
768
769 let dependency_groups =
772 FlatDependencyGroups::from_pyproject_toml(member.root(), &member.pyproject_toml)?;
773 let group_requires =
774 dependency_groups
775 .into_iter()
776 .filter_map(move |(group_name, flat_group)| {
777 if groups.contains(&group_name) {
778 flat_group.requires_python.map(|requires_python| {
779 ((name.to_owned(), Some(group_name)), requires_python)
780 })
781 } else {
782 None
783 }
784 });
785 requires.extend(group_requires);
786 }
787 Ok(requires)
788 }
789
790 pub fn requirements(&self) -> Vec<uv_pep508::Requirement<VerbatimParsedUrl>> {
795 Vec::new()
796 }
797
798 pub fn workspace_dependency_groups(
806 &self,
807 ) -> Result<BTreeMap<GroupName, FlatDependencyGroup>, DependencyGroupError> {
808 if self
809 .packages
810 .values()
811 .any(|member| *member.root() == self.install_path)
812 {
813 Ok(BTreeMap::default())
816 } else {
817 let dependency_groups = FlatDependencyGroups::from_pyproject_toml(
819 &self.install_path,
820 &self.pyproject_toml,
821 )?;
822 Ok(dependency_groups.into_inner())
823 }
824 }
825
826 pub fn overrides(&self) -> Vec<OverrideDependency> {
828 let Some(overrides) = self
829 .pyproject_toml
830 .tool
831 .as_ref()
832 .and_then(|tool| tool.uv.as_ref())
833 .and_then(|uv| uv.override_dependencies.as_ref())
834 else {
835 return vec![];
836 };
837 overrides.clone()
838 }
839
840 pub fn exclude_dependencies(&self) -> Vec<ExcludeDependency> {
842 let Some(excludes) = self
843 .pyproject_toml
844 .tool
845 .as_ref()
846 .and_then(|tool| tool.uv.as_ref())
847 .and_then(|uv| uv.exclude_dependencies.as_ref())
848 else {
849 return vec![];
850 };
851 excludes.clone()
852 }
853
854 pub fn constraints(&self) -> Vec<uv_pep508::Requirement<VerbatimParsedUrl>> {
856 let Some(constraints) = self
857 .pyproject_toml
858 .tool
859 .as_ref()
860 .and_then(|tool| tool.uv.as_ref())
861 .and_then(|uv| uv.constraint_dependencies.as_ref())
862 else {
863 return vec![];
864 };
865 constraints.clone()
866 }
867
868 pub fn build_constraints(&self) -> Vec<uv_pep508::Requirement<VerbatimParsedUrl>> {
870 let Some(build_constraints) = self
871 .pyproject_toml
872 .tool
873 .as_ref()
874 .and_then(|tool| tool.uv.as_ref())
875 .and_then(|uv| uv.build_constraint_dependencies.as_ref())
876 else {
877 return vec![];
878 };
879 build_constraints.clone()
880 }
881
882 pub fn install_path(&self) -> &PathBuf {
885 &self.install_path
886 }
887
888 pub fn environment_selection(&self, active: Option<bool>) -> ProjectEnvironmentSelection {
897 fn from_project_environment_variable(workspace: &Workspace) -> Option<PathBuf> {
899 let value = std::env::var_os(EnvVars::UV_PROJECT_ENVIRONMENT)?;
900
901 if value.is_empty() {
902 return None;
903 }
904
905 let path = PathBuf::from(value);
906 if path.is_absolute() {
907 return Some(path);
908 }
909
910 Some(workspace.install_path.join(path))
912 }
913
914 fn from_virtual_env_variable() -> Option<PathBuf> {
916 let value = std::env::var_os(EnvVars::VIRTUAL_ENV)?;
917
918 if value.is_empty() {
919 return None;
920 }
921
922 let path = PathBuf::from(value);
923 if path.is_absolute() {
924 return Some(path);
925 }
926
927 Some(CWD.join(path))
930 }
931
932 let selection = from_project_environment_variable(self)
933 .map(ProjectEnvironmentSelection::Override)
934 .unwrap_or(ProjectEnvironmentSelection::Default);
935 let project_environment_path = selection
936 .explicit_path()
937 .map_or_else(|| self.install_path.join(".venv"), Path::to_path_buf);
938
939 if let Some(from_virtual_env) = from_virtual_env_variable() {
941 let matches_project =
942 uv_fs::is_same_file_allow_missing(&from_virtual_env, &project_environment_path)
943 .unwrap_or(false);
944 match active {
945 Some(true) => {
946 if !matches_project {
947 debug!(
948 "Using active virtual environment `{}` instead of project environment `{}`",
949 from_virtual_env.user_display(),
950 project_environment_path.user_display()
951 );
952 }
953 return ProjectEnvironmentSelection::Active(from_virtual_env);
954 }
955 Some(false) => {}
956 None if !matches_project => {
957 warn_user_once!(
958 "`VIRTUAL_ENV={}` does not match the project environment path `{}` and will be ignored; use `--active` to target the active environment instead",
959 from_virtual_env.user_display(),
960 project_environment_path.user_display()
961 );
962 }
963 None => {}
964 }
965 } else {
966 if active.unwrap_or_default() {
967 debug!(
968 "Use of the active virtual environment was requested, but `VIRTUAL_ENV` is not set"
969 );
970 }
971 }
972
973 selection
974 }
975
976 pub fn packages(&self) -> &BTreeMap<PackageName, WorkspaceMember> {
978 &self.packages
979 }
980
981 pub fn sources(&self) -> &BTreeMap<PackageName, Sources> {
983 &self.sources
984 }
985
986 pub fn indexes(&self) -> &[Index] {
988 &self.indexes
989 }
990
991 pub fn pyproject_toml(&self) -> &PyProjectToml {
993 &self.pyproject_toml
994 }
995
996 pub fn excludes(&self, project_path: &Path) -> Result<bool, WorkspaceError> {
998 if let Some(workspace) = self
999 .pyproject_toml
1000 .tool
1001 .as_ref()
1002 .and_then(|tool| tool.uv.as_ref())
1003 .and_then(|uv| uv.workspace.as_ref())
1004 {
1005 is_excluded_from_workspace(project_path, &self.install_path, workspace)
1006 } else {
1007 Ok(false)
1008 }
1009 }
1010
1011 pub fn includes(&self, project_path: &Path) -> Result<bool, WorkspaceError> {
1013 if let Some(workspace) = self
1014 .pyproject_toml
1015 .tool
1016 .as_ref()
1017 .and_then(|tool| tool.uv.as_ref())
1018 .and_then(|uv| uv.workspace.as_ref())
1019 {
1020 is_included_in_workspace(project_path, &self.install_path, workspace)
1021 } else {
1022 Ok(false)
1023 }
1024 }
1025
1026 async fn build(
1028 workspace_root: PathBuf,
1029 workspace_definition: ToolUvWorkspace,
1030 workspace_pyproject_toml: PyProjectToml,
1031 current_project: Option<WorkspaceMember>,
1032 options: &DiscoveryOptions,
1033 cache: &Cache,
1034 ) -> Result<Arc<Self>, WorkspaceError> {
1035 trace!(
1036 "Discovering workspace members for: `{}`",
1037 &workspace_root.simplified_display()
1038 );
1039 let workspace_members = Self::collect_members_only(
1040 &workspace_root,
1041 &workspace_definition,
1042 &workspace_pyproject_toml,
1043 options,
1044 cache,
1045 )
1046 .await?;
1047 let mut workspace_members = Arc::new(workspace_members);
1048
1049 if let Some(root_member) = current_project
1051 && !workspace_members.contains_key(&root_member.project.name)
1052 {
1053 assert!(matches!(
1054 options.members,
1055 MemberDiscovery::None | MemberDiscovery::Ignore(_)
1056 ));
1057 debug!(
1058 "Adding current workspace member: `{}`",
1059 root_member.root.simplified_display()
1060 );
1061
1062 Arc::make_mut(&mut workspace_members)
1063 .insert(root_member.project.name.clone(), root_member);
1064 }
1065
1066 let workspace_sources = workspace_pyproject_toml
1067 .tool
1068 .clone()
1069 .and_then(|tool| tool.uv)
1070 .and_then(|uv| uv.sources)
1071 .map(ToolUvSources::into_inner)
1072 .unwrap_or_default();
1073
1074 let workspace_indexes = workspace_pyproject_toml
1075 .tool
1076 .clone()
1077 .and_then(|tool| tool.uv)
1078 .and_then(|uv| uv.index)
1079 .unwrap_or_default();
1080
1081 let required_members = Self::collect_required_members(
1082 &workspace_members,
1083 &workspace_sources,
1084 &workspace_pyproject_toml,
1085 )?;
1086
1087 let dev_dependencies_members = workspace_members
1088 .values()
1089 .filter_map(|member| {
1090 member
1091 .pyproject_toml
1092 .tool
1093 .as_ref()
1094 .and_then(|tool| tool.uv.as_ref())
1095 .and_then(|uv| uv.dev_dependencies.as_ref())
1096 .map(|_| format!("`{}`", member.root().join("pyproject.toml").user_display()))
1097 })
1098 .join(", ");
1099 if !dev_dependencies_members.is_empty() {
1100 warn_user_once!(
1101 "The `tool.uv.dev-dependencies` field (used in {}) is deprecated and will be removed in a future release; use `dependency-groups.dev` instead",
1102 dev_dependencies_members
1103 );
1104 }
1105
1106 let workspace = Self {
1107 install_path: workspace_root,
1108 packages: workspace_members,
1109 required_members,
1110 sources: workspace_sources,
1111 indexes: workspace_indexes,
1112 pyproject_toml: workspace_pyproject_toml,
1113 };
1114 Ok(Arc::new(workspace))
1115 }
1116
1117 async fn collect_members_only(
1118 workspace_root: &PathBuf,
1119 workspace_definition: &ToolUvWorkspace,
1120 workspace_pyproject_toml: &PyProjectToml,
1121 options: &DiscoveryOptions,
1122 cache: &Cache,
1123 ) -> Result<BTreeMap<PackageName, WorkspaceMember>, WorkspaceError> {
1124 let mut workspace_members = BTreeMap::new();
1125 let mut seen = FxHashSet::default();
1127
1128 let external_cache_root = options
1129 .stop_discovery_at
1130 .is_none()
1131 .then(|| {
1132 let cache_root = if cache.root().is_absolute() {
1134 cache.root().to_path_buf()
1135 } else {
1136 CWD.join(cache.root())
1137 };
1138 normalize_path(&cache_root).into_owned()
1139 })
1140 .filter(|cache_root| !workspace_root.starts_with(cache_root));
1141
1142 if let Some(project) = &workspace_pyproject_toml.project {
1145 debug!(
1146 "Adding root workspace member: `{}`",
1147 workspace_root.simplified_display()
1148 );
1149
1150 seen.insert(workspace_root.clone());
1151 workspace_members.insert(
1152 project.name.clone(),
1153 WorkspaceMember {
1154 root: workspace_root.clone(),
1155 project: project.clone(),
1156 pyproject_toml: workspace_pyproject_toml.clone(),
1157 },
1158 );
1159 }
1160
1161 for member_glob in workspace_definition.clone().members.unwrap_or_default() {
1163 let normalized_glob = normalize_path(Path::new(member_glob.as_str()));
1165 let absolute_glob = PathBuf::from(glob::Pattern::escape(
1166 workspace_root.simplified().to_string_lossy().as_ref(),
1167 ))
1168 .join(normalized_glob.as_ref())
1169 .to_string_lossy()
1170 .to_string();
1171 for member_root in glob(&absolute_glob)
1172 .map_err(|err| WorkspaceErrorKind::Pattern(absolute_glob.clone(), err))?
1173 {
1174 let member_root = member_root
1175 .map_err(|err| WorkspaceErrorKind::GlobWalk(absolute_glob.clone(), err))?;
1176 if external_cache_root
1177 .as_ref()
1178 .is_some_and(|cache_root| member_root.starts_with(cache_root))
1179 {
1180 debug!(
1181 "Ignoring cache directory while discovering workspace members: `{}`",
1182 member_root.simplified_display()
1183 );
1184 continue;
1185 }
1186 if !seen.insert(member_root.clone()) {
1187 continue;
1188 }
1189 let member_root = std::path::absolute(&member_root)
1190 .map_err(WorkspaceErrorKind::Normalize)?
1191 .clone();
1192
1193 let skip = match &options.members {
1195 MemberDiscovery::All | MemberDiscovery::Existing => false,
1196 MemberDiscovery::None => true,
1197 MemberDiscovery::Ignore(ignore) => ignore.contains(member_root.as_path()),
1198 };
1199 if skip {
1200 debug!(
1201 "Ignoring workspace member: `{}`",
1202 member_root.simplified_display()
1203 );
1204 continue;
1205 }
1206
1207 if is_excluded_from_workspace(&member_root, workspace_root, workspace_definition)? {
1209 debug!(
1210 "Ignoring workspace member: `{}`",
1211 member_root.simplified_display()
1212 );
1213 continue;
1214 }
1215
1216 trace!(
1217 "Processing workspace member: `{}`",
1218 member_root.user_display()
1219 );
1220
1221 let pyproject_path = member_root.join("pyproject.toml");
1223 let contents = match fs_err::tokio::read_to_string(&pyproject_path).await {
1224 Ok(contents) => contents,
1225 Err(err) => {
1226 let metadata = match fs_err::metadata(&member_root) {
1227 Ok(metadata) => metadata,
1228 Err(err)
1229 if matches!(options.members, MemberDiscovery::Existing)
1230 && err.kind() == std::io::ErrorKind::NotFound =>
1231 {
1232 debug!(
1233 "Ignoring missing workspace member: `{}`",
1234 member_root.simplified_display()
1235 );
1236 continue;
1237 }
1238 Err(err) => return Err(err.into()),
1239 };
1240 if !metadata.is_dir() {
1241 warn!(
1242 "Ignoring non-directory workspace member: `{}`",
1243 member_root.simplified_display()
1244 );
1245 continue;
1246 }
1247
1248 if err.kind() == std::io::ErrorKind::NotFound {
1250 if member_root
1252 .file_name()
1253 .is_some_and(|name| name.as_encoded_bytes().starts_with(b"."))
1254 {
1255 debug!(
1256 "Ignoring hidden workspace member: `{}`",
1257 member_root.simplified_display()
1258 );
1259 continue;
1260 }
1261
1262 if has_only_gitignored_files(&member_root) {
1265 debug!(
1266 "Ignoring workspace member with only gitignored files: `{}`",
1267 member_root.simplified_display()
1268 );
1269 continue;
1270 }
1271
1272 if matches!(options.members, MemberDiscovery::Existing) {
1273 debug!(
1274 "Ignoring missing workspace member: `{}`",
1275 member_root.simplified_display()
1276 );
1277 continue;
1278 }
1279
1280 return Err(WorkspaceError::from(
1281 WorkspaceErrorKind::MissingPyprojectTomlMember(
1282 member_root,
1283 member_glob.to_string(),
1284 ),
1285 ));
1286 }
1287
1288 return Err(err.into());
1289 }
1290 };
1291 let pyproject_toml = PyProjectToml::from_string(contents, &pyproject_path)
1292 .map_err(|err| {
1293 WorkspaceErrorKind::Toml(pyproject_path.clone(), Box::new(err))
1294 })?;
1295
1296 if pyproject_toml
1298 .tool
1299 .as_ref()
1300 .and_then(|tool| tool.uv.as_ref())
1301 .and_then(|uv| uv.managed)
1302 == Some(false)
1303 {
1304 if let Some(project) = pyproject_toml.project.as_ref() {
1305 debug!(
1306 "Project `{}` is marked as unmanaged; omitting from workspace members",
1307 project.name
1308 );
1309 } else {
1310 debug!(
1311 "Workspace member at `{}` is marked as unmanaged; omitting from workspace members",
1312 member_root.simplified_display()
1313 );
1314 }
1315 continue;
1316 }
1317
1318 let Some(project) = pyproject_toml.project.clone() else {
1320 return Err(WorkspaceError::from(WorkspaceErrorKind::MissingProject(
1321 pyproject_path,
1322 )));
1323 };
1324
1325 debug!(
1326 "Adding discovered workspace member: `{}`",
1327 member_root.simplified_display()
1328 );
1329
1330 if let Some(existing) = workspace_members.insert(
1331 project.name.clone(),
1332 WorkspaceMember {
1333 root: member_root.clone(),
1334 project,
1335 pyproject_toml,
1336 },
1337 ) {
1338 return Err(WorkspaceError::from(WorkspaceErrorKind::DuplicatePackage {
1339 name: existing.project.name,
1340 first: existing.root.clone(),
1341 second: member_root,
1342 }));
1343 }
1344 }
1345 }
1346
1347 for member in workspace_members.values() {
1349 if member.root() != workspace_root
1350 && member
1351 .pyproject_toml
1352 .tool
1353 .as_ref()
1354 .and_then(|tool| tool.uv.as_ref())
1355 .and_then(|uv| uv.workspace.as_ref())
1356 .is_some()
1357 {
1358 return Err(WorkspaceError::from(WorkspaceErrorKind::NestedWorkspace(
1359 member.root.clone(),
1360 )));
1361 }
1362 }
1363 Ok(workspace_members)
1364 }
1365}
1366
1367#[derive(Debug, Clone, PartialEq)]
1369#[cfg_attr(test, derive(serde::Serialize))]
1370pub struct WorkspaceMember {
1371 root: PathBuf,
1373 project: Project,
1376 pyproject_toml: PyProjectToml,
1378}
1379
1380impl WorkspaceMember {
1381 pub fn root(&self) -> &PathBuf {
1383 &self.root
1384 }
1385
1386 pub fn project(&self) -> &Project {
1389 &self.project
1390 }
1391
1392 pub fn pyproject_toml(&self) -> &PyProjectToml {
1394 &self.pyproject_toml
1395 }
1396}
1397
1398#[derive(Debug, Clone)]
1476#[cfg_attr(test, derive(serde::Serialize))]
1477pub struct ProjectWorkspace {
1478 project_root: PathBuf,
1480 project_name: PackageName,
1482 workspace: Arc<Workspace>,
1484}
1485
1486impl ProjectWorkspace {
1487 fn from_cache(
1488 project_root: &Path,
1489 options: &DiscoveryOptions,
1490 cache: &WorkspaceCache,
1491 ) -> Result<Option<Self>, WorkspaceError> {
1492 let workspace = match cache.get(project_root, &options.members) {
1493 Some(Ok(workspace)) => workspace,
1494 Some(Err(error)) => return Err(error),
1495 None => return Ok(None),
1496 };
1497 let Some((project_name, _member)) = workspace
1498 .packages
1499 .iter()
1500 .find(|(_project_name, member)| member.root() == project_root)
1501 else {
1502 return Ok(None);
1503 };
1504
1505 Ok(Some(Self {
1506 project_root: project_root.to_path_buf(),
1507 project_name: project_name.clone(),
1508 workspace,
1509 }))
1510 }
1511
1512 pub async fn discover(
1517 path: &Path,
1518 options: &DiscoveryOptions,
1519 cache: &Cache,
1520 workspace_cache: &WorkspaceCache,
1521 ) -> Result<Self, WorkspaceError> {
1522 assert!(
1523 path.is_absolute(),
1524 "project workspace discovery with relative path"
1525 );
1526 let project_root = path
1527 .ancestors()
1528 .take_while(|path| {
1529 options
1531 .stop_discovery_at
1532 .as_deref()
1533 .and_then(Path::parent)
1534 .is_none_or(|stop_discovery_at| stop_discovery_at != *path)
1535 })
1536 .find(|path| path.join("pyproject.toml").is_file())
1537 .ok_or_else(|| WorkspaceErrorKind::MissingPyprojectToml)?;
1538
1539 debug!(
1540 "Found project root: `{}`",
1541 project_root.simplified_display()
1542 );
1543
1544 Self::from_project_root(project_root, options, cache, workspace_cache).await
1545 }
1546
1547 async fn from_project_root(
1549 project_root: &Path,
1550 options: &DiscoveryOptions,
1551 cache: &Cache,
1552 workspace_cache: &WorkspaceCache,
1553 ) -> Result<Self, WorkspaceError> {
1554 if let Some(project) = Self::from_cache(project_root, options, workspace_cache)? {
1555 return Ok(project);
1556 }
1557
1558 let pyproject_path = project_root.join("pyproject.toml");
1560
1561 let contents = fs_err::tokio::read_to_string(&pyproject_path).await?;
1562 let pyproject_toml = PyProjectToml::from_string(contents, &pyproject_path)
1563 .map_err(|err| WorkspaceErrorKind::Toml(pyproject_path.clone(), Box::new(err)))?;
1564
1565 let project = pyproject_toml
1567 .project
1568 .clone()
1569 .ok_or_else(|| WorkspaceErrorKind::MissingProject(pyproject_path))?;
1570
1571 Self::from_project(
1572 project_root,
1573 &project,
1574 &pyproject_toml,
1575 options,
1576 cache,
1577 workspace_cache,
1578 )
1579 .await
1580 }
1581
1582 pub async fn from_maybe_project_root(
1585 project_root: &Path,
1586 options: &DiscoveryOptions,
1587 cache: &Cache,
1588 workspace_cache: &WorkspaceCache,
1589 ) -> Result<Option<Self>, WorkspaceError> {
1590 if let Some(project) = Self::from_cache(project_root, options, workspace_cache)? {
1591 return Ok(Some(project));
1592 }
1593
1594 let pyproject_path = project_root.join("pyproject.toml");
1596 let Ok(contents) = fs_err::tokio::read_to_string(&pyproject_path).await else {
1597 return Ok(None);
1599 };
1600 let pyproject_toml = PyProjectToml::from_string(contents, &pyproject_path)
1601 .map_err(|err| WorkspaceErrorKind::Toml(pyproject_path.clone(), Box::new(err)))?;
1602
1603 let Some(project) = pyproject_toml.project.clone() else {
1605 return Ok(None);
1607 };
1608
1609 match Self::from_project(
1610 project_root,
1611 &project,
1612 &pyproject_toml,
1613 options,
1614 cache,
1615 workspace_cache,
1616 )
1617 .await
1618 {
1619 Ok(workspace) => Ok(Some(workspace)),
1620 Err(error) if matches!(error.as_ref(), WorkspaceErrorKind::NonWorkspace(_)) => Ok(None),
1621 Err(err) => Err(err),
1622 }
1623 }
1624
1625 pub fn project_root(&self) -> &Path {
1628 &self.project_root
1629 }
1630
1631 pub fn project_name(&self) -> &PackageName {
1633 &self.project_name
1634 }
1635
1636 pub fn workspace(&self) -> &Workspace {
1638 &self.workspace
1639 }
1640
1641 pub fn current_project(&self) -> &WorkspaceMember {
1643 &self.workspace().packages[&self.project_name]
1644 }
1645
1646 fn update_member(self, pyproject_toml: PyProjectToml) -> Result<Option<Self>, WorkspaceError> {
1650 let Some(workspace) =
1651 Workspace::update_member(self.workspace, &self.project_name, pyproject_toml)?
1652 else {
1653 return Ok(None);
1654 };
1655 Ok(Some(Self { workspace, ..self }))
1656 }
1657
1658 async fn from_project(
1660 install_path: &Path,
1661 project: &Project,
1662 project_pyproject_toml: &PyProjectToml,
1663 options: &DiscoveryOptions,
1664 cache: &Cache,
1665 workspace_cache: &WorkspaceCache,
1666 ) -> Result<Self, WorkspaceError> {
1667 let project_path = std::path::absolute(install_path)
1668 .map_err(WorkspaceErrorKind::Normalize)?
1669 .clone();
1670 let project_path = normalize_path(&project_path);
1671
1672 if project_pyproject_toml
1674 .tool
1675 .as_ref()
1676 .and_then(|tool| tool.uv.as_ref())
1677 .and_then(|uv| uv.managed)
1678 == Some(false)
1679 {
1680 debug!("Project `{}` is marked as unmanaged", project.name);
1681 return Err(WorkspaceError::from(WorkspaceErrorKind::NonWorkspace(
1682 project_path.to_path_buf(),
1683 )));
1684 }
1685
1686 if let Some(project) = Self::from_cache(&project_path, options, workspace_cache)? {
1687 return Ok(project);
1688 }
1689
1690 let mut workspace = project_pyproject_toml
1692 .tool
1693 .as_ref()
1694 .and_then(|tool| tool.uv.as_ref())
1695 .and_then(|uv| uv.workspace.as_ref())
1696 .map(|workspace| {
1697 (
1698 project_path.to_path_buf(),
1699 workspace.clone(),
1700 project_pyproject_toml.clone(),
1701 )
1702 });
1703
1704 if workspace.is_none() {
1705 workspace = find_workspace(&project_path, options, cache).await?;
1708 }
1709
1710 let current_project = WorkspaceMember {
1711 root: project_path.to_path_buf(),
1712 project: project.clone(),
1713 pyproject_toml: project_pyproject_toml.clone(),
1714 };
1715
1716 let Some((workspace_root, workspace_definition, workspace_pyproject_toml)) = workspace
1717 else {
1718 debug!("No workspace root found, using project root");
1721
1722 let current_project_as_members = Arc::new(BTreeMap::from_iter([(
1723 project.name.clone(),
1724 current_project,
1725 )]));
1726 let workspace_sources = BTreeMap::default();
1727 let required_members = Workspace::collect_required_members(
1728 ¤t_project_as_members,
1729 &workspace_sources,
1730 project_pyproject_toml,
1731 )?;
1732
1733 let workspace = Workspace {
1734 install_path: project_path.to_path_buf(),
1735 packages: current_project_as_members,
1736 required_members,
1737 sources: workspace_sources,
1740 indexes: Vec::default(),
1741 pyproject_toml: project_pyproject_toml.clone(),
1742 };
1743 let workspace = Arc::new(workspace);
1744 if options.members == MemberDiscovery::All {
1745 workspace_cache.insert(Ok(workspace.clone()), &project_path);
1746 }
1747 return Ok(Self {
1748 project_root: project_path.to_path_buf(),
1749 project_name: project.name.clone(),
1750 workspace,
1751 });
1752 };
1753
1754 if options.members == MemberDiscovery::All {
1755 if let Some(workspace) = workspace_cache.register_or_wait(&workspace_root).await {
1757 return workspace.map(|workspace| Self {
1758 project_root: project_path.to_path_buf(),
1759 project_name: project.name.clone(),
1760 workspace,
1761 });
1762 }
1763 }
1764
1765 debug!(
1766 "Found workspace root: `{}`",
1767 workspace_root.simplified_display()
1768 );
1769
1770 let result = Workspace::build(
1771 workspace_root.clone(),
1772 workspace_definition,
1773 workspace_pyproject_toml,
1774 Some(current_project),
1775 options,
1776 cache,
1777 )
1778 .await;
1779 if options.members == MemberDiscovery::All {
1780 workspace_cache.insert(result.clone(), &workspace_root);
1781 }
1782
1783 Ok(Self {
1784 project_root: project_path.to_path_buf(),
1785 project_name: project.name.clone(),
1786 workspace: result?,
1787 })
1788 }
1789}
1790
1791async fn find_workspace(
1793 project_root: &Path,
1794 options: &DiscoveryOptions,
1795 cache: &Cache,
1796) -> Result<Option<(PathBuf, ToolUvWorkspace, PyProjectToml)>, WorkspaceError> {
1797 let external_cache_root = if options.stop_discovery_at.is_none() {
1798 let cache_root = if cache.root().is_absolute() {
1800 cache.root().to_path_buf()
1801 } else {
1802 CWD.join(cache.root())
1803 };
1804 Some(normalize_path(&cache_root).into_owned())
1805 } else {
1806 None
1807 };
1808 if let Some(cache_root) = external_cache_root
1810 && project_root.starts_with(cache_root)
1811 {
1812 debug!(
1813 "Project is contained in cache directory: `{}`",
1814 project_root.simplified_display()
1815 );
1816 return Ok(None);
1817 }
1818
1819 for workspace_root in project_root
1821 .ancestors()
1822 .take_while(|path| {
1823 options
1825 .stop_discovery_at
1826 .as_deref()
1827 .and_then(Path::parent)
1828 .is_none_or(|stop_discovery_at| stop_discovery_at != *path)
1829 })
1830 .skip(1)
1831 {
1832 let pyproject_path = workspace_root.join("pyproject.toml");
1833 if !pyproject_path.is_file() {
1834 continue;
1835 }
1836 trace!(
1837 "Found `pyproject.toml` at: `{}`",
1838 pyproject_path.simplified_display()
1839 );
1840
1841 let contents = fs_err::tokio::read_to_string(&pyproject_path).await?;
1843 let pyproject_toml = PyProjectToml::from_string(contents, &pyproject_path)
1844 .map_err(|err| WorkspaceErrorKind::Toml(pyproject_path.clone(), Box::new(err)))?;
1845
1846 return if let Some(workspace) = pyproject_toml
1847 .tool
1848 .as_ref()
1849 .and_then(|tool| tool.uv.as_ref())
1850 .and_then(|uv| uv.workspace.as_ref())
1851 {
1852 if !is_included_in_workspace(project_root, workspace_root, workspace)? {
1853 debug!(
1854 "Found workspace root `{}`, but project is not included",
1855 workspace_root.simplified_display()
1856 );
1857 return Ok(None);
1858 }
1859
1860 if is_excluded_from_workspace(project_root, workspace_root, workspace)? {
1861 debug!(
1862 "Found workspace root `{}`, but project is excluded",
1863 workspace_root.simplified_display()
1864 );
1865 return Ok(None);
1866 }
1867
1868 Ok(Some((
1870 workspace_root.to_path_buf(),
1871 workspace.clone(),
1872 pyproject_toml,
1873 )))
1874 } else if pyproject_toml.project.is_some() {
1875 debug!(
1894 "Project is contained in non-workspace project: `{}`",
1895 workspace_root.simplified_display()
1896 );
1897 Ok(None)
1898 } else {
1899 warn!(
1901 "`pyproject.toml` does not contain a `project` table: `{}`",
1902 pyproject_path.simplified_display()
1903 );
1904 Ok(None)
1905 };
1906 }
1907
1908 Ok(None)
1909}
1910
1911fn has_only_gitignored_files(path: &Path) -> bool {
1916 let walker = ignore::WalkBuilder::new(path)
1917 .hidden(false)
1918 .parents(true)
1919 .ignore(true)
1920 .git_ignore(true)
1921 .git_global(true)
1922 .git_exclude(true)
1923 .build();
1924
1925 for entry in walker {
1926 let Ok(entry) = entry else {
1927 return false;
1929 };
1930
1931 if entry.path().is_dir() {
1933 continue;
1934 }
1935
1936 return false;
1938 }
1939
1940 true
1941}
1942
1943fn is_excluded_from_workspace(
1945 project_path: &Path,
1946 workspace_root: &Path,
1947 workspace: &ToolUvWorkspace,
1948) -> Result<bool, WorkspaceError> {
1949 for exclude_glob in workspace.exclude.iter().flatten() {
1950 let normalized_glob = normalize_path(Path::new(exclude_glob.as_str()));
1952 let absolute_glob = PathBuf::from(glob::Pattern::escape(
1953 workspace_root.simplified().to_string_lossy().as_ref(),
1954 ))
1955 .join(normalized_glob.as_ref());
1956 let absolute_glob = absolute_glob.to_string_lossy();
1957 let exclude_pattern = glob::Pattern::new(&absolute_glob)
1958 .map_err(|err| WorkspaceErrorKind::Pattern(absolute_glob.to_string(), err))?;
1959 if exclude_pattern.matches_path(project_path) {
1960 return Ok(true);
1961 }
1962 }
1963 Ok(false)
1964}
1965
1966fn is_included_in_workspace(
1968 project_path: &Path,
1969 workspace_root: &Path,
1970 workspace: &ToolUvWorkspace,
1971) -> Result<bool, WorkspaceError> {
1972 for member_glob in workspace.members.iter().flatten() {
1973 let normalized_glob = normalize_path(Path::new(member_glob.as_str()));
1975 let absolute_glob = PathBuf::from(glob::Pattern::escape(
1976 workspace_root.simplified().to_string_lossy().as_ref(),
1977 ))
1978 .join(normalized_glob);
1979 let absolute_glob = absolute_glob.to_string_lossy();
1980 let include_pattern = glob::Pattern::new(&absolute_glob)
1981 .map_err(|err| WorkspaceErrorKind::Pattern(absolute_glob.to_string(), err))?;
1982 if include_pattern.matches_path(project_path) {
1983 return Ok(true);
1984 }
1985 }
1986 Ok(false)
1987}
1988
1989#[derive(Debug, Clone)]
1994pub enum VirtualProject {
1995 Project(ProjectWorkspace),
1997 NonProject(Arc<Workspace>),
1999}
2000
2001impl VirtualProject {
2002 pub async fn discover(
2010 path: &Path,
2011 options: &DiscoveryOptions,
2012 cache: &Cache,
2013 workspace_cache: &WorkspaceCache,
2014 ) -> Result<Self, WorkspaceError> {
2015 assert!(
2016 path.is_absolute(),
2017 "virtual project discovery with relative path"
2018 );
2019 let project_root = path
2020 .ancestors()
2021 .take_while(|path| {
2022 options
2024 .stop_discovery_at
2025 .as_deref()
2026 .and_then(Path::parent)
2027 .is_none_or(|stop_discovery_at| stop_discovery_at != *path)
2028 })
2029 .find(|path| path.join("pyproject.toml").is_file())
2030 .ok_or(WorkspaceErrorKind::MissingPyprojectToml)?;
2031
2032 debug!(
2033 "Found project root: `{}`",
2034 project_root.simplified_display()
2035 );
2036
2037 if let Some(workspace) = workspace_cache.get(project_root, &options.members) {
2039 let workspace = workspace?;
2040 let virtual_project = if let Some((project_name, _member)) = workspace
2041 .packages
2042 .iter()
2043 .find(|(_package_name, member)| member.root == project_root)
2044 {
2045 Self::Project(ProjectWorkspace {
2046 project_root: project_root.to_path_buf(),
2047 project_name: project_name.clone(),
2048 workspace,
2049 })
2050 } else {
2051 Self::NonProject(workspace.clone())
2052 };
2053 return Ok(virtual_project);
2054 }
2055
2056 let pyproject_path = project_root.join("pyproject.toml");
2058 let contents = fs_err::tokio::read_to_string(&pyproject_path).await?;
2059 let pyproject_toml = PyProjectToml::from_string(contents, &pyproject_path)
2060 .map_err(|err| WorkspaceErrorKind::Toml(pyproject_path.clone(), Box::new(err)))?;
2061
2062 if let Some(project) = pyproject_toml.project.as_ref() {
2063 let project = ProjectWorkspace::from_project(
2065 project_root,
2066 project,
2067 &pyproject_toml,
2068 options,
2069 cache,
2070 workspace_cache,
2071 )
2072 .await?;
2073 Ok(Self::Project(project))
2074 } else if let Some(workspace) = pyproject_toml
2075 .tool
2076 .as_ref()
2077 .and_then(|tool| tool.uv.as_ref())
2078 .and_then(|uv| uv.workspace.as_ref())
2079 {
2080 let project_path = std::path::absolute(project_root)
2083 .map_err(WorkspaceErrorKind::Normalize)?
2084 .clone();
2085
2086 let result = Workspace::build(
2087 project_path.clone(),
2088 workspace.clone(),
2089 pyproject_toml,
2090 None,
2091 options,
2092 cache,
2093 )
2094 .await;
2095 if options.members == MemberDiscovery::All {
2096 workspace_cache.insert(result.clone(), &project_path);
2097 }
2098 Ok(Self::NonProject(result?))
2099 } else {
2100 let project_path = std::path::absolute(project_root)
2103 .map_err(WorkspaceErrorKind::Normalize)?
2104 .clone();
2105
2106 let result = Workspace::build(
2107 project_path.clone(),
2108 ToolUvWorkspace::default(),
2109 pyproject_toml,
2110 None,
2111 options,
2112 cache,
2113 )
2114 .await;
2115 if options.members == MemberDiscovery::All {
2116 workspace_cache.insert(result.clone(), &project_path);
2117 }
2118 Ok(Self::NonProject(result?))
2119 }
2120 }
2121
2122 pub async fn discover_with_package(
2124 path: &Path,
2125 options: &DiscoveryOptions,
2126 cache: &Cache,
2127 workspace_cache: &WorkspaceCache,
2128 package: PackageName,
2129 ) -> Result<Self, WorkspaceError> {
2130 let workspace = Workspace::discover(path, options, cache, workspace_cache).await?;
2131 let Some(project_workspace) =
2132 Workspace::with_current_project(workspace.clone(), package.clone())
2133 else {
2134 return Err(WorkspaceError::from(WorkspaceErrorKind::NoSuchMember(
2135 package,
2136 workspace.install_path.clone(),
2137 )));
2138 };
2139 Ok(Self::Project(project_workspace))
2140 }
2141
2142 pub fn update_member(
2151 self,
2152 pyproject_toml: PyProjectToml,
2153 workspace_cache: &WorkspaceCache,
2154 ) -> Result<Option<Self>, WorkspaceError> {
2155 workspace_cache.invalidate_workspace(self.workspace());
2157 Ok(match self {
2158 Self::Project(project) => {
2159 let Some(project) = project.update_member(pyproject_toml)? else {
2160 return Ok(None);
2161 };
2162 Some(Self::Project(project))
2163 }
2164 Self::NonProject(workspace) => {
2165 debug_assert_eq!(
2166 Arc::strong_count(&workspace),
2167 1,
2168 "cannot modify workspace still in use",
2169 );
2170
2171 let workspace = Arc::unwrap_or_clone(workspace);
2172 let workspace = Workspace {
2175 pyproject_toml,
2176 ..workspace
2177 };
2178 Some(Self::NonProject(Arc::new(workspace)))
2179 }
2180 })
2181 }
2182
2183 #[must_use]
2188 pub fn clone_detach(&self) -> Self {
2189 match self {
2190 Self::Project(project) => Self::Project(ProjectWorkspace {
2191 project_root: project.project_root.clone(),
2192 project_name: project.project_name.clone(),
2193 workspace: Arc::new((*project.workspace).clone()),
2194 }),
2195 Self::NonProject(workspace) => Self::NonProject(Arc::new((**workspace).clone())),
2196 }
2197 }
2198
2199 pub fn root(&self) -> &Path {
2201 match self {
2202 Self::Project(project) => project.project_root(),
2203 Self::NonProject(workspace) => workspace.install_path(),
2204 }
2205 }
2206
2207 pub fn pyproject_toml(&self) -> &PyProjectToml {
2209 match self {
2210 Self::Project(project) => project.current_project().pyproject_toml(),
2211 Self::NonProject(workspace) => &workspace.pyproject_toml,
2212 }
2213 }
2214
2215 pub fn workspace(&self) -> &Workspace {
2217 match self {
2218 Self::Project(project) => project.workspace(),
2219 Self::NonProject(workspace) => workspace,
2220 }
2221 }
2222
2223 pub fn project_name(&self) -> Option<&PackageName> {
2225 match self {
2226 Self::Project(project) => Some(project.project_name()),
2227 Self::NonProject(_) => None,
2228 }
2229 }
2230
2231 pub fn is_non_project(&self) -> bool {
2233 matches!(self, Self::NonProject(_))
2234 }
2235}
2236
2237#[cfg(test)]
2238#[cfg(unix)] mod tests {
2240 use std::collections::BTreeMap;
2241 use std::env;
2242 use std::path::Path;
2243 use std::str::FromStr;
2244 use std::sync::Arc;
2245
2246 use anyhow::Result;
2247 use assert_fs::fixture::ChildPath;
2248 use assert_fs::prelude::*;
2249 use insta::{assert_json_snapshot, assert_snapshot};
2250
2251 use uv_cache::Cache;
2252 use uv_normalize::{GroupName, PackageName};
2253 use uv_pypi_types::DependencyGroupSpecifier;
2254
2255 use crate::pyproject::PyProjectToml;
2256 use crate::workspace::{DiscoveryOptions, MemberDiscovery, ProjectWorkspace, Workspace};
2257 use crate::{WorkspaceCache, WorkspaceError};
2258
2259 async fn workspace_test(folder: &str) -> (ProjectWorkspace, String) {
2260 let root_dir = env::current_dir()
2261 .unwrap()
2262 .parent()
2263 .unwrap()
2264 .parent()
2265 .unwrap()
2266 .join("test")
2267 .join("workspaces");
2268 let cache = Cache::from_path(root_dir.join(".uv_cache"));
2269 let project = ProjectWorkspace::discover(
2270 &root_dir.join(folder),
2271 &DiscoveryOptions::default(),
2272 &cache,
2273 &WorkspaceCache::default(),
2274 )
2275 .await
2276 .unwrap();
2277 let root_escaped = regex::escape(root_dir.to_string_lossy().as_ref());
2278 (project, root_escaped)
2279 }
2280
2281 async fn temporary_test(
2282 folder: &Path,
2283 ) -> Result<(ProjectWorkspace, String), (WorkspaceError, String)> {
2284 let root_escaped = regex::escape(folder.to_string_lossy().as_ref());
2285 let cache = Cache::from_path(env::temp_dir().join("uv-workspace-cache"));
2286 let project = ProjectWorkspace::discover(
2287 folder,
2288 &DiscoveryOptions::default(),
2289 &cache,
2290 &WorkspaceCache::default(),
2291 )
2292 .await
2293 .map_err(|error| (error, root_escaped.clone()))?;
2294
2295 Ok((project, root_escaped))
2296 }
2297
2298 #[tokio::test]
2299 async fn albatross_in_example() {
2300 let (project, root_escaped) =
2301 workspace_test("albatross-in-example/examples/bird-feeder").await;
2302 let filters = vec![(root_escaped.as_str(), "[ROOT]")];
2303 insta::with_settings!({filters => filters}, {
2304 assert_json_snapshot!(
2305 project,
2306 {
2307 ".workspace.packages.*.pyproject_toml" => "[PYPROJECT_TOML]"
2308 },
2309 @r#"
2310 {
2311 "project_root": "[ROOT]/albatross-in-example/examples/bird-feeder",
2312 "project_name": "bird-feeder",
2313 "workspace": {
2314 "install_path": "[ROOT]/albatross-in-example/examples/bird-feeder",
2315 "packages": {
2316 "bird-feeder": {
2317 "root": "[ROOT]/albatross-in-example/examples/bird-feeder",
2318 "project": {
2319 "name": "bird-feeder",
2320 "version": "1.0.0",
2321 "requires-python": ">=3.12",
2322 "dependencies": [
2323 "iniconfig>=2,<3"
2324 ],
2325 "optional-dependencies": null
2326 },
2327 "pyproject_toml": "[PYPROJECT_TOML]"
2328 }
2329 },
2330 "required_members": {},
2331 "sources": {},
2332 "indexes": [],
2333 "pyproject_toml": {
2334 "project": {
2335 "name": "bird-feeder",
2336 "version": "1.0.0",
2337 "requires-python": ">=3.12",
2338 "dependencies": [
2339 "iniconfig>=2,<3"
2340 ],
2341 "optional-dependencies": null
2342 },
2343 "tool": null,
2344 "dependency-groups": null
2345 }
2346 }
2347 }
2348 "#);
2349 });
2350 }
2351
2352 #[tokio::test]
2353 async fn albatross_project_in_excluded() {
2354 let (project, root_escaped) =
2355 workspace_test("albatross-project-in-excluded/excluded/bird-feeder").await;
2356 let filters = vec![(root_escaped.as_str(), "[ROOT]")];
2357 insta::with_settings!({filters => filters}, {
2358 assert_json_snapshot!(
2359 project,
2360 {
2361 ".workspace.packages.*.pyproject_toml" => "[PYPROJECT_TOML]"
2362 },
2363 @r#"
2364 {
2365 "project_root": "[ROOT]/albatross-project-in-excluded/excluded/bird-feeder",
2366 "project_name": "bird-feeder",
2367 "workspace": {
2368 "install_path": "[ROOT]/albatross-project-in-excluded/excluded/bird-feeder",
2369 "packages": {
2370 "bird-feeder": {
2371 "root": "[ROOT]/albatross-project-in-excluded/excluded/bird-feeder",
2372 "project": {
2373 "name": "bird-feeder",
2374 "version": "1.0.0",
2375 "requires-python": ">=3.12",
2376 "dependencies": [
2377 "iniconfig>=2,<3"
2378 ],
2379 "optional-dependencies": null
2380 },
2381 "pyproject_toml": "[PYPROJECT_TOML]"
2382 }
2383 },
2384 "required_members": {},
2385 "sources": {},
2386 "indexes": [],
2387 "pyproject_toml": {
2388 "project": {
2389 "name": "bird-feeder",
2390 "version": "1.0.0",
2391 "requires-python": ">=3.12",
2392 "dependencies": [
2393 "iniconfig>=2,<3"
2394 ],
2395 "optional-dependencies": null
2396 },
2397 "tool": null,
2398 "dependency-groups": null
2399 }
2400 }
2401 }
2402 "#);
2403 });
2404 }
2405
2406 #[tokio::test]
2407 async fn albatross_root_workspace() {
2408 let (project, root_escaped) = workspace_test("albatross-root-workspace").await;
2409 let filters = vec![(root_escaped.as_str(), "[ROOT]")];
2410 insta::with_settings!({filters => filters}, {
2411 assert_json_snapshot!(
2412 project,
2413 {
2414 ".workspace.packages.*.pyproject_toml" => "[PYPROJECT_TOML]"
2415 },
2416 @r#"
2417 {
2418 "project_root": "[ROOT]/albatross-root-workspace",
2419 "project_name": "albatross",
2420 "workspace": {
2421 "install_path": "[ROOT]/albatross-root-workspace",
2422 "packages": {
2423 "albatross": {
2424 "root": "[ROOT]/albatross-root-workspace",
2425 "project": {
2426 "name": "albatross",
2427 "version": "0.1.0",
2428 "requires-python": ">=3.12",
2429 "dependencies": [
2430 "bird-feeder",
2431 "iniconfig>=2,<3"
2432 ],
2433 "optional-dependencies": null
2434 },
2435 "pyproject_toml": "[PYPROJECT_TOML]"
2436 },
2437 "bird-feeder": {
2438 "root": "[ROOT]/albatross-root-workspace/packages/bird-feeder",
2439 "project": {
2440 "name": "bird-feeder",
2441 "version": "1.0.0",
2442 "requires-python": ">=3.8",
2443 "dependencies": [
2444 "iniconfig>=2,<3",
2445 "seeds"
2446 ],
2447 "optional-dependencies": null
2448 },
2449 "pyproject_toml": "[PYPROJECT_TOML]"
2450 },
2451 "seeds": {
2452 "root": "[ROOT]/albatross-root-workspace/packages/seeds",
2453 "project": {
2454 "name": "seeds",
2455 "version": "1.0.0",
2456 "requires-python": ">=3.12",
2457 "dependencies": [
2458 "idna==3.6"
2459 ],
2460 "optional-dependencies": null
2461 },
2462 "pyproject_toml": "[PYPROJECT_TOML]"
2463 }
2464 },
2465 "required_members": {
2466 "bird-feeder": null,
2467 "seeds": null
2468 },
2469 "sources": {
2470 "bird-feeder": [
2471 {
2472 "workspace": true,
2473 "editable": null,
2474 "extra": null,
2475 "group": null
2476 }
2477 ]
2478 },
2479 "indexes": [],
2480 "pyproject_toml": {
2481 "project": {
2482 "name": "albatross",
2483 "version": "0.1.0",
2484 "requires-python": ">=3.12",
2485 "dependencies": [
2486 "bird-feeder",
2487 "iniconfig>=2,<3"
2488 ],
2489 "optional-dependencies": null
2490 },
2491 "tool": {
2492 "uv": {
2493 "sources": {
2494 "bird-feeder": [
2495 {
2496 "workspace": true,
2497 "editable": null,
2498 "extra": null,
2499 "group": null
2500 }
2501 ]
2502 },
2503 "index": null,
2504 "workspace": {
2505 "members": [
2506 "packages/*"
2507 ],
2508 "exclude": null
2509 },
2510 "managed": null,
2511 "package": null,
2512 "default-groups": null,
2513 "dependency-groups": null,
2514 "dev-dependencies": null,
2515 "override-dependencies": null,
2516 "exclude-dependencies": null,
2517 "constraint-dependencies": null,
2518 "build-constraint-dependencies": null,
2519 "environments": null,
2520 "required-environments": null,
2521 "conflicts": null,
2522 "build-backend": null
2523 }
2524 },
2525 "dependency-groups": null
2526 }
2527 }
2528 }
2529 "#);
2530 });
2531 }
2532
2533 #[tokio::test]
2534 async fn albatross_virtual_workspace() {
2535 let (project, root_escaped) =
2536 workspace_test("albatross-virtual-workspace/packages/albatross").await;
2537 let filters = vec![(root_escaped.as_str(), "[ROOT]")];
2538 insta::with_settings!({filters => filters}, {
2539 assert_json_snapshot!(
2540 project,
2541 {
2542 ".workspace.packages.*.pyproject_toml" => "[PYPROJECT_TOML]"
2543 },
2544 @r#"
2545 {
2546 "project_root": "[ROOT]/albatross-virtual-workspace/packages/albatross",
2547 "project_name": "albatross",
2548 "workspace": {
2549 "install_path": "[ROOT]/albatross-virtual-workspace",
2550 "packages": {
2551 "albatross": {
2552 "root": "[ROOT]/albatross-virtual-workspace/packages/albatross",
2553 "project": {
2554 "name": "albatross",
2555 "version": "0.1.0",
2556 "requires-python": ">=3.12",
2557 "dependencies": [
2558 "bird-feeder",
2559 "iniconfig>=2,<3"
2560 ],
2561 "optional-dependencies": null
2562 },
2563 "pyproject_toml": "[PYPROJECT_TOML]"
2564 },
2565 "bird-feeder": {
2566 "root": "[ROOT]/albatross-virtual-workspace/packages/bird-feeder",
2567 "project": {
2568 "name": "bird-feeder",
2569 "version": "1.0.0",
2570 "requires-python": ">=3.12",
2571 "dependencies": [
2572 "anyio>=4.3.0,<5",
2573 "seeds"
2574 ],
2575 "optional-dependencies": null
2576 },
2577 "pyproject_toml": "[PYPROJECT_TOML]"
2578 },
2579 "seeds": {
2580 "root": "[ROOT]/albatross-virtual-workspace/packages/seeds",
2581 "project": {
2582 "name": "seeds",
2583 "version": "1.0.0",
2584 "requires-python": ">=3.12",
2585 "dependencies": [
2586 "idna==3.6"
2587 ],
2588 "optional-dependencies": null
2589 },
2590 "pyproject_toml": "[PYPROJECT_TOML]"
2591 }
2592 },
2593 "required_members": {
2594 "bird-feeder": null,
2595 "seeds": null
2596 },
2597 "sources": {},
2598 "indexes": [],
2599 "pyproject_toml": {
2600 "project": null,
2601 "tool": {
2602 "uv": {
2603 "sources": null,
2604 "index": null,
2605 "workspace": {
2606 "members": [
2607 "packages/*"
2608 ],
2609 "exclude": null
2610 },
2611 "managed": null,
2612 "package": null,
2613 "default-groups": null,
2614 "dependency-groups": null,
2615 "dev-dependencies": null,
2616 "override-dependencies": null,
2617 "exclude-dependencies": null,
2618 "constraint-dependencies": null,
2619 "build-constraint-dependencies": null,
2620 "environments": null,
2621 "required-environments": null,
2622 "conflicts": null,
2623 "build-backend": null
2624 }
2625 },
2626 "dependency-groups": null
2627 }
2628 }
2629 }
2630 "#);
2631 });
2632 }
2633
2634 #[tokio::test]
2635 async fn workspace_cache_reuses_workspace_for_member() -> Result<()> {
2636 let root = tempfile::TempDir::new()?;
2637 let root = ChildPath::new(root.path());
2638
2639 root.child("pyproject.toml").write_str(
2640 r#"
2641 [project]
2642 name = "albatross"
2643 version = "0.1.0"
2644 requires-python = ">=3.12"
2645
2646 [tool.uv.workspace]
2647 members = ["packages/*"]
2648 "#,
2649 )?;
2650
2651 root.child("packages")
2652 .child("seeds")
2653 .child("pyproject.toml")
2654 .write_str(
2655 r#"
2656 [project]
2657 name = "seeds"
2658 version = "1.0.0"
2659 requires-python = ">=3.12"
2660 "#,
2661 )?;
2662
2663 let cache = Cache::from_path(env::temp_dir().join("uv-workspace-cache"));
2664 let workspace_cache = WorkspaceCache::default();
2665 let root_workspace = Workspace::discover(
2666 root.as_ref(),
2667 &DiscoveryOptions::default(),
2668 &cache,
2669 &workspace_cache,
2670 )
2671 .await?;
2672 let member_workspace = Workspace::discover(
2673 root.child("packages").child("seeds").as_ref(),
2674 &DiscoveryOptions::default(),
2675 &cache,
2676 &workspace_cache,
2677 )
2678 .await?;
2679
2680 assert!(Arc::ptr_eq(&root_workspace, &member_workspace));
2681
2682 root.child("pyproject.toml")
2683 .write_str("not valid toml >.<")?;
2684 let member_project = ProjectWorkspace::from_maybe_project_root(
2685 root.child("packages").child("seeds").as_ref(),
2686 &DiscoveryOptions::default(),
2687 &cache,
2688 &workspace_cache,
2689 )
2690 .await?
2691 .expect("cached workspace member ignores invalid change in the meantime");
2692
2693 assert!(Arc::ptr_eq(&root_workspace, &member_project.workspace));
2694
2695 Ok(())
2696 }
2697
2698 #[tokio::test]
2699 async fn workspace_cache_does_not_store_partial_discovery() -> Result<()> {
2700 let root = tempfile::TempDir::new()?;
2701 let root = ChildPath::new(root.path());
2702
2703 root.child("pyproject.toml").write_str(
2704 r#"
2705 [project]
2706 name = "albatross"
2707 version = "0.1.0"
2708 requires-python = ">=3.12"
2709
2710 [tool.uv.workspace]
2711 members = ["packages/*"]
2712 "#,
2713 )?;
2714
2715 root.child("packages")
2716 .child("seeds")
2717 .child("pyproject.toml")
2718 .write_str(
2719 r#"
2720 [project]
2721 name = "seeds"
2722 version = "1.0.0"
2723 requires-python = ">=3.12"
2724 "#,
2725 )?;
2726
2727 let cache = Cache::from_path(env::temp_dir().join("uv-workspace-cache"));
2728 let workspace_cache = WorkspaceCache::default();
2729 let partial_options = DiscoveryOptions {
2730 members: MemberDiscovery::None,
2731 ..DiscoveryOptions::default()
2732 };
2733 let partial_project =
2734 ProjectWorkspace::discover(root.as_ref(), &partial_options, &cache, &workspace_cache)
2735 .await?;
2736
2737 assert_eq!(partial_project.workspace().packages().len(), 1);
2738
2739 let member_project = ProjectWorkspace::discover(
2740 root.child("packages").child("seeds").as_ref(),
2741 &DiscoveryOptions::default(),
2742 &cache,
2743 &workspace_cache,
2744 )
2745 .await?;
2746 let seeds = PackageName::from_str("seeds")?;
2747
2748 assert_eq!(member_project.project_name(), &seeds);
2749 assert_eq!(member_project.workspace().packages().len(), 2);
2750 assert!(member_project.workspace().packages().contains_key(&seeds));
2751
2752 Ok(())
2753 }
2754
2755 #[tokio::test]
2756 async fn albatross_just_project() {
2757 let (project, root_escaped) = workspace_test("albatross-just-project").await;
2758 let filters = vec![(root_escaped.as_str(), "[ROOT]")];
2759 insta::with_settings!({filters => filters}, {
2760 assert_json_snapshot!(
2761 project,
2762 {
2763 ".workspace.packages.*.pyproject_toml" => "[PYPROJECT_TOML]"
2764 },
2765 @r#"
2766 {
2767 "project_root": "[ROOT]/albatross-just-project",
2768 "project_name": "albatross",
2769 "workspace": {
2770 "install_path": "[ROOT]/albatross-just-project",
2771 "packages": {
2772 "albatross": {
2773 "root": "[ROOT]/albatross-just-project",
2774 "project": {
2775 "name": "albatross",
2776 "version": "0.1.0",
2777 "requires-python": ">=3.12",
2778 "dependencies": [
2779 "iniconfig>=2,<3"
2780 ],
2781 "optional-dependencies": null
2782 },
2783 "pyproject_toml": "[PYPROJECT_TOML]"
2784 }
2785 },
2786 "required_members": {},
2787 "sources": {},
2788 "indexes": [],
2789 "pyproject_toml": {
2790 "project": {
2791 "name": "albatross",
2792 "version": "0.1.0",
2793 "requires-python": ">=3.12",
2794 "dependencies": [
2795 "iniconfig>=2,<3"
2796 ],
2797 "optional-dependencies": null
2798 },
2799 "tool": null,
2800 "dependency-groups": null
2801 }
2802 }
2803 }
2804 "#);
2805 });
2806 }
2807
2808 #[tokio::test]
2809 async fn exclude_package() -> Result<()> {
2810 let root = tempfile::TempDir::new()?;
2811 let root = ChildPath::new(root.path());
2812
2813 root.child("pyproject.toml").write_str(
2815 r#"
2816 [project]
2817 name = "albatross"
2818 version = "0.1.0"
2819 requires-python = ">=3.12"
2820 dependencies = ["tqdm>=4,<5"]
2821
2822 [tool.uv.workspace]
2823 members = ["packages/*"]
2824 exclude = ["packages/bird-feeder"]
2825
2826 [build-system]
2827 requires = ["hatchling"]
2828 build-backend = "hatchling.build"
2829 "#,
2830 )?;
2831 root.child("albatross").child("__init__.py").touch()?;
2832
2833 root.child("packages")
2835 .child("seeds")
2836 .child("pyproject.toml")
2837 .write_str(
2838 r#"
2839 [project]
2840 name = "seeds"
2841 version = "1.0.0"
2842 requires-python = ">=3.12"
2843 dependencies = ["idna==3.6"]
2844
2845 [build-system]
2846 requires = ["hatchling"]
2847 build-backend = "hatchling.build"
2848 "#,
2849 )?;
2850 root.child("packages")
2851 .child("seeds")
2852 .child("seeds")
2853 .child("__init__.py")
2854 .touch()?;
2855
2856 root.child("packages")
2858 .child("bird-feeder")
2859 .child("pyproject.toml")
2860 .write_str(
2861 r#"
2862 [project]
2863 name = "bird-feeder"
2864 version = "1.0.0"
2865 requires-python = ">=3.12"
2866 dependencies = ["anyio>=4.3.0,<5"]
2867
2868 [build-system]
2869 requires = ["hatchling"]
2870 build-backend = "hatchling.build"
2871 "#,
2872 )?;
2873 root.child("packages")
2874 .child("bird-feeder")
2875 .child("bird_feeder")
2876 .child("__init__.py")
2877 .touch()?;
2878
2879 let (project, root_escaped) = temporary_test(root.as_ref()).await.unwrap();
2880 let filters = vec![(root_escaped.as_str(), "[ROOT]")];
2881 insta::with_settings!({filters => filters}, {
2882 assert_json_snapshot!(
2883 project,
2884 {
2885 ".workspace.packages.*.pyproject_toml" => "[PYPROJECT_TOML]"
2886 },
2887 @r#"
2888 {
2889 "project_root": "[ROOT]",
2890 "project_name": "albatross",
2891 "workspace": {
2892 "install_path": "[ROOT]",
2893 "packages": {
2894 "albatross": {
2895 "root": "[ROOT]",
2896 "project": {
2897 "name": "albatross",
2898 "version": "0.1.0",
2899 "requires-python": ">=3.12",
2900 "dependencies": [
2901 "tqdm>=4,<5"
2902 ],
2903 "optional-dependencies": null
2904 },
2905 "pyproject_toml": "[PYPROJECT_TOML]"
2906 },
2907 "seeds": {
2908 "root": "[ROOT]/packages/seeds",
2909 "project": {
2910 "name": "seeds",
2911 "version": "1.0.0",
2912 "requires-python": ">=3.12",
2913 "dependencies": [
2914 "idna==3.6"
2915 ],
2916 "optional-dependencies": null
2917 },
2918 "pyproject_toml": "[PYPROJECT_TOML]"
2919 }
2920 },
2921 "required_members": {},
2922 "sources": {},
2923 "indexes": [],
2924 "pyproject_toml": {
2925 "project": {
2926 "name": "albatross",
2927 "version": "0.1.0",
2928 "requires-python": ">=3.12",
2929 "dependencies": [
2930 "tqdm>=4,<5"
2931 ],
2932 "optional-dependencies": null
2933 },
2934 "tool": {
2935 "uv": {
2936 "sources": null,
2937 "index": null,
2938 "workspace": {
2939 "members": [
2940 "packages/*"
2941 ],
2942 "exclude": [
2943 "packages/bird-feeder"
2944 ]
2945 },
2946 "managed": null,
2947 "package": null,
2948 "default-groups": null,
2949 "dependency-groups": null,
2950 "dev-dependencies": null,
2951 "override-dependencies": null,
2952 "exclude-dependencies": null,
2953 "constraint-dependencies": null,
2954 "build-constraint-dependencies": null,
2955 "environments": null,
2956 "required-environments": null,
2957 "conflicts": null,
2958 "build-backend": null
2959 }
2960 },
2961 "dependency-groups": null
2962 }
2963 }
2964 }
2965 "#);
2966 });
2967
2968 root.child("pyproject.toml").write_str(
2970 r#"
2971 [project]
2972 name = "albatross"
2973 version = "0.1.0"
2974 requires-python = ">=3.12"
2975 dependencies = ["tqdm>=4,<5"]
2976
2977 [tool.uv.workspace]
2978 members = ["packages/seeds", "packages/bird-feeder"]
2979 exclude = ["packages/bird-feeder"]
2980
2981 [build-system]
2982 requires = ["hatchling"]
2983 build-backend = "hatchling.build"
2984 "#,
2985 )?;
2986
2987 let (project, root_escaped) = temporary_test(root.as_ref()).await.unwrap();
2989 let filters = vec![(root_escaped.as_str(), "[ROOT]")];
2990 insta::with_settings!({filters => filters}, {
2991 assert_json_snapshot!(
2992 project,
2993 {
2994 ".workspace.packages.*.pyproject_toml" => "[PYPROJECT_TOML]"
2995 },
2996 @r#"
2997 {
2998 "project_root": "[ROOT]",
2999 "project_name": "albatross",
3000 "workspace": {
3001 "install_path": "[ROOT]",
3002 "packages": {
3003 "albatross": {
3004 "root": "[ROOT]",
3005 "project": {
3006 "name": "albatross",
3007 "version": "0.1.0",
3008 "requires-python": ">=3.12",
3009 "dependencies": [
3010 "tqdm>=4,<5"
3011 ],
3012 "optional-dependencies": null
3013 },
3014 "pyproject_toml": "[PYPROJECT_TOML]"
3015 },
3016 "seeds": {
3017 "root": "[ROOT]/packages/seeds",
3018 "project": {
3019 "name": "seeds",
3020 "version": "1.0.0",
3021 "requires-python": ">=3.12",
3022 "dependencies": [
3023 "idna==3.6"
3024 ],
3025 "optional-dependencies": null
3026 },
3027 "pyproject_toml": "[PYPROJECT_TOML]"
3028 }
3029 },
3030 "required_members": {},
3031 "sources": {},
3032 "indexes": [],
3033 "pyproject_toml": {
3034 "project": {
3035 "name": "albatross",
3036 "version": "0.1.0",
3037 "requires-python": ">=3.12",
3038 "dependencies": [
3039 "tqdm>=4,<5"
3040 ],
3041 "optional-dependencies": null
3042 },
3043 "tool": {
3044 "uv": {
3045 "sources": null,
3046 "index": null,
3047 "workspace": {
3048 "members": [
3049 "packages/seeds",
3050 "packages/bird-feeder"
3051 ],
3052 "exclude": [
3053 "packages/bird-feeder"
3054 ]
3055 },
3056 "managed": null,
3057 "package": null,
3058 "default-groups": null,
3059 "dependency-groups": null,
3060 "dev-dependencies": null,
3061 "override-dependencies": null,
3062 "exclude-dependencies": null,
3063 "constraint-dependencies": null,
3064 "build-constraint-dependencies": null,
3065 "environments": null,
3066 "required-environments": null,
3067 "conflicts": null,
3068 "build-backend": null
3069 }
3070 },
3071 "dependency-groups": null
3072 }
3073 }
3074 }
3075 "#);
3076 });
3077
3078 root.child("pyproject.toml").write_str(
3080 r#"
3081 [project]
3082 name = "albatross"
3083 version = "0.1.0"
3084 requires-python = ">=3.12"
3085 dependencies = ["tqdm>=4,<5"]
3086
3087 [tool.uv.workspace]
3088 members = ["packages/seeds", "packages/bird-feeder"]
3089 exclude = ["packages"]
3090
3091 [build-system]
3092 requires = ["hatchling"]
3093 build-backend = "hatchling.build"
3094 "#,
3095 )?;
3096
3097 let (project, root_escaped) = temporary_test(root.as_ref()).await.unwrap();
3099 let filters = vec![(root_escaped.as_str(), "[ROOT]")];
3100 insta::with_settings!({filters => filters}, {
3101 assert_json_snapshot!(
3102 project,
3103 {
3104 ".workspace.packages.*.pyproject_toml" => "[PYPROJECT_TOML]"
3105 },
3106 @r#"
3107 {
3108 "project_root": "[ROOT]",
3109 "project_name": "albatross",
3110 "workspace": {
3111 "install_path": "[ROOT]",
3112 "packages": {
3113 "albatross": {
3114 "root": "[ROOT]",
3115 "project": {
3116 "name": "albatross",
3117 "version": "0.1.0",
3118 "requires-python": ">=3.12",
3119 "dependencies": [
3120 "tqdm>=4,<5"
3121 ],
3122 "optional-dependencies": null
3123 },
3124 "pyproject_toml": "[PYPROJECT_TOML]"
3125 },
3126 "bird-feeder": {
3127 "root": "[ROOT]/packages/bird-feeder",
3128 "project": {
3129 "name": "bird-feeder",
3130 "version": "1.0.0",
3131 "requires-python": ">=3.12",
3132 "dependencies": [
3133 "anyio>=4.3.0,<5"
3134 ],
3135 "optional-dependencies": null
3136 },
3137 "pyproject_toml": "[PYPROJECT_TOML]"
3138 },
3139 "seeds": {
3140 "root": "[ROOT]/packages/seeds",
3141 "project": {
3142 "name": "seeds",
3143 "version": "1.0.0",
3144 "requires-python": ">=3.12",
3145 "dependencies": [
3146 "idna==3.6"
3147 ],
3148 "optional-dependencies": null
3149 },
3150 "pyproject_toml": "[PYPROJECT_TOML]"
3151 }
3152 },
3153 "required_members": {},
3154 "sources": {},
3155 "indexes": [],
3156 "pyproject_toml": {
3157 "project": {
3158 "name": "albatross",
3159 "version": "0.1.0",
3160 "requires-python": ">=3.12",
3161 "dependencies": [
3162 "tqdm>=4,<5"
3163 ],
3164 "optional-dependencies": null
3165 },
3166 "tool": {
3167 "uv": {
3168 "sources": null,
3169 "index": null,
3170 "workspace": {
3171 "members": [
3172 "packages/seeds",
3173 "packages/bird-feeder"
3174 ],
3175 "exclude": [
3176 "packages"
3177 ]
3178 },
3179 "managed": null,
3180 "package": null,
3181 "default-groups": null,
3182 "dependency-groups": null,
3183 "dev-dependencies": null,
3184 "override-dependencies": null,
3185 "exclude-dependencies": null,
3186 "constraint-dependencies": null,
3187 "build-constraint-dependencies": null,
3188 "environments": null,
3189 "required-environments": null,
3190 "conflicts": null,
3191 "build-backend": null
3192 }
3193 },
3194 "dependency-groups": null
3195 }
3196 }
3197 }
3198 "#);
3199 });
3200
3201 root.child("pyproject.toml").write_str(
3203 r#"
3204 [project]
3205 name = "albatross"
3206 version = "0.1.0"
3207 requires-python = ">=3.12"
3208 dependencies = ["tqdm>=4,<5"]
3209
3210 [tool.uv.workspace]
3211 members = ["packages/seeds", "packages/bird-feeder"]
3212 exclude = ["packages/*"]
3213
3214 [build-system]
3215 requires = ["hatchling"]
3216 build-backend = "hatchling.build"
3217 "#,
3218 )?;
3219
3220 let (project, root_escaped) = temporary_test(root.as_ref()).await.unwrap();
3222 let filters = vec![(root_escaped.as_str(), "[ROOT]")];
3223 insta::with_settings!({filters => filters}, {
3224 assert_json_snapshot!(
3225 project,
3226 {
3227 ".workspace.packages.*.pyproject_toml" => "[PYPROJECT_TOML]"
3228 },
3229 @r#"
3230 {
3231 "project_root": "[ROOT]",
3232 "project_name": "albatross",
3233 "workspace": {
3234 "install_path": "[ROOT]",
3235 "packages": {
3236 "albatross": {
3237 "root": "[ROOT]",
3238 "project": {
3239 "name": "albatross",
3240 "version": "0.1.0",
3241 "requires-python": ">=3.12",
3242 "dependencies": [
3243 "tqdm>=4,<5"
3244 ],
3245 "optional-dependencies": null
3246 },
3247 "pyproject_toml": "[PYPROJECT_TOML]"
3248 }
3249 },
3250 "required_members": {},
3251 "sources": {},
3252 "indexes": [],
3253 "pyproject_toml": {
3254 "project": {
3255 "name": "albatross",
3256 "version": "0.1.0",
3257 "requires-python": ">=3.12",
3258 "dependencies": [
3259 "tqdm>=4,<5"
3260 ],
3261 "optional-dependencies": null
3262 },
3263 "tool": {
3264 "uv": {
3265 "sources": null,
3266 "index": null,
3267 "workspace": {
3268 "members": [
3269 "packages/seeds",
3270 "packages/bird-feeder"
3271 ],
3272 "exclude": [
3273 "packages/*"
3274 ]
3275 },
3276 "managed": null,
3277 "package": null,
3278 "default-groups": null,
3279 "dependency-groups": null,
3280 "dev-dependencies": null,
3281 "override-dependencies": null,
3282 "exclude-dependencies": null,
3283 "constraint-dependencies": null,
3284 "build-constraint-dependencies": null,
3285 "environments": null,
3286 "required-environments": null,
3287 "conflicts": null,
3288 "build-backend": null
3289 }
3290 },
3291 "dependency-groups": null
3292 }
3293 }
3294 }
3295 "#);
3296 });
3297
3298 Ok(())
3299 }
3300
3301 #[test]
3302 fn read_dependency_groups() {
3303 let toml = r#"
3304[dependency-groups]
3305foo = ["a", {include-group = "bar"}]
3306bar = ["b"]
3307future = [{include-group = "bar", unknown = "value"}]
3308"#;
3309
3310 let result = PyProjectToml::from_string(toml.to_string(), "pyproject.toml")
3311 .expect("Deserialization should succeed");
3312
3313 let groups = result
3314 .dependency_groups
3315 .expect("`dependency-groups` should be present");
3316 let foo = groups
3317 .get(&GroupName::from_str("foo").unwrap())
3318 .expect("Group `foo` should be present");
3319 assert_eq!(
3320 foo,
3321 &[
3322 DependencyGroupSpecifier::Requirement("a".to_string()),
3323 DependencyGroupSpecifier::IncludeGroup {
3324 include_group: GroupName::from_str("bar").unwrap(),
3325 }
3326 ]
3327 );
3328
3329 let bar = groups
3330 .get(&GroupName::from_str("bar").unwrap())
3331 .expect("Group `bar` should be present");
3332 assert_eq!(
3333 bar,
3334 &[DependencyGroupSpecifier::Requirement("b".to_string())]
3335 );
3336
3337 let future = groups
3338 .get(&GroupName::from_str("future").unwrap())
3339 .expect("Group `future` should be present");
3340 assert_eq!(
3341 future,
3342 &[DependencyGroupSpecifier::Object(BTreeMap::from([
3343 ("include-group".to_string(), "bar".to_string()),
3344 ("unknown".to_string(), "value".to_string()),
3345 ]))]
3346 );
3347 }
3348
3349 #[test]
3350 fn reject_colliding_optional_dependency_names() {
3351 let err = PyProjectToml::from_string(
3352 r#"
3353[project]
3354name = "example"
3355version = "1.0.0"
3356
3357[project.optional-dependencies]
3358foo-bar = ["anyio"]
3359foo_bar = ["iniconfig"]
3360"#
3361 .to_string(),
3362 "pyproject.toml",
3363 )
3364 .unwrap_err();
3365
3366 assert_snapshot!(err.to_string(), @r#"
3367 TOML parse error at line 6, column 1
3368 |
3369 6 | [project.optional-dependencies]
3370 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
3371 duplicate normalized extra name `foo-bar`
3372 "#);
3373 }
3374
3375 #[tokio::test]
3376 async fn nested_workspace() -> Result<()> {
3377 let root = tempfile::TempDir::new()?;
3378 let root = ChildPath::new(root.path());
3379
3380 root.child("pyproject.toml").write_str(
3382 r#"
3383 [project]
3384 name = "albatross"
3385 version = "0.1.0"
3386 requires-python = ">=3.12"
3387 dependencies = ["tqdm>=4,<5"]
3388
3389 [tool.uv.workspace]
3390 members = ["packages/*"]
3391 "#,
3392 )?;
3393
3394 root.child("packages")
3396 .child("seeds")
3397 .child("pyproject.toml")
3398 .write_str(
3399 r#"
3400 [project]
3401 name = "seeds"
3402 version = "1.0.0"
3403 requires-python = ">=3.12"
3404 dependencies = ["idna==3.6"]
3405
3406 [tool.uv.workspace]
3407 members = ["nested_packages/*"]
3408 "#,
3409 )?;
3410
3411 let (error, root_escaped) = temporary_test(root.as_ref()).await.unwrap_err();
3412 let filters = vec![(root_escaped.as_str(), "[ROOT]")];
3413 insta::with_settings!({filters => filters}, {
3414 assert_snapshot!(
3415 error,
3416 @"Nested workspaces are not supported, but workspace member has a `tool.uv.workspace` table: [ROOT]/packages/seeds");
3417 });
3418
3419 Ok(())
3420 }
3421
3422 #[tokio::test]
3423 async fn duplicate_names() -> Result<()> {
3424 let root = tempfile::TempDir::new()?;
3425 let root = ChildPath::new(root.path());
3426
3427 root.child("pyproject.toml").write_str(
3429 r#"
3430 [project]
3431 name = "albatross"
3432 version = "0.1.0"
3433 requires-python = ">=3.12"
3434 dependencies = ["tqdm>=4,<5"]
3435
3436 [tool.uv.workspace]
3437 members = ["packages/*"]
3438 "#,
3439 )?;
3440
3441 root.child("packages")
3443 .child("seeds")
3444 .child("pyproject.toml")
3445 .write_str(
3446 r#"
3447 [project]
3448 name = "seeds"
3449 version = "1.0.0"
3450 requires-python = ">=3.12"
3451 dependencies = ["idna==3.6"]
3452
3453 [tool.uv.workspace]
3454 members = ["nested_packages/*"]
3455 "#,
3456 )?;
3457
3458 root.child("packages")
3460 .child("seeds2")
3461 .child("pyproject.toml")
3462 .write_str(
3463 r#"
3464 [project]
3465 name = "seeds"
3466 version = "1.0.0"
3467 requires-python = ">=3.12"
3468 dependencies = ["idna==3.6"]
3469
3470 [tool.uv.workspace]
3471 members = ["nested_packages/*"]
3472 "#,
3473 )?;
3474
3475 let (error, root_escaped) = temporary_test(root.as_ref()).await.unwrap_err();
3476 let filters = vec![(root_escaped.as_str(), "[ROOT]")];
3477 insta::with_settings!({filters => filters}, {
3478 assert_snapshot!(
3479 error,
3480 @"Two workspace members are both named `seeds`: `[ROOT]/packages/seeds` and `[ROOT]/packages/seeds2`");
3481 });
3482
3483 Ok(())
3484 }
3485}