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