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