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