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 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.or(source.marker());
169 }
170
171 let mut remaining = total.negate();
173 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.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 origin: requirement.origin.clone(),
313 }))
314 }
315 }))
316 .await
317 .into_iter()
318 .chain(std::iter::once(Ok(remaining)))
319 .filter(|requirement| match requirement {
320 Ok(requirement) => !requirement.0.marker.is_false(),
321 Err(_) => true,
322 }),
323 )
324 }
325
326 pub async fn from_non_workspace_requirement<'data>(
329 requirement: uv_pep508::Requirement<VerbatimParsedUrl>,
330 dir: &'data Path,
331 sources: &'data BTreeMap<PackageName, Sources>,
332 indexes: &'data [Index],
333 locations: &'data IndexLocations,
334 cache: &'data Cache,
335 workspace_cache: &'data WorkspaceCache,
336 credentials_cache: &'data CredentialsCache,
337 ) -> impl Iterator<Item = Result<Self, LoweringError>> + 'data {
338 let source = sources.get(&requirement.name).cloned();
339
340 let Some(source) = source else {
341 return Either::Left(std::iter::once(Ok(Self(Requirement::from(requirement)))));
342 };
343
344 let source = source
346 .iter()
347 .filter(|source| {
348 source.extra().is_none_or(|target| {
349 requirement
350 .marker
351 .top_level_extra_name()
352 .is_some_and(|extra| &*extra == target)
353 })
354 })
355 .cloned()
356 .collect::<Sources>();
357
358 let remaining = {
361 let mut total = MarkerTree::FALSE;
363 for source in source.iter() {
364 total.or(source.marker());
365 }
366
367 let mut remaining = total.negate();
369 remaining.and(requirement.marker);
370
371 Self(Requirement {
372 marker: remaining,
373 ..Requirement::from(requirement.clone())
374 })
375 };
376
377 Either::Right(
378 join_all(source.into_iter().map(|source| {
379 let requirement = &requirement;
380 async move {
381 let (source, mut marker) = match source {
382 Source::Git {
383 git,
384 subdirectory,
385 path,
386 rev,
387 tag,
388 branch,
389 lfs,
390 marker,
391 ..
392 } => {
393 let source = git_source(
394 git,
395 subdirectory.map(Box::<Path>::from),
396 path.map(Box::<Path>::from).map(PathBuf::from),
397 rev,
398 tag,
399 branch,
400 lfs,
401 )?;
402 (source, marker)
403 }
404 Source::Url {
405 url,
406 subdirectory,
407 marker,
408 ..
409 } => {
410 let source =
411 url_source(requirement, url, subdirectory.map(Box::<Path>::from))?;
412 (source, marker)
413 }
414 Source::Path {
415 path,
416 editable,
417 package,
418 marker,
419 ..
420 } => {
421 let source = path_source(
422 path,
423 None,
424 RequirementOrigin::Project,
425 dir,
426 dir,
427 editable,
428 package,
429 true,
430 )?;
431 (source, marker)
432 }
433 Source::Registry { index, marker, .. } => {
434 let Some(index) = locations
435 .indexes()
436 .filter(|index| matches!(index.origin, Some(Origin::Cli)))
437 .chain(indexes.iter())
438 .find(|Index { name, .. }| {
439 name.as_ref().is_some_and(|name| *name == index)
440 })
441 else {
442 let hint = missing_index_hint(locations, &index);
443 return Err(LoweringError::MissingIndex {
444 package: requirement.name.clone(),
445 index,
446 hint,
447 });
448 };
449 if let Some(credentials) = index.credentials()? {
450 credentials_cache.store_credentials(index.raw_url(), credentials);
451 }
452 let index = IndexMetadata {
453 url: index.url.clone(),
454 format: index.format,
455 };
456 let conflict = None;
457 let source = registry_source(requirement, index, conflict);
458 (source, marker)
459 }
460 Source::Workspace {
461 workspace: workspace_ref,
462 editable,
463 marker,
464 ..
465 } => {
466 let source = workspace_source(
467 requirement,
468 &workspace_ref,
469 editable,
470 true,
471 RequirementOrigin::Project,
472 dir,
473 dir,
474 None,
475 None,
476 cache,
477 workspace_cache,
478 )
479 .await?;
480 (source, marker)
481 }
482 };
483
484 marker.and(requirement.marker);
485
486 Ok(Self(Requirement {
487 name: requirement.name.clone(),
488 extras: requirement.extras.clone(),
489 groups: Box::new([]),
490 marker,
491 source,
492 origin: requirement.origin.clone(),
493 }))
494 }
495 }))
496 .await
497 .into_iter()
498 .chain(std::iter::once(Ok(remaining)))
499 .filter(|requirement| match requirement {
500 Ok(requirement) => !requirement.0.marker.is_false(),
501 Err(_) => true,
502 }),
503 )
504 }
505
506 pub(crate) fn preserve_git_source(
509 requirement: uv_pep508::Requirement<VerbatimParsedUrl>,
510 git_member: Option<&GitWorkspaceMember>,
511 ) -> Result<Self, LoweringError> {
512 let Some(git_member) = git_member else {
513 return Ok(Self(Requirement::from(requirement)));
514 };
515
516 let Some(VersionOrUrl::Url(url)) = &requirement.version_or_url else {
517 return Ok(Self(Requirement::from(requirement)));
518 };
519
520 let (install_path, is_archive) = match &url.parsed_url {
521 ParsedUrl::Directory(directory) => (directory.install_path.as_ref(), false),
522 ParsedUrl::Path(path) => (path.install_path.as_ref(), true),
523 _ => return Ok(Self(Requirement::from(requirement))),
524 };
525
526 let install_path = git_path(install_path)?;
527 let fetch_root = git_path(git_member.fetch_root)?;
528 if !install_path.starts_with(&fetch_root) {
529 return Ok(Self(Requirement::from(requirement)));
530 }
531
532 Ok(Self(Requirement {
533 name: requirement.name,
534 groups: Box::new([]),
535 extras: requirement.extras,
536 marker: requirement.marker,
537 source: if is_archive {
538 git_archive_source_from_path(&install_path, git_member)?
539 } else {
540 git_directory_source_from_path(&install_path, git_member)?
541 },
542 origin: requirement.origin,
543 }))
544 }
545
546 pub fn into_inner(self) -> Requirement {
548 self.0
549 }
550}
551
552#[derive(Debug, Error)]
555pub enum LoweringError {
556 #[error(
557 "`{0}` is included as a workspace member, but is missing an entry in `tool.uv.sources` (e.g., `{0} = {{ workspace = true }}`)"
558 )]
559 MissingWorkspaceSource(PackageName),
560 #[error(
561 "`{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 }}`)."
562 )]
563 NonWorkspaceSource(PackageName, SourceKind),
564 #[error(
565 "`{0}` references a workspace in `tool.uv.sources` (e.g., `{0} = {{ workspace = true }}`), but is not a workspace member"
566 )]
567 UndeclaredWorkspacePackage(PackageName),
568 #[error(
569 "`{0}` is included as a workspace member, but does not use `workspace = true` in `tool.uv.sources`"
570 )]
571 InvalidWorkspaceSource(PackageName),
572 #[error("Can only specify one of: `rev`, `tag`, or `branch`")]
573 MoreThanOneGitRef,
574 #[error(transparent)]
575 GitUrlParse(#[from] GitUrlParseError),
576 #[error("Package `{package}` references an undeclared index: `{index}`")]
577 MissingIndex {
578 package: PackageName,
579 index: IndexName,
580 hint: Option<String>,
581 },
582 #[error("Workspace members are not allowed in non-workspace contexts")]
583 WorkspaceMember,
584 #[error(transparent)]
585 InvalidUrl(#[from] DisplaySafeUrlError),
586 #[error(transparent)]
587 IndexCredentials(#[from] IndexCredentialsError),
588 #[error(transparent)]
589 InvalidVerbatimUrl(#[from] uv_pep508::VerbatimUrlError),
590 #[error("Fragments are not allowed in URLs: `{0}`")]
591 ForbiddenFragment(DisplaySafeUrl),
592 #[error(
593 "`{0}` is associated with a URL source, but references a Git repository. Consider using a Git source instead (e.g., `{0} = {{ git = \"{1}\" }}`)"
594 )]
595 MissingGitSource(PackageName, DisplaySafeUrl),
596 #[error("`workspace = false` is not yet supported")]
597 WorkspaceFalse,
598 #[error(transparent)]
599 Workspace(#[from] WorkspaceError),
600 #[error(
601 "Workspace source path `{}` must point to a workspace root (found workspace at `{}`)", path.simplified_display(), root.simplified_display()
602 )]
603 WorkspaceSourceNotRoot { path: PathBuf, root: PathBuf },
604 #[error("Source with `editable = true` must refer to a local directory, not a file: `{0}`")]
605 EditableFile(String),
606 #[error("Source with `package = true` must refer to a local directory, not a file: `{0}`")]
607 PackagedFile(String),
608 #[error(
609 "Git repository references local file source, but only directories are supported as transitive Git dependencies: `{0}`"
610 )]
611 GitFile(String),
612 #[error("Git repository references local directory outside the repository: `{0}`")]
613 GitDirectory(String),
614 #[error(transparent)]
615 ParsedUrl(#[from] ParsedUrlError),
616 #[error("Path must be UTF-8: `{0}`")]
617 NonUtf8Path(PathBuf),
618 #[error(transparent)] RelativeTo(io::Error),
620}
621
622impl uv_errors::Hint for LoweringError {
623 fn hints(&self) -> uv_errors::Hints<'_> {
624 match self {
625 Self::MissingIndex {
626 hint: Some(hint), ..
627 } => uv_errors::Hints::from(hint.clone()),
628 _ => uv_errors::Hints::none(),
629 }
630 }
631}
632
633#[derive(Debug, Copy, Clone)]
634pub enum SourceKind {
635 Path,
636 Url,
637 Git,
638 Registry,
639}
640
641impl std::fmt::Display for SourceKind {
642 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
643 match self {
644 Self::Path => write!(f, "path"),
645 Self::Url => write!(f, "URL"),
646 Self::Git => write!(f, "Git"),
647 Self::Registry => write!(f, "registry"),
648 }
649 }
650}
651
652fn missing_index_hint(locations: &IndexLocations, index: &IndexName) -> Option<String> {
655 let config_index = locations
656 .simple_indexes()
657 .filter(|idx| !matches!(idx.origin, Some(Origin::Cli)))
658 .find(|idx| idx.name.as_ref().is_some_and(|name| *name == *index));
659
660 config_index.and_then(|idx| {
661 let source = match idx.origin {
662 Some(Origin::User) => "a user-level `uv.toml`",
663 Some(Origin::System) => "a system-level `uv.toml`",
664 Some(Origin::Project) => "a project-level `uv.toml`",
665 Some(Origin::Cli | Origin::RequirementsTxt) | None => return None,
666 };
667 Some(format!(
668 "Index `{index}` was found in {source}, but indexes \
669 referenced via `tool.uv.sources` must be defined in the project's \
670 `pyproject.toml`"
671 ))
672 })
673}
674
675fn git_source(
677 git: DisplaySafeUrl,
678 subdirectory: Option<Box<Path>>,
679 path: Option<PathBuf>,
680 rev: Option<String>,
681 tag: Option<String>,
682 branch: Option<String>,
683 lfs: Option<bool>,
684) -> Result<RequirementSource, LoweringError> {
685 let reference = match (rev, tag, branch) {
686 (None, None, None) => GitReference::DefaultBranch,
687 (Some(rev), None, None) => GitReference::from_rev(rev),
688 (None, Some(tag), None) => GitReference::Tag(tag),
689 (None, None, Some(branch)) => GitReference::Branch(branch),
690 _ => return Err(LoweringError::MoreThanOneGitRef),
691 };
692
693 let mut url = DisplaySafeUrl::parse(&format!("git+{git}"))?;
695 if let Some(rev) = reference.as_str() {
696 let path = format!("{}@{}", url.path(), rev);
697 url.set_path(&path);
698 }
699 let mut frags: Vec<String> = Vec::new();
700 if let Some(subdirectory) = subdirectory.as_ref() {
701 let subdirectory = subdirectory
702 .to_str()
703 .ok_or_else(|| LoweringError::NonUtf8Path(subdirectory.to_path_buf()))?;
704 frags.push(format!("subdirectory={subdirectory}"));
705 }
706 let lfs = GitLfs::from(lfs);
710 if lfs.enabled() {
712 frags.push("lfs=true".to_string());
713 }
714 if let Some(path) = path.as_ref() {
715 let path = path
716 .to_str()
717 .ok_or_else(|| LoweringError::NonUtf8Path(path.clone()))?;
718 frags.push(format!("path={path}"));
719 }
720 if !frags.is_empty() {
721 url.set_fragment(Some(&frags.join("&")));
722 }
723 let url = VerbatimUrl::from_url(url);
724
725 let git = GitUrl::from_fields(git, reference, None, lfs)?;
726
727 if let Some(path) = path {
728 let ext = match DistExtension::from_path(&path) {
729 Ok(ext) => ext,
730 Err(err) => {
731 return Err(ParsedUrlError::MissingExtensionPath(path, err).into());
732 }
733 };
734 Ok(RequirementSource::GitPath {
735 url,
736 git,
737 install_path: path,
738 ext,
739 })
740 } else {
741 Ok(RequirementSource::GitDirectory {
742 url,
743 git,
744 subdirectory,
745 })
746 }
747}
748
749fn url_source(
751 requirement: &uv_pep508::Requirement<VerbatimParsedUrl>,
752 url: DisplaySafeUrl,
753 subdirectory: Option<Box<Path>>,
754) -> Result<RequirementSource, LoweringError> {
755 let mut verbatim_url = url.clone();
756 if verbatim_url.fragment().is_some() {
757 return Err(LoweringError::ForbiddenFragment(url));
758 }
759 if let Some(subdirectory) = subdirectory.as_ref() {
760 let subdirectory = subdirectory
761 .to_str()
762 .ok_or_else(|| LoweringError::NonUtf8Path(subdirectory.to_path_buf()))?;
763 verbatim_url.set_fragment(Some(&format!("subdirectory={subdirectory}")));
764 }
765
766 let ext = match DistExtension::from_path(url.path()) {
767 Ok(ext) => ext,
768 Err(..) if looks_like_git_repository(&url) => {
769 return Err(LoweringError::MissingGitSource(
770 requirement.name.clone(),
771 url.clone(),
772 ));
773 }
774 Err(err) => {
775 return Err(ParsedUrlError::MissingExtensionUrl(url.to_string(), err).into());
776 }
777 };
778
779 let verbatim_url = VerbatimUrl::from_url(verbatim_url);
780 Ok(RequirementSource::Url {
781 location: url,
782 subdirectory,
783 ext,
784 url: verbatim_url,
785 })
786}
787
788fn registry_source(
790 requirement: &uv_pep508::Requirement<VerbatimParsedUrl>,
791 index: IndexMetadata,
792 conflict: Option<ConflictItem>,
793) -> RequirementSource {
794 match &requirement.version_or_url {
795 None => RequirementSource::Registry {
796 specifier: VersionSpecifiers::empty(),
797 index: Some(index),
798 conflict,
799 },
800 Some(VersionOrUrl::VersionSpecifier(version)) => RequirementSource::Registry {
801 specifier: version.clone(),
802 index: Some(index),
803 conflict,
804 },
805 Some(VersionOrUrl::Url(_)) => RequirementSource::Registry {
806 specifier: VersionSpecifiers::empty(),
807 index: Some(index),
808 conflict,
809 },
810 }
811}
812
813async fn workspace_source(
814 requirement: &uv_pep508::Requirement<VerbatimParsedUrl>,
815 workspace_ref: &WorkspaceReference,
816 source_editable: Option<bool>,
817 default_editable: bool,
818 origin: RequirementOrigin,
819 project_dir: &Path,
820 workspace_root: &Path,
821 current_workspace: Option<&Workspace>,
822 git_member: Option<&GitWorkspaceMember<'_>>,
823 cache: &Cache,
824 workspace_cache: &WorkspaceCache,
825) -> Result<RequirementSource, LoweringError> {
826 let base = match origin {
827 RequirementOrigin::Project => project_dir,
828 RequirementOrigin::Workspace => workspace_root,
829 };
830
831 match workspace_ref {
832 WorkspaceReference::Bool(false) => Err(LoweringError::WorkspaceFalse),
833 WorkspaceReference::Bool(true) => {
834 let workspace = current_workspace.ok_or(LoweringError::WorkspaceMember)?;
835 let member = workspace.packages().get(&requirement.name).ok_or_else(|| {
836 LoweringError::UndeclaredWorkspacePackage(requirement.name.clone())
837 })?;
838
839 let value = workspace.required_members().get(&requirement.name);
840 let is_required_member = value.is_some();
841 let is_package = member.pyproject_toml().is_package(!is_required_member);
842 let editable = if is_package {
843 Some(value.copied().flatten().unwrap_or(default_editable))
844 } else {
845 Some(false)
846 };
847 path_source(
848 member.root(),
849 git_member,
850 origin,
851 project_dir,
852 workspace_root,
853 editable,
854 Some(is_package),
855 false,
856 )
857 }
858 WorkspaceReference::Path(path) => {
859 let workspace_path = VerbatimUrl::from_path(path.as_ref(), base)?
860 .to_file_path()
861 .map_err(|()| {
862 LoweringError::RelativeTo(io::Error::other("Invalid path in file URL"))
863 })?;
864 let target_workspace = Workspace::discover(
865 &workspace_path,
866 &DiscoveryOptions::default(),
867 cache,
868 workspace_cache,
869 )
870 .await?;
871
872 if target_workspace.install_path() != &workspace_path {
873 return Err(LoweringError::WorkspaceSourceNotRoot {
874 path: workspace_path,
875 root: target_workspace.install_path().clone(),
876 });
877 }
878
879 let member = target_workspace
880 .packages()
881 .get(&requirement.name)
882 .ok_or_else(|| {
883 LoweringError::UndeclaredWorkspacePackage(requirement.name.clone())
884 })?;
885
886 let is_package = member.pyproject_toml().is_package(false);
887 let editable = if is_package {
888 Some(source_editable.unwrap_or(true))
889 } else {
890 Some(false)
891 };
892 let member_path =
893 uv_fs::relative_to(member.root(), base).unwrap_or_else(|_| member.root().into());
894
895 path_source(
896 member_path,
897 git_member,
898 origin,
899 project_dir,
900 workspace_root,
901 editable,
902 Some(is_package),
903 true,
904 )
905 }
906 }
907}
908
909fn path_source(
911 path: impl AsRef<Path>,
912 git_member: Option<&GitWorkspaceMember>,
913 origin: RequirementOrigin,
914 project_dir: &Path,
915 workspace_root: &Path,
916 editable: Option<bool>,
917 package: Option<bool>,
918 preserve_given: bool,
919) -> Result<RequirementSource, LoweringError> {
920 let path = path.as_ref();
921 let base = match origin {
922 RequirementOrigin::Project => project_dir,
923 RequirementOrigin::Workspace => workspace_root,
924 };
925 let url = VerbatimUrl::from_path(path, base)?;
926 let url = if preserve_given {
927 url.with_given(path.to_string_lossy())
928 } else {
929 url
930 };
931 let install_path = url
932 .to_file_path()
933 .map_err(|()| LoweringError::RelativeTo(io::Error::other("Invalid path in file URL")))?;
934
935 let is_dir = if let Ok(metadata) = install_path.metadata() {
936 metadata.is_dir()
937 } else {
938 install_path.extension().is_none()
939 };
940 if is_dir {
941 if let Some(git_member) = git_member {
942 return git_directory_source_from_path(install_path, git_member);
943 }
944
945 if editable == Some(true) {
946 Ok(RequirementSource::Directory {
947 install_path: install_path.into_boxed_path(),
948 url,
949 editable,
950 r#virtual: Some(false),
951 })
952 } else {
953 let is_package = package.unwrap_or_else(|| {
957 let pyproject_path = install_path.join("pyproject.toml");
958 fs_err::read_to_string(&pyproject_path)
959 .ok()
960 .and_then(|contents| PyProjectToml::from_string(contents, pyproject_path).ok())
961 .is_none_or(|pyproject_toml| pyproject_toml.is_package(false))
963 });
964
965 let r#virtual = !is_package;
967
968 Ok(RequirementSource::Directory {
969 install_path: install_path.into_boxed_path(),
970 url,
971 editable: Some(false),
972 r#virtual: Some(r#virtual),
973 })
974 }
975 } else {
976 if let Some(git_member) = git_member {
977 return git_archive_source_from_path(install_path, git_member);
978 }
979 if editable == Some(true) {
980 return Err(LoweringError::EditableFile(url.to_string()));
981 }
982 if package == Some(true) {
983 return Err(LoweringError::PackagedFile(url.to_string()));
984 }
985 Ok(RequirementSource::Path {
986 ext: DistExtension::from_path(&install_path)
987 .map_err(|err| ParsedUrlError::MissingExtensionPath(path.to_path_buf(), err))?,
988 install_path: install_path.into_boxed_path(),
989 url,
990 })
991 }
992}
993
994fn git_directory_source_from_path(
995 install_path: impl AsRef<Path>,
996 git_member: &GitWorkspaceMember,
997) -> Result<RequirementSource, LoweringError> {
998 let git = git_member.git_source.git.clone();
999 let install_path = git_path(install_path.as_ref())?;
1000 let fetch_root = git_path(git_member.fetch_root)?;
1001 let subdirectory = uv_fs::relative_to(&install_path, fetch_root)
1002 .map_err(|_| LoweringError::GitDirectory(install_path.display().to_string()))?;
1003 let subdirectory = normalize_path(subdirectory);
1004 let subdirectory = if subdirectory == PathBuf::new() {
1005 None
1006 } else {
1007 Some(subdirectory.into_owned().into_boxed_path())
1008 };
1009 let url = DisplaySafeUrl::from(ParsedGitDirectoryUrl {
1010 url: git.clone(),
1011 subdirectory: subdirectory.clone(),
1012 });
1013 Ok(RequirementSource::GitDirectory {
1014 git,
1015 subdirectory,
1016 url: VerbatimUrl::from_url(url),
1017 })
1018}
1019
1020fn git_archive_source_from_path(
1021 install_path: impl AsRef<Path>,
1022 git_member: &GitWorkspaceMember,
1023) -> Result<RequirementSource, LoweringError> {
1024 let git = git_member.git_source.git.clone();
1025 let install_path = git_path(install_path.as_ref())?;
1026 let fetch_root = git_path(git_member.fetch_root)?;
1027 let install_path =
1028 uv_fs::relative_to(install_path, fetch_root).map_err(LoweringError::RelativeTo)?;
1029 let install_path = normalize_path(install_path).into_owned();
1030 let ext = DistExtension::from_path(&install_path)
1031 .map_err(|err| ParsedUrlError::MissingExtensionPath(install_path.clone(), err))?;
1032 let url = DisplaySafeUrl::from(ParsedGitPathUrl {
1033 url: git.clone(),
1034 install_path: install_path.clone(),
1035 ext,
1036 });
1037 Ok(RequirementSource::GitPath {
1038 git,
1039 install_path,
1040 ext,
1041 url: VerbatimUrl::from_url(url),
1042 })
1043}
1044
1045fn git_path(path: &Path) -> Result<PathBuf, LoweringError> {
1046 path.simple_canonicalize()
1047 .or_else(|_| normalize_absolute_path(path))
1048 .map_err(LoweringError::RelativeTo)
1049}