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