1use std::collections::BTreeMap;
2use std::io;
3use std::path::{Path, PathBuf};
4
5use either::Either;
6use owo_colors::OwoColorize;
7use thiserror::Error;
8use uv_auth::CredentialsCache;
9use uv_distribution_filename::DistExtension;
10use uv_distribution_types::{
11 Index, IndexLocations, IndexMetadata, IndexName, Origin, Requirement, RequirementSource,
12};
13use uv_git_types::{GitLfs, GitReference, GitUrl, GitUrlParseError};
14use uv_normalize::{ExtraName, GroupName, PackageName};
15use uv_pep440::VersionSpecifiers;
16use uv_pep508::{MarkerTree, VerbatimUrl, VersionOrUrl, looks_like_git_repository};
17use uv_pypi_types::{ConflictItem, ParsedGitUrl, ParsedUrlError, VerbatimParsedUrl};
18use uv_redacted::{DisplaySafeUrl, DisplaySafeUrlError};
19use uv_workspace::Workspace;
20use uv_workspace::pyproject::{PyProjectToml, Source, Sources};
21
22use crate::metadata::GitWorkspaceMember;
23
24#[derive(Debug, Clone)]
25pub struct LoweredRequirement(Requirement);
26
27#[derive(Debug, Clone, Copy)]
28enum RequirementOrigin {
29 Project,
31 Workspace,
33}
34
35impl LoweredRequirement {
36 pub(crate) fn from_requirement<'data>(
38 requirement: uv_pep508::Requirement<VerbatimParsedUrl>,
39 project_name: Option<&'data PackageName>,
40 project_dir: &'data Path,
41 project_sources: &'data BTreeMap<PackageName, Sources>,
42 project_indexes: &'data [Index],
43 extra: Option<&ExtraName>,
44 group: Option<&GroupName>,
45 locations: &'data IndexLocations,
46 workspace: &'data Workspace,
47 git_member: Option<&'data GitWorkspaceMember<'data>>,
48 credentials_cache: &'data CredentialsCache,
49 ) -> impl Iterator<Item = Result<Self, LoweringError>> + use<'data> + 'data {
50 let (sources, origin) = if let Some(source) = project_sources.get(&requirement.name) {
52 (Some(source), RequirementOrigin::Project)
53 } else if let Some(source) = workspace.sources().get(&requirement.name) {
54 (Some(source), RequirementOrigin::Workspace)
55 } else {
56 (None, RequirementOrigin::Project)
57 };
58
59 let sources = sources.map(|sources| {
61 sources
62 .iter()
63 .filter(|source| {
64 if let Some(target) = source.extra() {
65 if extra != Some(target) {
66 return false;
67 }
68 }
69
70 if let Some(target) = source.group() {
71 if group != Some(target) {
72 return false;
73 }
74 }
75
76 true
77 })
78 .cloned()
79 .collect::<Sources>()
80 });
81
82 if workspace.packages().contains_key(&requirement.name) {
84 if project_name.is_none_or(|project_name| *project_name != requirement.name) {
87 let Some(sources) = sources.as_ref() else {
89 return Either::Left(std::iter::once(Err(
91 LoweringError::MissingWorkspaceSource(requirement.name.clone()),
92 )));
93 };
94
95 for source in sources.iter() {
96 match source {
97 Source::Git { .. } => {
98 return Either::Left(std::iter::once(Err(
99 LoweringError::NonWorkspaceSource(
100 requirement.name.clone(),
101 SourceKind::Git,
102 ),
103 )));
104 }
105 Source::Url { .. } => {
106 return Either::Left(std::iter::once(Err(
107 LoweringError::NonWorkspaceSource(
108 requirement.name.clone(),
109 SourceKind::Url,
110 ),
111 )));
112 }
113 Source::Path { .. } => {
114 return Either::Left(std::iter::once(Err(
115 LoweringError::NonWorkspaceSource(
116 requirement.name.clone(),
117 SourceKind::Path,
118 ),
119 )));
120 }
121 Source::Registry { .. } => {
122 return Either::Left(std::iter::once(Err(
123 LoweringError::NonWorkspaceSource(
124 requirement.name.clone(),
125 SourceKind::Registry,
126 ),
127 )));
128 }
129 Source::Workspace { .. } => {
130 }
132 }
133 }
134 }
135 }
136
137 let Some(sources) = sources else {
138 return Either::Left(std::iter::once(Ok(Self(Requirement::from(requirement)))));
139 };
140
141 let remaining = {
144 let mut total = MarkerTree::FALSE;
146 for source in sources.iter() {
147 total.or(source.marker());
148 }
149
150 let mut remaining = total.negate();
152 remaining.and(requirement.marker);
153
154 Self(Requirement {
155 marker: remaining,
156 ..Requirement::from(requirement.clone())
157 })
158 };
159
160 Either::Right(
161 sources
162 .into_iter()
163 .map(move |source| {
164 let (source, mut marker) = match source {
165 Source::Git {
166 git,
167 subdirectory,
168 rev,
169 tag,
170 branch,
171 lfs,
172 marker,
173 ..
174 } => {
175 let source = git_source(
176 &git,
177 subdirectory.map(Box::<Path>::from),
178 rev,
179 tag,
180 branch,
181 lfs,
182 )?;
183 (source, marker)
184 }
185 Source::Url {
186 url,
187 subdirectory,
188 marker,
189 ..
190 } => {
191 let source =
192 url_source(&requirement, url, subdirectory.map(Box::<Path>::from))?;
193 (source, marker)
194 }
195 Source::Path {
196 path,
197 editable,
198 package,
199 marker,
200 ..
201 } => {
202 let source = path_source(
203 path,
204 git_member,
205 origin,
206 project_dir,
207 workspace.install_path(),
208 editable,
209 package,
210 )?;
211 (source, marker)
212 }
213 Source::Registry {
214 index,
215 marker,
216 extra,
217 group,
218 } => {
219 let Some(index) = locations
222 .indexes()
223 .filter(|index| matches!(index.origin, Some(Origin::Cli)))
224 .chain(project_indexes.iter())
225 .chain(workspace.indexes().iter())
226 .find(|Index { name, .. }| {
227 name.as_ref().is_some_and(|name| *name == index)
228 })
229 else {
230 let hint = missing_index_hint(locations, &index);
231 return Err(LoweringError::MissingIndex {
232 package: requirement.name.clone(),
233 index,
234 hint,
235 });
236 };
237 if let Some(credentials) = index.credentials() {
238 credentials_cache.store_credentials(index.raw_url(), credentials);
239 }
240 let index = IndexMetadata {
241 url: index.url.clone(),
242 format: index.format,
243 };
244 let conflict = project_name.and_then(|project_name| {
245 if let Some(extra) = extra {
246 Some(ConflictItem::from((project_name.clone(), extra)))
247 } else {
248 group.map(|group| {
249 ConflictItem::from((project_name.clone(), group))
250 })
251 }
252 });
253 let source = registry_source(&requirement, index, conflict);
254 (source, marker)
255 }
256 Source::Workspace {
257 workspace: is_workspace,
258 marker,
259 ..
260 } => {
261 if !is_workspace {
262 return Err(LoweringError::WorkspaceFalse);
263 }
264 let member = workspace
265 .packages()
266 .get(&requirement.name)
267 .ok_or_else(|| {
268 LoweringError::UndeclaredWorkspacePackage(
269 requirement.name.clone(),
270 )
271 })?
272 .clone();
273
274 let url = VerbatimUrl::from_absolute_path(member.root())?;
292 let install_path = url.to_file_path().map_err(|()| {
293 LoweringError::RelativeTo(io::Error::other(
294 "Invalid path in file URL",
295 ))
296 })?;
297
298 let source = if let Some(git_member) = &git_member {
299 let subdirectory =
302 uv_fs::relative_to(member.root(), git_member.fetch_root)
303 .expect("Workspace member must be relative");
304 let subdirectory = uv_fs::normalize_path_buf(subdirectory);
305 RequirementSource::Git {
306 git: git_member.git_source.git.clone(),
307 subdirectory: if subdirectory == PathBuf::new() {
308 None
309 } else {
310 Some(subdirectory.into_boxed_path())
311 },
312 url,
313 }
314 } else {
315 let value = workspace.required_members().get(&requirement.name);
316 let is_required_member = value.is_some();
317 let editability = value.copied().flatten();
318 if member.pyproject_toml().is_package(!is_required_member) {
319 RequirementSource::Directory {
320 install_path: install_path.into_boxed_path(),
321 url,
322 editable: Some(editability.unwrap_or(true)),
323 r#virtual: Some(false),
324 }
325 } else {
326 RequirementSource::Directory {
327 install_path: install_path.into_boxed_path(),
328 url,
329 editable: Some(false),
330 r#virtual: Some(true),
331 }
332 }
333 };
334 (source, marker)
335 }
336 };
337
338 marker.and(requirement.marker);
339
340 Ok(Self(Requirement {
341 name: requirement.name.clone(),
342 extras: requirement.extras.clone(),
343 groups: Box::new([]),
344 marker,
345 source,
346 origin: requirement.origin.clone(),
347 }))
348 })
349 .chain(std::iter::once(Ok(remaining)))
350 .filter(|requirement| match requirement {
351 Ok(requirement) => !requirement.0.marker.is_false(),
352 Err(_) => true,
353 }),
354 )
355 }
356
357 pub fn from_non_workspace_requirement<'data>(
360 requirement: uv_pep508::Requirement<VerbatimParsedUrl>,
361 dir: &'data Path,
362 sources: &'data BTreeMap<PackageName, Sources>,
363 indexes: &'data [Index],
364 locations: &'data IndexLocations,
365 credentials_cache: &'data CredentialsCache,
366 ) -> impl Iterator<Item = Result<Self, LoweringError>> + 'data {
367 let source = sources.get(&requirement.name).cloned();
368
369 let Some(source) = source else {
370 return Either::Left(std::iter::once(Ok(Self(Requirement::from(requirement)))));
371 };
372
373 let source = source
375 .iter()
376 .filter(|source| {
377 source.extra().is_none_or(|target| {
378 requirement
379 .marker
380 .top_level_extra_name()
381 .is_some_and(|extra| &*extra == target)
382 })
383 })
384 .cloned()
385 .collect::<Sources>();
386
387 let remaining = {
390 let mut total = MarkerTree::FALSE;
392 for source in source.iter() {
393 total.or(source.marker());
394 }
395
396 let mut remaining = total.negate();
398 remaining.and(requirement.marker);
399
400 Self(Requirement {
401 marker: remaining,
402 ..Requirement::from(requirement.clone())
403 })
404 };
405
406 Either::Right(
407 source
408 .into_iter()
409 .map(move |source| {
410 let (source, mut marker) = match source {
411 Source::Git {
412 git,
413 subdirectory,
414 rev,
415 tag,
416 branch,
417 lfs,
418 marker,
419 ..
420 } => {
421 let source = git_source(
422 &git,
423 subdirectory.map(Box::<Path>::from),
424 rev,
425 tag,
426 branch,
427 lfs,
428 )?;
429 (source, marker)
430 }
431 Source::Url {
432 url,
433 subdirectory,
434 marker,
435 ..
436 } => {
437 let source =
438 url_source(&requirement, url, subdirectory.map(Box::<Path>::from))?;
439 (source, marker)
440 }
441 Source::Path {
442 path,
443 editable,
444 package,
445 marker,
446 ..
447 } => {
448 let source = path_source(
449 path,
450 None,
451 RequirementOrigin::Project,
452 dir,
453 dir,
454 editable,
455 package,
456 )?;
457 (source, marker)
458 }
459 Source::Registry { index, marker, .. } => {
460 let Some(index) = locations
461 .indexes()
462 .filter(|index| matches!(index.origin, Some(Origin::Cli)))
463 .chain(indexes.iter())
464 .find(|Index { name, .. }| {
465 name.as_ref().is_some_and(|name| *name == index)
466 })
467 else {
468 let hint = missing_index_hint(locations, &index);
469 return Err(LoweringError::MissingIndex {
470 package: requirement.name.clone(),
471 index,
472 hint,
473 });
474 };
475 if let Some(credentials) = index.credentials() {
476 credentials_cache.store_credentials(index.raw_url(), credentials);
477 }
478 let index = IndexMetadata {
479 url: index.url.clone(),
480 format: index.format,
481 };
482 let conflict = None;
483 let source = registry_source(&requirement, index, conflict);
484 (source, marker)
485 }
486 Source::Workspace { .. } => {
487 return Err(LoweringError::WorkspaceMember);
488 }
489 };
490
491 marker.and(requirement.marker);
492
493 Ok(Self(Requirement {
494 name: requirement.name.clone(),
495 extras: requirement.extras.clone(),
496 groups: Box::new([]),
497 marker,
498 source,
499 origin: requirement.origin.clone(),
500 }))
501 })
502 .chain(std::iter::once(Ok(remaining)))
503 .filter(|requirement| match requirement {
504 Ok(requirement) => !requirement.0.marker.is_false(),
505 Err(_) => true,
506 }),
507 )
508 }
509
510 pub fn into_inner(self) -> Requirement {
512 self.0
513 }
514}
515
516#[derive(Debug, Error)]
519pub enum LoweringError {
520 #[error(
521 "`{0}` is included as a workspace member, but is missing an entry in `tool.uv.sources` (e.g., `{0} = {{ workspace = true }}`)"
522 )]
523 MissingWorkspaceSource(PackageName),
524 #[error(
525 "`{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 }}`)."
526 )]
527 NonWorkspaceSource(PackageName, SourceKind),
528 #[error(
529 "`{0}` references a workspace in `tool.uv.sources` (e.g., `{0} = {{ workspace = true }}`), but is not a workspace member"
530 )]
531 UndeclaredWorkspacePackage(PackageName),
532 #[error("Can only specify one of: `rev`, `tag`, or `branch`")]
533 MoreThanOneGitRef,
534 #[error(transparent)]
535 GitUrlParse(#[from] GitUrlParseError),
536 #[error("Package `{package}` references an undeclared index: `{index}`{}", if let Some(hint) = hint { format!("\n\n{}{} {hint}", "hint".bold().cyan(), ":".bold()) } else { String::new() })]
537 MissingIndex {
538 package: PackageName,
539 index: IndexName,
540 hint: Option<String>,
541 },
542 #[error("Workspace members are not allowed in non-workspace contexts")]
543 WorkspaceMember,
544 #[error(transparent)]
545 InvalidUrl(#[from] DisplaySafeUrlError),
546 #[error(transparent)]
547 InvalidVerbatimUrl(#[from] uv_pep508::VerbatimUrlError),
548 #[error("Fragments are not allowed in URLs: `{0}`")]
549 ForbiddenFragment(DisplaySafeUrl),
550 #[error(
551 "`{0}` is associated with a URL source, but references a Git repository. Consider using a Git source instead (e.g., `{0} = {{ git = \"{1}\" }}`)"
552 )]
553 MissingGitSource(PackageName, DisplaySafeUrl),
554 #[error("`workspace = false` is not yet supported")]
555 WorkspaceFalse,
556 #[error("Source with `editable = true` must refer to a local directory, not a file: `{0}`")]
557 EditableFile(String),
558 #[error("Source with `package = true` must refer to a local directory, not a file: `{0}`")]
559 PackagedFile(String),
560 #[error(
561 "Git repository references local file source, but only directories are supported as transitive Git dependencies: `{0}`"
562 )]
563 GitFile(String),
564 #[error(transparent)]
565 ParsedUrl(#[from] ParsedUrlError),
566 #[error("Path must be UTF-8: `{0}`")]
567 NonUtf8Path(PathBuf),
568 #[error(transparent)] RelativeTo(io::Error),
570}
571
572#[derive(Debug, Copy, Clone)]
573pub enum SourceKind {
574 Path,
575 Url,
576 Git,
577 Registry,
578}
579
580impl std::fmt::Display for SourceKind {
581 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
582 match self {
583 Self::Path => write!(f, "path"),
584 Self::Url => write!(f, "URL"),
585 Self::Git => write!(f, "Git"),
586 Self::Registry => write!(f, "registry"),
587 }
588 }
589}
590
591fn missing_index_hint(locations: &IndexLocations, index: &IndexName) -> Option<String> {
594 let config_index = locations
595 .simple_indexes()
596 .filter(|idx| !matches!(idx.origin, Some(Origin::Cli)))
597 .find(|idx| idx.name.as_ref().is_some_and(|name| *name == *index));
598
599 config_index.and_then(|idx| {
600 let source = match idx.origin {
601 Some(Origin::User) => "a user-level `uv.toml`",
602 Some(Origin::System) => "a system-level `uv.toml`",
603 Some(Origin::Project) => "a project-level `uv.toml`",
604 Some(Origin::Cli | Origin::RequirementsTxt) | None => return None,
605 };
606 Some(format!(
607 "Index `{index}` was found in {source}, but indexes \
608 referenced via `tool.uv.sources` must be defined in the project's \
609 `pyproject.toml`"
610 ))
611 })
612}
613
614fn git_source(
616 git: &DisplaySafeUrl,
617 subdirectory: Option<Box<Path>>,
618 rev: Option<String>,
619 tag: Option<String>,
620 branch: Option<String>,
621 lfs: Option<bool>,
622) -> Result<RequirementSource, LoweringError> {
623 let reference = match (rev, tag, branch) {
624 (None, None, None) => GitReference::DefaultBranch,
625 (Some(rev), None, None) => GitReference::from_rev(rev),
626 (None, Some(tag), None) => GitReference::Tag(tag),
627 (None, None, Some(branch)) => GitReference::Branch(branch),
628 _ => return Err(LoweringError::MoreThanOneGitRef),
629 };
630
631 let mut url = DisplaySafeUrl::parse(&format!("git+{git}"))?;
633 if let Some(rev) = reference.as_str() {
634 let path = format!("{}@{}", url.path(), rev);
635 url.set_path(&path);
636 }
637 let mut frags: Vec<String> = Vec::new();
638 if let Some(subdirectory) = subdirectory.as_ref() {
639 let subdirectory = subdirectory
640 .to_str()
641 .ok_or_else(|| LoweringError::NonUtf8Path(subdirectory.to_path_buf()))?;
642 frags.push(format!("subdirectory={subdirectory}"));
643 }
644 let lfs = GitLfs::from(lfs);
648 if lfs.enabled() {
650 frags.push("lfs=true".to_string());
651 }
652 if !frags.is_empty() {
653 url.set_fragment(Some(&frags.join("&")));
654 }
655
656 let url = VerbatimUrl::from_url(url);
657
658 let repository = git.clone();
659
660 Ok(RequirementSource::Git {
661 url,
662 git: GitUrl::from_fields(repository, reference, None, lfs)?,
663 subdirectory,
664 })
665}
666
667fn url_source(
669 requirement: &uv_pep508::Requirement<VerbatimParsedUrl>,
670 url: DisplaySafeUrl,
671 subdirectory: Option<Box<Path>>,
672) -> Result<RequirementSource, LoweringError> {
673 let mut verbatim_url = url.clone();
674 if verbatim_url.fragment().is_some() {
675 return Err(LoweringError::ForbiddenFragment(url));
676 }
677 if let Some(subdirectory) = subdirectory.as_ref() {
678 let subdirectory = subdirectory
679 .to_str()
680 .ok_or_else(|| LoweringError::NonUtf8Path(subdirectory.to_path_buf()))?;
681 verbatim_url.set_fragment(Some(&format!("subdirectory={subdirectory}")));
682 }
683
684 let ext = match DistExtension::from_path(url.path()) {
685 Ok(ext) => ext,
686 Err(..) if looks_like_git_repository(&url) => {
687 return Err(LoweringError::MissingGitSource(
688 requirement.name.clone(),
689 url.clone(),
690 ));
691 }
692 Err(err) => {
693 return Err(ParsedUrlError::MissingExtensionUrl(url.to_string(), err).into());
694 }
695 };
696
697 let verbatim_url = VerbatimUrl::from_url(verbatim_url);
698 Ok(RequirementSource::Url {
699 location: url,
700 subdirectory,
701 ext,
702 url: verbatim_url,
703 })
704}
705
706fn registry_source(
708 requirement: &uv_pep508::Requirement<VerbatimParsedUrl>,
709 index: IndexMetadata,
710 conflict: Option<ConflictItem>,
711) -> RequirementSource {
712 match &requirement.version_or_url {
713 None => RequirementSource::Registry {
714 specifier: VersionSpecifiers::empty(),
715 index: Some(index),
716 conflict,
717 },
718 Some(VersionOrUrl::VersionSpecifier(version)) => RequirementSource::Registry {
719 specifier: version.clone(),
720 index: Some(index),
721 conflict,
722 },
723 Some(VersionOrUrl::Url(_)) => RequirementSource::Registry {
724 specifier: VersionSpecifiers::empty(),
725 index: Some(index),
726 conflict,
727 },
728 }
729}
730
731fn path_source(
733 path: impl AsRef<Path>,
734 git_member: Option<&GitWorkspaceMember>,
735 origin: RequirementOrigin,
736 project_dir: &Path,
737 workspace_root: &Path,
738 editable: Option<bool>,
739 package: Option<bool>,
740) -> Result<RequirementSource, LoweringError> {
741 let path = path.as_ref();
742 let base = match origin {
743 RequirementOrigin::Project => project_dir,
744 RequirementOrigin::Workspace => workspace_root,
745 };
746 let url = VerbatimUrl::from_path(path, base)?.with_given(path.to_string_lossy());
747 let install_path = url
748 .to_file_path()
749 .map_err(|()| LoweringError::RelativeTo(io::Error::other("Invalid path in file URL")))?;
750
751 let is_dir = if let Ok(metadata) = install_path.metadata() {
752 metadata.is_dir()
753 } else {
754 install_path.extension().is_none()
755 };
756 if is_dir {
757 if let Some(git_member) = git_member {
758 let git = git_member.git_source.git.clone();
759 let subdirectory = uv_fs::relative_to(install_path, git_member.fetch_root)
760 .expect("Workspace member must be relative");
761 let subdirectory = uv_fs::normalize_path_buf(subdirectory);
762 let subdirectory = if subdirectory == PathBuf::new() {
763 None
764 } else {
765 Some(subdirectory.into_boxed_path())
766 };
767 let url = DisplaySafeUrl::from(ParsedGitUrl {
768 url: git.clone(),
769 subdirectory: subdirectory.clone(),
770 });
771 return Ok(RequirementSource::Git {
772 git,
773 subdirectory,
774 url: VerbatimUrl::from_url(url),
775 });
776 }
777
778 if editable == Some(true) {
779 Ok(RequirementSource::Directory {
780 install_path: install_path.into_boxed_path(),
781 url,
782 editable,
783 r#virtual: Some(false),
784 })
785 } else {
786 let is_package = package.unwrap_or_else(|| {
790 let pyproject_path = install_path.join("pyproject.toml");
791 fs_err::read_to_string(&pyproject_path)
792 .ok()
793 .and_then(|contents| PyProjectToml::from_string(contents).ok())
794 .map(|pyproject_toml| pyproject_toml.is_package(false))
796 .unwrap_or(true)
797 });
798
799 let r#virtual = !is_package;
801
802 Ok(RequirementSource::Directory {
803 install_path: install_path.into_boxed_path(),
804 url,
805 editable: Some(false),
806 r#virtual: Some(r#virtual),
807 })
808 }
809 } else {
810 if git_member.is_some() {
812 return Err(LoweringError::GitFile(url.to_string()));
813 }
814 if editable == Some(true) {
815 return Err(LoweringError::EditableFile(url.to_string()));
816 }
817 if package == Some(true) {
818 return Err(LoweringError::PackagedFile(url.to_string()));
819 }
820 Ok(RequirementSource::Path {
821 ext: DistExtension::from_path(&install_path)
822 .map_err(|err| ParsedUrlError::MissingExtensionPath(path.to_path_buf(), err))?,
823 install_path: install_path.into_boxed_path(),
824 url,
825 })
826 }
827}