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::{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 marker,
170 ..
171 } => {
172 let source = git_source(
173 &git,
174 subdirectory.map(Box::<Path>::from),
175 rev,
176 tag,
177 branch,
178 )?;
179 (source, marker)
180 }
181 Source::Url {
182 url,
183 subdirectory,
184 marker,
185 ..
186 } => {
187 let source =
188 url_source(&requirement, url, subdirectory.map(Box::<Path>::from))?;
189 (source, marker)
190 }
191 Source::Path {
192 path,
193 editable,
194 package,
195 marker,
196 ..
197 } => {
198 let source = path_source(
199 path,
200 git_member,
201 origin,
202 project_dir,
203 workspace.install_path(),
204 editable,
205 package,
206 )?;
207 (source, marker)
208 }
209 Source::Registry {
210 index,
211 marker,
212 extra,
213 group,
214 } => {
215 let Some(index) = locations
218 .indexes()
219 .filter(|index| matches!(index.origin, Some(Origin::Cli)))
220 .chain(project_indexes.iter())
221 .chain(workspace.indexes().iter())
222 .find(|Index { name, .. }| {
223 name.as_ref().is_some_and(|name| *name == index)
224 })
225 else {
226 return Err(LoweringError::MissingIndex(
227 requirement.name.clone(),
228 index,
229 ));
230 };
231 if let Some(credentials) = index.credentials() {
232 uv_auth::store_credentials(index.raw_url(), credentials);
233 }
234 let index = IndexMetadata {
235 url: index.url.clone(),
236 format: index.format,
237 };
238 let conflict = project_name.and_then(|project_name| {
239 if let Some(extra) = extra {
240 Some(ConflictItem::from((project_name.clone(), extra)))
241 } else {
242 group.map(|group| {
243 ConflictItem::from((project_name.clone(), group))
244 })
245 }
246 });
247 let source = registry_source(&requirement, index, conflict);
248 (source, marker)
249 }
250 Source::Workspace {
251 workspace: is_workspace,
252 marker,
253 ..
254 } => {
255 if !is_workspace {
256 return Err(LoweringError::WorkspaceFalse);
257 }
258 let member = workspace
259 .packages()
260 .get(&requirement.name)
261 .ok_or_else(|| {
262 LoweringError::UndeclaredWorkspacePackage(
263 requirement.name.clone(),
264 )
265 })?
266 .clone();
267
268 let url = VerbatimUrl::from_absolute_path(member.root())?;
286 let install_path = url.to_file_path().map_err(|()| {
287 LoweringError::RelativeTo(io::Error::other(
288 "Invalid path in file URL",
289 ))
290 })?;
291
292 let source = if let Some(git_member) = &git_member {
293 let subdirectory =
296 uv_fs::relative_to(member.root(), git_member.fetch_root)
297 .expect("Workspace member must be relative");
298 let subdirectory = uv_fs::normalize_path_buf(subdirectory);
299 RequirementSource::Git {
300 git: git_member.git_source.git.clone(),
301 subdirectory: if subdirectory == PathBuf::new() {
302 None
303 } else {
304 Some(subdirectory.into_boxed_path())
305 },
306 url,
307 }
308 } else {
309 let value = workspace.required_members().get(&requirement.name);
310 let is_required_member = value.is_some();
311 let editability = value.copied().flatten();
312 if member.pyproject_toml().is_package(!is_required_member) {
313 RequirementSource::Directory {
314 install_path: install_path.into_boxed_path(),
315 url,
316 editable: Some(editability.unwrap_or(true)),
317 r#virtual: Some(false),
318 }
319 } else {
320 RequirementSource::Directory {
321 install_path: install_path.into_boxed_path(),
322 url,
323 editable: Some(false),
324 r#virtual: Some(true),
325 }
326 }
327 };
328 (source, marker)
329 }
330 };
331
332 marker.and(requirement.marker);
333
334 Ok(Self(Requirement {
335 name: requirement.name.clone(),
336 extras: requirement.extras.clone(),
337 groups: Box::new([]),
338 marker,
339 source,
340 origin: requirement.origin.clone(),
341 }))
342 })
343 .chain(std::iter::once(Ok(remaining)))
344 .filter(|requirement| match requirement {
345 Ok(requirement) => !requirement.0.marker.is_false(),
346 Err(_) => true,
347 }),
348 )
349 }
350
351 pub fn from_non_workspace_requirement<'data>(
354 requirement: uv_pep508::Requirement<VerbatimParsedUrl>,
355 dir: &'data Path,
356 sources: &'data BTreeMap<PackageName, Sources>,
357 indexes: &'data [Index],
358 locations: &'data IndexLocations,
359 ) -> impl Iterator<Item = Result<Self, LoweringError>> + 'data {
360 let source = sources.get(&requirement.name).cloned();
361
362 let Some(source) = source else {
363 return Either::Left(std::iter::once(Ok(Self(Requirement::from(requirement)))));
364 };
365
366 let source = source
368 .iter()
369 .filter(|source| {
370 source.extra().is_none_or(|target| {
371 requirement
372 .marker
373 .top_level_extra_name()
374 .is_some_and(|extra| &*extra == target)
375 })
376 })
377 .cloned()
378 .collect::<Sources>();
379
380 let remaining = {
383 let mut total = MarkerTree::FALSE;
385 for source in source.iter() {
386 total.or(source.marker());
387 }
388
389 let mut remaining = total.negate();
391 remaining.and(requirement.marker);
392
393 Self(Requirement {
394 marker: remaining,
395 ..Requirement::from(requirement.clone())
396 })
397 };
398
399 Either::Right(
400 source
401 .into_iter()
402 .map(move |source| {
403 let (source, mut marker) = match source {
404 Source::Git {
405 git,
406 subdirectory,
407 rev,
408 tag,
409 branch,
410 marker,
411 ..
412 } => {
413 let source = git_source(
414 &git,
415 subdirectory.map(Box::<Path>::from),
416 rev,
417 tag,
418 branch,
419 )?;
420 (source, marker)
421 }
422 Source::Url {
423 url,
424 subdirectory,
425 marker,
426 ..
427 } => {
428 let source =
429 url_source(&requirement, url, subdirectory.map(Box::<Path>::from))?;
430 (source, marker)
431 }
432 Source::Path {
433 path,
434 editable,
435 package,
436 marker,
437 ..
438 } => {
439 let source = path_source(
440 path,
441 None,
442 RequirementOrigin::Project,
443 dir,
444 dir,
445 editable,
446 package,
447 )?;
448 (source, marker)
449 }
450 Source::Registry { index, marker, .. } => {
451 let Some(index) = locations
452 .indexes()
453 .filter(|index| matches!(index.origin, Some(Origin::Cli)))
454 .chain(indexes.iter())
455 .find(|Index { name, .. }| {
456 name.as_ref().is_some_and(|name| *name == index)
457 })
458 else {
459 return Err(LoweringError::MissingIndex(
460 requirement.name.clone(),
461 index,
462 ));
463 };
464 if let Some(credentials) = index.credentials() {
465 uv_auth::store_credentials(index.raw_url(), credentials);
466 }
467 let index = IndexMetadata {
468 url: index.url.clone(),
469 format: index.format,
470 };
471 let conflict = None;
472 let source = registry_source(&requirement, index, conflict);
473 (source, marker)
474 }
475 Source::Workspace { .. } => {
476 return Err(LoweringError::WorkspaceMember);
477 }
478 };
479
480 marker.and(requirement.marker);
481
482 Ok(Self(Requirement {
483 name: requirement.name.clone(),
484 extras: requirement.extras.clone(),
485 groups: Box::new([]),
486 marker,
487 source,
488 origin: requirement.origin.clone(),
489 }))
490 })
491 .chain(std::iter::once(Ok(remaining)))
492 .filter(|requirement| match requirement {
493 Ok(requirement) => !requirement.0.marker.is_false(),
494 Err(_) => true,
495 }),
496 )
497 }
498
499 pub fn into_inner(self) -> Requirement {
501 self.0
502 }
503}
504
505#[derive(Debug, Error)]
508pub enum LoweringError {
509 #[error(
510 "`{0}` is included as a workspace member, but is missing an entry in `tool.uv.sources` (e.g., `{0} = {{ workspace = true }}`)"
511 )]
512 MissingWorkspaceSource(PackageName),
513 #[error(
514 "`{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 }}`)."
515 )]
516 NonWorkspaceSource(PackageName, SourceKind),
517 #[error(
518 "`{0}` references a workspace in `tool.uv.sources` (e.g., `{0} = {{ workspace = true }}`), but is not a workspace member"
519 )]
520 UndeclaredWorkspacePackage(PackageName),
521 #[error("Can only specify one of: `rev`, `tag`, or `branch`")]
522 MoreThanOneGitRef,
523 #[error(transparent)]
524 GitUrlParse(#[from] GitUrlParseError),
525 #[error("Package `{0}` references an undeclared index: `{1}`")]
526 MissingIndex(PackageName, IndexName),
527 #[error("Workspace members are not allowed in non-workspace contexts")]
528 WorkspaceMember,
529 #[error(transparent)]
530 InvalidUrl(#[from] DisplaySafeUrlError),
531 #[error(transparent)]
532 InvalidVerbatimUrl(#[from] uv_pep508::VerbatimUrlError),
533 #[error("Fragments are not allowed in URLs: `{0}`")]
534 ForbiddenFragment(DisplaySafeUrl),
535 #[error(
536 "`{0}` is associated with a URL source, but references a Git repository. Consider using a Git source instead (e.g., `{0} = {{ git = \"{1}\" }}`)"
537 )]
538 MissingGitSource(PackageName, DisplaySafeUrl),
539 #[error("`workspace = false` is not yet supported")]
540 WorkspaceFalse,
541 #[error("Source with `editable = true` must refer to a local directory, not a file: `{0}`")]
542 EditableFile(String),
543 #[error("Source with `package = true` must refer to a local directory, not a file: `{0}`")]
544 PackagedFile(String),
545 #[error(
546 "Git repository references local file source, but only directories are supported as transitive Git dependencies: `{0}`"
547 )]
548 GitFile(String),
549 #[error(transparent)]
550 ParsedUrl(#[from] ParsedUrlError),
551 #[error("Path must be UTF-8: `{0}`")]
552 NonUtf8Path(PathBuf),
553 #[error(transparent)] RelativeTo(io::Error),
555}
556
557#[derive(Debug, Copy, Clone)]
558pub enum SourceKind {
559 Path,
560 Url,
561 Git,
562 Registry,
563}
564
565impl std::fmt::Display for SourceKind {
566 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
567 match self {
568 Self::Path => write!(f, "path"),
569 Self::Url => write!(f, "URL"),
570 Self::Git => write!(f, "Git"),
571 Self::Registry => write!(f, "registry"),
572 }
573 }
574}
575
576fn git_source(
578 git: &DisplaySafeUrl,
579 subdirectory: Option<Box<Path>>,
580 rev: Option<String>,
581 tag: Option<String>,
582 branch: Option<String>,
583) -> Result<RequirementSource, LoweringError> {
584 let reference = match (rev, tag, branch) {
585 (None, None, None) => GitReference::DefaultBranch,
586 (Some(rev), None, None) => GitReference::from_rev(rev),
587 (None, Some(tag), None) => GitReference::Tag(tag),
588 (None, None, Some(branch)) => GitReference::Branch(branch),
589 _ => return Err(LoweringError::MoreThanOneGitRef),
590 };
591
592 let mut url = DisplaySafeUrl::parse(&format!("git+{git}"))?;
594 if let Some(rev) = reference.as_str() {
595 let path = format!("{}@{}", url.path(), rev);
596 url.set_path(&path);
597 }
598 if let Some(subdirectory) = subdirectory.as_ref() {
599 let subdirectory = subdirectory
600 .to_str()
601 .ok_or_else(|| LoweringError::NonUtf8Path(subdirectory.to_path_buf()))?;
602 url.set_fragment(Some(&format!("subdirectory={subdirectory}")));
603 }
604 let url = VerbatimUrl::from_url(url);
605
606 let repository = git.clone();
607
608 Ok(RequirementSource::Git {
609 url,
610 git: GitUrl::from_reference(repository, reference)?,
611 subdirectory,
612 })
613}
614
615fn url_source(
617 requirement: &uv_pep508::Requirement<VerbatimParsedUrl>,
618 url: DisplaySafeUrl,
619 subdirectory: Option<Box<Path>>,
620) -> Result<RequirementSource, LoweringError> {
621 let mut verbatim_url = url.clone();
622 if verbatim_url.fragment().is_some() {
623 return Err(LoweringError::ForbiddenFragment(url));
624 }
625 if let Some(subdirectory) = subdirectory.as_ref() {
626 let subdirectory = subdirectory
627 .to_str()
628 .ok_or_else(|| LoweringError::NonUtf8Path(subdirectory.to_path_buf()))?;
629 verbatim_url.set_fragment(Some(&format!("subdirectory={subdirectory}")));
630 }
631
632 let ext = match DistExtension::from_path(url.path()) {
633 Ok(ext) => ext,
634 Err(..) if looks_like_git_repository(&url) => {
635 return Err(LoweringError::MissingGitSource(
636 requirement.name.clone(),
637 url.clone(),
638 ));
639 }
640 Err(err) => {
641 return Err(ParsedUrlError::MissingExtensionUrl(url.to_string(), err).into());
642 }
643 };
644
645 let verbatim_url = VerbatimUrl::from_url(verbatim_url);
646 Ok(RequirementSource::Url {
647 location: url,
648 subdirectory,
649 ext,
650 url: verbatim_url,
651 })
652}
653
654fn registry_source(
656 requirement: &uv_pep508::Requirement<VerbatimParsedUrl>,
657 index: IndexMetadata,
658 conflict: Option<ConflictItem>,
659) -> RequirementSource {
660 match &requirement.version_or_url {
661 None => RequirementSource::Registry {
662 specifier: VersionSpecifiers::empty(),
663 index: Some(index),
664 conflict,
665 },
666 Some(VersionOrUrl::VersionSpecifier(version)) => RequirementSource::Registry {
667 specifier: version.clone(),
668 index: Some(index),
669 conflict,
670 },
671 Some(VersionOrUrl::Url(_)) => RequirementSource::Registry {
672 specifier: VersionSpecifiers::empty(),
673 index: Some(index),
674 conflict,
675 },
676 }
677}
678
679fn path_source(
681 path: impl AsRef<Path>,
682 git_member: Option<&GitWorkspaceMember>,
683 origin: RequirementOrigin,
684 project_dir: &Path,
685 workspace_root: &Path,
686 editable: Option<bool>,
687 package: Option<bool>,
688) -> Result<RequirementSource, LoweringError> {
689 let path = path.as_ref();
690 let base = match origin {
691 RequirementOrigin::Project => project_dir,
692 RequirementOrigin::Workspace => workspace_root,
693 };
694 let url = VerbatimUrl::from_path(path, base)?.with_given(path.to_string_lossy());
695 let install_path = url
696 .to_file_path()
697 .map_err(|()| LoweringError::RelativeTo(io::Error::other("Invalid path in file URL")))?;
698
699 let is_dir = if let Ok(metadata) = install_path.metadata() {
700 metadata.is_dir()
701 } else {
702 install_path.extension().is_none()
703 };
704 if is_dir {
705 if let Some(git_member) = git_member {
706 let git = git_member.git_source.git.clone();
707 let subdirectory = uv_fs::relative_to(install_path, git_member.fetch_root)
708 .expect("Workspace member must be relative");
709 let subdirectory = uv_fs::normalize_path_buf(subdirectory);
710 let subdirectory = if subdirectory == PathBuf::new() {
711 None
712 } else {
713 Some(subdirectory.into_boxed_path())
714 };
715 let url = DisplaySafeUrl::from(ParsedGitUrl {
716 url: git.clone(),
717 subdirectory: subdirectory.clone(),
718 });
719 return Ok(RequirementSource::Git {
720 git,
721 subdirectory,
722 url: VerbatimUrl::from_url(url),
723 });
724 }
725
726 if editable == Some(true) {
727 Ok(RequirementSource::Directory {
728 install_path: install_path.into_boxed_path(),
729 url,
730 editable,
731 r#virtual: Some(false),
732 })
733 } else {
734 let is_package = package.unwrap_or_else(|| {
738 let pyproject_path = install_path.join("pyproject.toml");
739 fs_err::read_to_string(&pyproject_path)
740 .ok()
741 .and_then(|contents| PyProjectToml::from_string(contents).ok())
742 .map(|pyproject_toml| pyproject_toml.is_package(false))
744 .unwrap_or(true)
745 });
746
747 let r#virtual = !is_package;
749
750 Ok(RequirementSource::Directory {
751 install_path: install_path.into_boxed_path(),
752 url,
753 editable: Some(false),
754 r#virtual: Some(r#virtual),
755 })
756 }
757 } else {
758 if git_member.is_some() {
760 return Err(LoweringError::GitFile(url.to_string()));
761 }
762 if editable == Some(true) {
763 return Err(LoweringError::EditableFile(url.to_string()));
764 }
765 if package == Some(true) {
766 return Err(LoweringError::PackagedFile(url.to_string()));
767 }
768 Ok(RequirementSource::Path {
769 ext: DistExtension::from_path(&install_path)
770 .map_err(|err| ParsedUrlError::MissingExtensionPath(path.to_path_buf(), err))?,
771 install_path: install_path.into_boxed_path(),
772 url,
773 })
774 }
775}