1use std::collections::BTreeMap;
2use std::io;
3use std::path::{Path, PathBuf};
4
5use either::Either;
6use futures::future::join_all;
7
8use thiserror::Error;
9use uv_auth::CredentialsCache;
10use uv_cache::Cache;
11use uv_distribution_filename::DistExtension;
12use uv_distribution_types::{
13 Index, IndexCredentialsError, IndexLocations, IndexMetadata, IndexName, Origin, Requirement,
14 RequirementScope, RequirementSource,
15};
16use uv_fs::{Simplified, normalize_absolute_path, normalize_path};
17use uv_git_types::{GitLfs, GitReference, GitUrl, GitUrlParseError};
18use uv_normalize::{ExtraName, GroupName, PackageName};
19use uv_pep440::VersionSpecifiers;
20use uv_pep508::{MarkerTree, VerbatimUrl, VersionOrUrl, looks_like_git_repository};
21use uv_pypi_types::{
22 ConflictItem, ParsedGitDirectoryUrl, ParsedGitPathUrl, ParsedUrl, ParsedUrlError,
23 VerbatimParsedUrl,
24};
25use uv_redacted::{DisplaySafeUrl, DisplaySafeUrlError};
26use uv_workspace::pyproject::{PyProjectToml, Source, Sources, WorkspaceReference};
27use uv_workspace::{DiscoveryOptions, Workspace, WorkspaceCache, WorkspaceError};
28
29use crate::metadata::GitWorkspaceMember;
30
31#[derive(Debug, Clone)]
32pub struct LoweredRequirement(Requirement);
33
34#[derive(Debug, Clone, Copy)]
35enum RequirementOrigin {
36 Project,
38 Workspace,
40}
41
42impl LoweredRequirement {
43 pub(crate) async fn from_requirement<'data>(
45 requirement: uv_pep508::Requirement<VerbatimParsedUrl>,
46 project_name: Option<&'data PackageName>,
47 project_dir: &'data Path,
48 project_sources: &'data BTreeMap<PackageName, Sources>,
49 project_indexes: &'data [Index],
50 extra: Option<&ExtraName>,
51 group: Option<&GroupName>,
52 locations: &'data IndexLocations,
53 workspace: &'data Workspace,
54 git_member: Option<&'data GitWorkspaceMember<'data>>,
55 editable: bool,
56 cache: &'data Cache,
57 workspace_cache: &'data WorkspaceCache,
58 credentials_cache: &'data CredentialsCache,
59 ) -> impl Iterator<Item = Result<Self, LoweringError>> + use<'data> + 'data {
60 let (sources, origin) = if let Some(source) = project_sources.get(&requirement.name) {
62 (Some(source), RequirementOrigin::Project)
63 } else if let Some(source) = workspace.sources().get(&requirement.name) {
64 (Some(source), RequirementOrigin::Workspace)
65 } else {
66 (None, RequirementOrigin::Project)
67 };
68
69 let sources = sources.map(|sources| {
71 sources
72 .iter()
73 .filter(|source| {
74 if let Some(target) = source.extra()
75 && extra != Some(target)
76 {
77 return false;
78 }
79
80 if let Some(target) = source.group()
81 && group != Some(target)
82 {
83 return false;
84 }
85
86 true
87 })
88 .cloned()
89 .collect::<Sources>()
90 });
91
92 if workspace.packages().contains_key(&requirement.name) {
94 if project_name.is_none_or(|project_name| *project_name != requirement.name) {
97 let Some(sources) = sources.as_ref() else {
99 return Either::Left(std::iter::once(Err(
101 LoweringError::MissingWorkspaceSource(requirement.name.clone()),
102 )));
103 };
104
105 for source in sources.iter() {
106 match source {
107 Source::Git { .. } => {
108 return Either::Left(std::iter::once(Err(
109 LoweringError::NonWorkspaceSource(
110 requirement.name.clone(),
111 SourceKind::Git,
112 ),
113 )));
114 }
115 Source::Url { .. } => {
116 return Either::Left(std::iter::once(Err(
117 LoweringError::NonWorkspaceSource(
118 requirement.name.clone(),
119 SourceKind::Url,
120 ),
121 )));
122 }
123 Source::Path { .. } => {
124 return Either::Left(std::iter::once(Err(
125 LoweringError::NonWorkspaceSource(
126 requirement.name.clone(),
127 SourceKind::Path,
128 ),
129 )));
130 }
131 Source::Registry { .. } => {
132 return Either::Left(std::iter::once(Err(
133 LoweringError::NonWorkspaceSource(
134 requirement.name.clone(),
135 SourceKind::Registry,
136 ),
137 )));
138 }
139 Source::Workspace {
140 workspace: WorkspaceReference::Bool(true),
141 ..
142 } => {
143 }
145 Source::Workspace { .. } => {
146 return Either::Left(std::iter::once(Err(
147 LoweringError::InvalidWorkspaceSource(requirement.name.clone()),
148 )));
149 }
150 }
151 }
152 }
153 }
154
155 let Some(sources) = sources else {
156 return Either::Left(std::iter::once(Self::preserve_git_source(
157 requirement,
158 git_member,
159 )));
160 };
161
162 let remaining = {
165 let mut total = MarkerTree::FALSE;
167 for source in sources.iter() {
168 total = total.or(source.marker());
169 }
170
171 let mut remaining = total.negate();
173 remaining = remaining.and(requirement.marker);
174
175 Self(Requirement {
176 marker: remaining,
177 ..Requirement::from(requirement.clone())
178 })
179 };
180
181 Either::Right(
182 join_all(sources.into_iter().map(|source| {
183 let requirement = &requirement;
184 async move {
185 let (source, mut marker) = match source {
186 Source::Git {
187 git,
188 subdirectory,
189 path,
190 rev,
191 tag,
192 branch,
193 lfs,
194 marker,
195 ..
196 } => {
197 let source = git_source(
198 git,
199 subdirectory.map(Box::<Path>::from),
200 path.map(Box::<Path>::from).map(PathBuf::from),
201 rev,
202 tag,
203 branch,
204 lfs,
205 )?;
206 (source, marker)
207 }
208 Source::Url {
209 url,
210 subdirectory,
211 marker,
212 ..
213 } => {
214 let source =
215 url_source(requirement, url, subdirectory.map(Box::<Path>::from))?;
216 (source, marker)
217 }
218 Source::Path {
219 path,
220 editable,
221 package,
222 marker,
223 ..
224 } => {
225 let source = path_source(
226 path,
227 git_member,
228 origin,
229 project_dir,
230 workspace.install_path(),
231 editable,
232 package,
233 true,
234 )?;
235 (source, marker)
236 }
237 Source::Registry {
238 index,
239 marker,
240 extra,
241 group,
242 } => {
243 let Some(index) = locations
246 .indexes()
247 .filter(|index| matches!(index.origin, Some(Origin::Cli)))
248 .chain(project_indexes.iter())
249 .chain(workspace.indexes().iter())
250 .find(|Index { name, .. }| {
251 name.as_ref().is_some_and(|name| *name == index)
252 })
253 else {
254 let hint = missing_index_hint(locations, &index);
255 return Err(LoweringError::MissingIndex {
256 package: requirement.name.clone(),
257 index,
258 hint,
259 });
260 };
261 if let Some(credentials) = index.credentials()? {
262 credentials_cache.store_credentials(index.raw_url(), credentials);
263 }
264 let index = IndexMetadata {
265 url: index.url.clone(),
266 format: index.format,
267 };
268 let conflict = project_name.and_then(|project_name| {
269 if let Some(extra) = extra {
270 Some(ConflictItem::from((project_name.clone(), extra)))
271 } else {
272 group.map(|group| {
273 ConflictItem::from((project_name.clone(), group))
274 })
275 }
276 });
277 let source = registry_source(requirement, index, conflict);
278 (source, marker)
279 }
280 Source::Workspace {
281 workspace: workspace_ref,
282 editable: source_editable,
283 marker,
284 ..
285 } => {
286 let source = workspace_source(
287 requirement,
288 &workspace_ref,
289 source_editable,
290 editable,
291 origin,
292 project_dir,
293 workspace.install_path(),
294 Some(workspace),
295 git_member,
296 cache,
297 workspace_cache,
298 )
299 .await?;
300 (source, marker)
301 }
302 };
303
304 marker = marker.and(requirement.marker);
305
306 Ok(Self(Requirement {
307 name: requirement.name.clone(),
308 extras: requirement.extras.clone(),
309 groups: Box::new([]),
310 marker,
311 source,
312 scope: RequirementScope::Global,
313 origin: requirement.origin.clone(),
314 }))
315 }
316 }))
317 .await
318 .into_iter()
319 .chain(std::iter::once(Ok(remaining)))
320 .filter(|requirement| match requirement {
321 Ok(requirement) => !requirement.0.marker.is_false(),
322 Err(_) => true,
323 }),
324 )
325 }
326
327 pub async fn from_non_workspace_requirement<'data>(
330 requirement: uv_pep508::Requirement<VerbatimParsedUrl>,
331 dir: &'data Path,
332 sources: &'data BTreeMap<PackageName, Sources>,
333 indexes: &'data [Index],
334 locations: &'data IndexLocations,
335 cache: &'data Cache,
336 workspace_cache: &'data WorkspaceCache,
337 credentials_cache: &'data CredentialsCache,
338 ) -> impl Iterator<Item = Result<Self, LoweringError>> + 'data {
339 let source = sources.get(&requirement.name).cloned();
340
341 let Some(source) = source else {
342 return Either::Left(std::iter::once(Ok(Self(Requirement::from(requirement)))));
343 };
344
345 let source = source
347 .iter()
348 .filter(|source| {
349 source.extra().is_none_or(|target| {
350 requirement
351 .marker
352 .top_level_extra_name()
353 .is_some_and(|extra| &*extra == target)
354 })
355 })
356 .cloned()
357 .collect::<Sources>();
358
359 let remaining = {
362 let mut total = MarkerTree::FALSE;
364 for source in source.iter() {
365 total = total.or(source.marker());
366 }
367
368 let mut remaining = total.negate();
370 remaining = remaining.and(requirement.marker);
371
372 Self(Requirement {
373 marker: remaining,
374 ..Requirement::from(requirement.clone())
375 })
376 };
377
378 Either::Right(
379 join_all(source.into_iter().map(|source| {
380 let requirement = &requirement;
381 async move {
382 let (source, mut marker) = match source {
383 Source::Git {
384 git,
385 subdirectory,
386 path,
387 rev,
388 tag,
389 branch,
390 lfs,
391 marker,
392 ..
393 } => {
394 let source = git_source(
395 git,
396 subdirectory.map(Box::<Path>::from),
397 path.map(Box::<Path>::from).map(PathBuf::from),
398 rev,
399 tag,
400 branch,
401 lfs,
402 )?;
403 (source, marker)
404 }
405 Source::Url {
406 url,
407 subdirectory,
408 marker,
409 ..
410 } => {
411 let source =
412 url_source(requirement, url, subdirectory.map(Box::<Path>::from))?;
413 (source, marker)
414 }
415 Source::Path {
416 path,
417 editable,
418 package,
419 marker,
420 ..
421 } => {
422 let source = path_source(
423 path,
424 None,
425 RequirementOrigin::Project,
426 dir,
427 dir,
428 editable,
429 package,
430 true,
431 )?;
432 (source, marker)
433 }
434 Source::Registry { index, marker, .. } => {
435 let Some(index) = locations
436 .indexes()
437 .filter(|index| matches!(index.origin, Some(Origin::Cli)))
438 .chain(indexes.iter())
439 .find(|Index { name, .. }| {
440 name.as_ref().is_some_and(|name| *name == index)
441 })
442 else {
443 let hint = missing_index_hint(locations, &index);
444 return Err(LoweringError::MissingIndex {
445 package: requirement.name.clone(),
446 index,
447 hint,
448 });
449 };
450 if let Some(credentials) = index.credentials()? {
451 credentials_cache.store_credentials(index.raw_url(), credentials);
452 }
453 let index = IndexMetadata {
454 url: index.url.clone(),
455 format: index.format,
456 };
457 let conflict = None;
458 let source = registry_source(requirement, index, conflict);
459 (source, marker)
460 }
461 Source::Workspace {
462 workspace: workspace_ref,
463 editable,
464 marker,
465 ..
466 } => {
467 let source = workspace_source(
468 requirement,
469 &workspace_ref,
470 editable,
471 true,
472 RequirementOrigin::Project,
473 dir,
474 dir,
475 None,
476 None,
477 cache,
478 workspace_cache,
479 )
480 .await?;
481 (source, marker)
482 }
483 };
484
485 marker = marker.and(requirement.marker);
486
487 Ok(Self(Requirement {
488 name: requirement.name.clone(),
489 extras: requirement.extras.clone(),
490 groups: Box::new([]),
491 marker,
492 source,
493 scope: RequirementScope::Global,
494 origin: requirement.origin.clone(),
495 }))
496 }
497 }))
498 .await
499 .into_iter()
500 .chain(std::iter::once(Ok(remaining)))
501 .filter(|requirement| match requirement {
502 Ok(requirement) => !requirement.0.marker.is_false(),
503 Err(_) => true,
504 }),
505 )
506 }
507
508 pub(crate) fn preserve_git_source(
511 requirement: uv_pep508::Requirement<VerbatimParsedUrl>,
512 git_member: Option<&GitWorkspaceMember>,
513 ) -> Result<Self, LoweringError> {
514 let Some(git_member) = git_member else {
515 return Ok(Self(Requirement::from(requirement)));
516 };
517
518 let Some(VersionOrUrl::Url(url)) = &requirement.version_or_url else {
519 return Ok(Self(Requirement::from(requirement)));
520 };
521
522 let (install_path, is_archive) = match &url.parsed_url {
523 ParsedUrl::Directory(directory) => (directory.install_path.as_ref(), false),
524 ParsedUrl::Path(path) => (path.install_path.as_ref(), true),
525 _ => return Ok(Self(Requirement::from(requirement))),
526 };
527
528 let install_path = git_path(install_path)?;
529 let fetch_root = git_path(git_member.fetch_root)?;
530 if !install_path.starts_with(&fetch_root) {
531 return Ok(Self(Requirement::from(requirement)));
532 }
533
534 Ok(Self(Requirement {
535 name: requirement.name,
536 groups: Box::new([]),
537 extras: requirement.extras,
538 marker: requirement.marker,
539 source: if is_archive {
540 git_archive_source_from_path(&install_path, git_member)?
541 } else {
542 git_directory_source_from_path(&install_path, git_member)?
543 },
544 scope: RequirementScope::Global,
545 origin: requirement.origin,
546 }))
547 }
548
549 pub fn into_inner(self) -> Requirement {
551 self.0
552 }
553}
554
555#[derive(Debug, Error)]
558pub enum LoweringError {
559 #[error(
560 "`{0}` is included as a workspace member, but is missing an entry in `tool.uv.sources` (e.g., `{0} = {{ workspace = true }}`)"
561 )]
562 MissingWorkspaceSource(PackageName),
563 #[error(
564 "`{0}` is included as a workspace member, but references a {1} in `tool.uv.sources`. Workspace members must be declared as workspace sources (e.g., `{0} = {{ workspace = true }}`)."
565 )]
566 NonWorkspaceSource(PackageName, SourceKind),
567 #[error(
568 "`{0}` references a workspace in `tool.uv.sources` (e.g., `{0} = {{ workspace = true }}`), but is not a workspace member"
569 )]
570 UndeclaredWorkspacePackage(PackageName),
571 #[error(
572 "`{0}` is included as a workspace member, but does not use `workspace = true` in `tool.uv.sources`"
573 )]
574 InvalidWorkspaceSource(PackageName),
575 #[error("Can only specify one of: `rev`, `tag`, or `branch`")]
576 MoreThanOneGitRef,
577 #[error(transparent)]
578 GitUrlParse(#[from] GitUrlParseError),
579 #[error("Package `{package}` references an undeclared index: `{index}`")]
580 MissingIndex {
581 package: PackageName,
582 index: IndexName,
583 hint: Option<String>,
584 },
585 #[error("Workspace members are not allowed in non-workspace contexts")]
586 WorkspaceMember,
587 #[error(transparent)]
588 InvalidUrl(#[from] DisplaySafeUrlError),
589 #[error(transparent)]
590 IndexCredentials(#[from] IndexCredentialsError),
591 #[error(transparent)]
592 InvalidVerbatimUrl(#[from] uv_pep508::VerbatimUrlError),
593 #[error("Fragments are not allowed in URLs: `{0}`")]
594 ForbiddenFragment(DisplaySafeUrl),
595 #[error(
596 "`{0}` is associated with a URL source, but references a Git repository. Consider using a Git source instead (e.g., `{0} = {{ git = \"{1}\" }}`)"
597 )]
598 MissingGitSource(PackageName, DisplaySafeUrl),
599 #[error("`workspace = false` is not yet supported")]
600 WorkspaceFalse,
601 #[error(transparent)]
602 Workspace(#[from] WorkspaceError),
603 #[error(
604 "Workspace source path `{}` must point to a workspace root (found workspace at `{}`)", path.simplified_display(), root.simplified_display()
605 )]
606 WorkspaceSourceNotRoot { path: PathBuf, root: PathBuf },
607 #[error("Source with `editable = true` must refer to a local directory, not a file: `{0}`")]
608 EditableFile(String),
609 #[error("Source with `package = true` must refer to a local directory, not a file: `{0}`")]
610 PackagedFile(String),
611 #[error(
612 "Git repository references local file source, but only directories are supported as transitive Git dependencies: `{0}`"
613 )]
614 GitFile(String),
615 #[error("Git repository references local directory outside the repository: `{0}`")]
616 GitDirectory(String),
617 #[error(transparent)]
618 ParsedUrl(#[from] ParsedUrlError),
619 #[error("Path must be UTF-8: `{0}`")]
620 NonUtf8Path(PathBuf),
621 #[error(transparent)] RelativeTo(io::Error),
623}
624
625impl uv_errors::Hinted for LoweringError {
626 fn hints(&self) -> uv_errors::Hints<'_> {
627 match self {
628 Self::MissingIndex {
629 hint: Some(hint), ..
630 } => uv_errors::Hints::from(hint.clone()),
631 _ => uv_errors::Hints::none(),
632 }
633 }
634}
635
636#[derive(Debug, Copy, Clone)]
637pub enum SourceKind {
638 Path,
639 Url,
640 Git,
641 Registry,
642}
643
644impl std::fmt::Display for SourceKind {
645 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
646 match self {
647 Self::Path => write!(f, "path"),
648 Self::Url => write!(f, "URL"),
649 Self::Git => write!(f, "Git"),
650 Self::Registry => write!(f, "registry"),
651 }
652 }
653}
654
655fn missing_index_hint(locations: &IndexLocations, index: &IndexName) -> Option<String> {
658 let config_index = locations
659 .simple_indexes()
660 .filter(|idx| !matches!(idx.origin, Some(Origin::Cli)))
661 .find(|idx| idx.name.as_ref().is_some_and(|name| *name == *index));
662
663 config_index.and_then(|idx| {
664 let source = match idx.origin {
665 Some(Origin::User) => "a user-level `uv.toml`",
666 Some(Origin::System) => "a system-level `uv.toml`",
667 Some(Origin::Project) => "a project-level `uv.toml`",
668 Some(Origin::Cli | Origin::RequirementsTxt) | None => return None,
669 };
670 Some(format!(
671 "Index `{index}` was found in {source}, but indexes \
672 referenced via `tool.uv.sources` must be defined in the project's \
673 `pyproject.toml`"
674 ))
675 })
676}
677
678fn git_source(
680 git: DisplaySafeUrl,
681 subdirectory: Option<Box<Path>>,
682 path: Option<PathBuf>,
683 rev: Option<String>,
684 tag: Option<String>,
685 branch: Option<String>,
686 lfs: Option<bool>,
687) -> Result<RequirementSource, LoweringError> {
688 let reference = match (rev, tag, branch) {
689 (None, None, None) => GitReference::DefaultBranch,
690 (Some(rev), None, None) => GitReference::from_rev(rev),
691 (None, Some(tag), None) => GitReference::Tag(tag),
692 (None, None, Some(branch)) => GitReference::Branch(branch),
693 _ => return Err(LoweringError::MoreThanOneGitRef),
694 };
695
696 let mut url = DisplaySafeUrl::parse(&format!("git+{git}"))?;
698 if let Some(rev) = reference.as_str() {
699 let path = format!("{}@{}", url.path(), rev);
700 url.set_path(&path);
701 }
702 let mut frags: Vec<String> = Vec::new();
703 if let Some(subdirectory) = subdirectory.as_ref() {
704 let subdirectory = subdirectory
705 .to_str()
706 .ok_or_else(|| LoweringError::NonUtf8Path(subdirectory.to_path_buf()))?;
707 frags.push(format!("subdirectory={subdirectory}"));
708 }
709 let lfs = GitLfs::from(lfs);
713 if lfs.enabled() {
715 frags.push("lfs=true".to_string());
716 }
717 if let Some(path) = path.as_ref() {
718 let path = path
719 .to_str()
720 .ok_or_else(|| LoweringError::NonUtf8Path(path.clone()))?;
721 frags.push(format!("path={path}"));
722 }
723 if !frags.is_empty() {
724 url.set_fragment(Some(&frags.join("&")));
725 }
726 let url = VerbatimUrl::from_url(url);
727
728 let git = GitUrl::from_fields(git, reference, None, lfs)?;
729
730 if let Some(path) = path {
731 let ext = match DistExtension::from_path(&path) {
732 Ok(ext) => ext,
733 Err(err) => {
734 return Err(ParsedUrlError::MissingExtensionPath(path, err).into());
735 }
736 };
737 Ok(RequirementSource::GitPath {
738 url,
739 git,
740 install_path: path,
741 ext,
742 })
743 } else {
744 Ok(RequirementSource::GitDirectory {
745 url,
746 git,
747 subdirectory,
748 })
749 }
750}
751
752fn url_source(
754 requirement: &uv_pep508::Requirement<VerbatimParsedUrl>,
755 url: DisplaySafeUrl,
756 subdirectory: Option<Box<Path>>,
757) -> Result<RequirementSource, LoweringError> {
758 let mut verbatim_url = url.clone();
759 if verbatim_url.fragment().is_some() {
760 return Err(LoweringError::ForbiddenFragment(url));
761 }
762 if let Some(subdirectory) = subdirectory.as_ref() {
763 let subdirectory = subdirectory
764 .to_str()
765 .ok_or_else(|| LoweringError::NonUtf8Path(subdirectory.to_path_buf()))?;
766 verbatim_url.set_fragment(Some(&format!("subdirectory={subdirectory}")));
767 }
768
769 let ext = match DistExtension::from_path(url.path()) {
770 Ok(ext) => ext,
771 Err(..) if looks_like_git_repository(&url) => {
772 return Err(LoweringError::MissingGitSource(
773 requirement.name.clone(),
774 url.clone(),
775 ));
776 }
777 Err(err) => {
778 return Err(ParsedUrlError::MissingExtensionUrl(url.to_string(), err).into());
779 }
780 };
781
782 let verbatim_url = VerbatimUrl::from_url(verbatim_url);
783 Ok(RequirementSource::Url {
784 location: url,
785 subdirectory,
786 ext,
787 url: verbatim_url,
788 })
789}
790
791fn registry_source(
793 requirement: &uv_pep508::Requirement<VerbatimParsedUrl>,
794 index: IndexMetadata,
795 conflict: Option<ConflictItem>,
796) -> RequirementSource {
797 match &requirement.version_or_url {
798 None => RequirementSource::Registry {
799 specifier: VersionSpecifiers::empty(),
800 index: Some(index),
801 conflict,
802 },
803 Some(VersionOrUrl::VersionSpecifier(version)) => RequirementSource::Registry {
804 specifier: version.clone(),
805 index: Some(index),
806 conflict,
807 },
808 Some(VersionOrUrl::Url(_)) => RequirementSource::Registry {
809 specifier: VersionSpecifiers::empty(),
810 index: Some(index),
811 conflict,
812 },
813 }
814}
815
816async fn workspace_source(
817 requirement: &uv_pep508::Requirement<VerbatimParsedUrl>,
818 workspace_ref: &WorkspaceReference,
819 source_editable: Option<bool>,
820 default_editable: bool,
821 origin: RequirementOrigin,
822 project_dir: &Path,
823 workspace_root: &Path,
824 current_workspace: Option<&Workspace>,
825 git_member: Option<&GitWorkspaceMember<'_>>,
826 cache: &Cache,
827 workspace_cache: &WorkspaceCache,
828) -> Result<RequirementSource, LoweringError> {
829 let base = match origin {
830 RequirementOrigin::Project => project_dir,
831 RequirementOrigin::Workspace => workspace_root,
832 };
833
834 match workspace_ref {
835 WorkspaceReference::Bool(false) => Err(LoweringError::WorkspaceFalse),
836 WorkspaceReference::Bool(true) => {
837 let workspace = current_workspace.ok_or(LoweringError::WorkspaceMember)?;
838 let member = workspace.packages().get(&requirement.name).ok_or_else(|| {
839 LoweringError::UndeclaredWorkspacePackage(requirement.name.clone())
840 })?;
841
842 let value = workspace.required_members().get(&requirement.name);
843 let is_required_member = value.is_some();
844 let is_package = member.pyproject_toml().is_package(!is_required_member);
845 let editable = if is_package {
846 Some(value.copied().flatten().unwrap_or(default_editable))
847 } else {
848 Some(false)
849 };
850 path_source(
851 member.root(),
852 git_member,
853 origin,
854 project_dir,
855 workspace_root,
856 editable,
857 Some(is_package),
858 false,
859 )
860 }
861 WorkspaceReference::Path(path) => {
862 let workspace_path = VerbatimUrl::from_path(path.as_ref(), base)?
863 .to_file_path()
864 .map_err(|()| {
865 LoweringError::RelativeTo(io::Error::other("Invalid path in file URL"))
866 })?;
867 let target_workspace = Workspace::discover(
868 &workspace_path,
869 &DiscoveryOptions::default(),
870 cache,
871 workspace_cache,
872 )
873 .await?;
874
875 if target_workspace.install_path() != &workspace_path {
876 return Err(LoweringError::WorkspaceSourceNotRoot {
877 path: workspace_path,
878 root: target_workspace.install_path().clone(),
879 });
880 }
881
882 let member = target_workspace
883 .packages()
884 .get(&requirement.name)
885 .ok_or_else(|| {
886 LoweringError::UndeclaredWorkspacePackage(requirement.name.clone())
887 })?;
888
889 let is_package = member.pyproject_toml().is_package(false);
890 let editable = if is_package {
891 Some(source_editable.unwrap_or(true))
892 } else {
893 Some(false)
894 };
895 let member_path =
896 uv_fs::relative_to(member.root(), base).unwrap_or_else(|_| member.root().into());
897
898 path_source(
899 member_path,
900 git_member,
901 origin,
902 project_dir,
903 workspace_root,
904 editable,
905 Some(is_package),
906 true,
907 )
908 }
909 }
910}
911
912fn path_source(
914 path: impl AsRef<Path>,
915 git_member: Option<&GitWorkspaceMember>,
916 origin: RequirementOrigin,
917 project_dir: &Path,
918 workspace_root: &Path,
919 editable: Option<bool>,
920 package: Option<bool>,
921 preserve_given: bool,
922) -> Result<RequirementSource, LoweringError> {
923 let path = path.as_ref();
924 let base = match origin {
925 RequirementOrigin::Project => project_dir,
926 RequirementOrigin::Workspace => workspace_root,
927 };
928 let url = VerbatimUrl::from_path(path, base)?;
929 let url = if preserve_given {
930 url.with_given(path.to_string_lossy())
931 } else {
932 url
933 };
934 let install_path = url
935 .to_file_path()
936 .map_err(|()| LoweringError::RelativeTo(io::Error::other("Invalid path in file URL")))?;
937
938 let is_dir = if let Ok(metadata) = install_path.metadata() {
939 metadata.is_dir()
940 } else {
941 install_path.extension().is_none()
942 };
943 if is_dir {
944 if let Some(git_member) = git_member {
945 return git_directory_source_from_path(install_path, git_member);
946 }
947
948 if editable == Some(true) {
949 Ok(RequirementSource::Directory {
950 install_path: install_path.into_boxed_path(),
951 url,
952 editable,
953 r#virtual: Some(false),
954 })
955 } else {
956 let is_package = package.unwrap_or_else(|| {
960 let pyproject_path = install_path.join("pyproject.toml");
961 fs_err::read_to_string(&pyproject_path)
962 .ok()
963 .and_then(|contents| PyProjectToml::from_string(contents, pyproject_path).ok())
964 .is_none_or(|pyproject_toml| pyproject_toml.is_package(false))
966 });
967
968 let r#virtual = !is_package;
970
971 Ok(RequirementSource::Directory {
972 install_path: install_path.into_boxed_path(),
973 url,
974 editable: Some(false),
975 r#virtual: Some(r#virtual),
976 })
977 }
978 } else {
979 if let Some(git_member) = git_member {
980 return git_archive_source_from_path(install_path, git_member);
981 }
982 if editable == Some(true) {
983 return Err(LoweringError::EditableFile(url.to_string()));
984 }
985 if package == Some(true) {
986 return Err(LoweringError::PackagedFile(url.to_string()));
987 }
988 Ok(RequirementSource::Path {
989 ext: DistExtension::from_path(&install_path)
990 .map_err(|err| ParsedUrlError::MissingExtensionPath(path.to_path_buf(), err))?,
991 install_path: install_path.into_boxed_path(),
992 url,
993 })
994 }
995}
996
997fn git_directory_source_from_path(
998 install_path: impl AsRef<Path>,
999 git_member: &GitWorkspaceMember,
1000) -> Result<RequirementSource, LoweringError> {
1001 let git = git_member.git_source.git.clone();
1002 let install_path = git_path(install_path.as_ref())?;
1003 let fetch_root = git_path(git_member.fetch_root)?;
1004 let subdirectory = uv_fs::relative_to(&install_path, fetch_root)
1005 .map_err(|_| LoweringError::GitDirectory(install_path.display().to_string()))?;
1006 let subdirectory = normalize_path(subdirectory);
1007 let subdirectory = if subdirectory == PathBuf::new() {
1008 None
1009 } else {
1010 Some(subdirectory.into_owned().into_boxed_path())
1011 };
1012 let url = DisplaySafeUrl::from(ParsedGitDirectoryUrl {
1013 url: git.clone(),
1014 subdirectory: subdirectory.clone(),
1015 });
1016 Ok(RequirementSource::GitDirectory {
1017 git,
1018 subdirectory,
1019 url: VerbatimUrl::from_url(url),
1020 })
1021}
1022
1023fn git_archive_source_from_path(
1024 install_path: impl AsRef<Path>,
1025 git_member: &GitWorkspaceMember,
1026) -> Result<RequirementSource, LoweringError> {
1027 let git = git_member.git_source.git.clone();
1028 let install_path = git_path(install_path.as_ref())?;
1029 let fetch_root = git_path(git_member.fetch_root)?;
1030 let install_path =
1031 uv_fs::relative_to(install_path, fetch_root).map_err(LoweringError::RelativeTo)?;
1032 let install_path = normalize_path(install_path).into_owned();
1033 let ext = DistExtension::from_path(&install_path)
1034 .map_err(|err| ParsedUrlError::MissingExtensionPath(install_path.clone(), err))?;
1035 let url = DisplaySafeUrl::from(ParsedGitPathUrl {
1036 url: git.clone(),
1037 install_path: install_path.clone(),
1038 ext,
1039 });
1040 Ok(RequirementSource::GitPath {
1041 git,
1042 install_path,
1043 ext,
1044 url: VerbatimUrl::from_url(url),
1045 })
1046}
1047
1048fn git_path(path: &Path) -> Result<PathBuf, LoweringError> {
1049 path.simple_canonicalize()
1050 .or_else(|_| normalize_absolute_path(path))
1051 .map_err(LoweringError::RelativeTo)
1052}