1use std::collections::BTreeMap;
2use std::io;
3use std::path::{Path, PathBuf};
4
5use either::Either;
6use futures::future::join_all;
7
8use thiserror::Error;
9use uv_auth::CredentialsCache;
10use uv_cache::Cache;
11use uv_distribution_filename::DistExtension;
12use uv_distribution_types::{
13 Index, IndexCredentialsError, IndexLocations, IndexMetadata, IndexName, Origin, Requirement,
14 RequirementScope, RequirementSource,
15};
16use uv_fs::{Simplified, normalize_absolute_path, normalize_path};
17use uv_git_types::{GitLfs, GitReference, GitUrl, GitUrlParseError};
18use uv_normalize::{ExtraName, GroupName, PackageName};
19use uv_pep440::VersionSpecifiers;
20use uv_pep508::{MarkerTree, VerbatimUrl, VersionOrUrl, looks_like_git_repository};
21use uv_pypi_types::{
22 ConflictItem, ParsedGitDirectoryUrl, ParsedGitPathUrl, ParsedUrl, ParsedUrlError,
23 VerbatimParsedUrl,
24};
25use uv_redacted::policy::Raw;
26use uv_redacted::{SafeUrl, SafeUrlError};
27use uv_workspace::pyproject::{PyProjectToml, Source, Sources, WorkspaceReference};
28use uv_workspace::{DiscoveryOptions, Workspace, WorkspaceCache, WorkspaceError};
29
30use crate::metadata::GitWorkspaceMember;
31
32#[derive(Debug, Clone)]
33pub struct LoweredRequirement(Requirement);
34
35#[derive(Debug, Clone, Copy)]
36enum RequirementOrigin {
37 Project,
39 Workspace,
41}
42
43impl LoweredRequirement {
44 pub(crate) async fn from_requirement<'data>(
46 requirement: uv_pep508::Requirement<VerbatimParsedUrl>,
47 project_name: Option<&'data PackageName>,
48 project_dir: &'data Path,
49 project_sources: &'data BTreeMap<PackageName, Sources>,
50 project_indexes: &'data [Index],
51 extra: Option<&ExtraName>,
52 group: Option<&GroupName>,
53 locations: &'data IndexLocations,
54 workspace: &'data Workspace,
55 git_member: Option<&'data GitWorkspaceMember<'data>>,
56 editable: bool,
57 cache: &'data Cache,
58 workspace_cache: &'data WorkspaceCache,
59 credentials_cache: &'data CredentialsCache,
60 ) -> impl Iterator<Item = Result<Self, LoweringError>> + use<'data> + 'data {
61 let (sources, origin) = if let Some(source) = project_sources.get(&requirement.name) {
63 (Some(source), RequirementOrigin::Project)
64 } else if let Some(source) = workspace.sources().get(&requirement.name) {
65 (Some(source), RequirementOrigin::Workspace)
66 } else {
67 (None, RequirementOrigin::Project)
68 };
69
70 let sources = sources.map(|sources| {
72 sources
73 .iter()
74 .filter(|source| {
75 if let Some(target) = source.extra()
76 && extra != Some(target)
77 {
78 return false;
79 }
80
81 if let Some(target) = source.group()
82 && group != Some(target)
83 {
84 return false;
85 }
86
87 true
88 })
89 .cloned()
90 .collect::<Sources>()
91 });
92
93 if workspace.packages().contains_key(&requirement.name) {
95 if project_name.is_none_or(|project_name| *project_name != requirement.name) {
98 let Some(sources) = sources.as_ref() else {
100 return Either::Left(std::iter::once(Err(
102 LoweringError::MissingWorkspaceSource(requirement.name.clone()),
103 )));
104 };
105
106 for source in sources.iter() {
107 match source {
108 Source::Git { .. } => {
109 return Either::Left(std::iter::once(Err(
110 LoweringError::NonWorkspaceSource(
111 requirement.name.clone(),
112 SourceKind::Git,
113 ),
114 )));
115 }
116 Source::Url { .. } => {
117 return Either::Left(std::iter::once(Err(
118 LoweringError::NonWorkspaceSource(
119 requirement.name.clone(),
120 SourceKind::Url,
121 ),
122 )));
123 }
124 Source::Path { .. } => {
125 return Either::Left(std::iter::once(Err(
126 LoweringError::NonWorkspaceSource(
127 requirement.name.clone(),
128 SourceKind::Path,
129 ),
130 )));
131 }
132 Source::Registry { .. } => {
133 return Either::Left(std::iter::once(Err(
134 LoweringError::NonWorkspaceSource(
135 requirement.name.clone(),
136 SourceKind::Registry,
137 ),
138 )));
139 }
140 Source::Workspace {
141 workspace: WorkspaceReference::Bool(true),
142 ..
143 } => {
144 }
146 Source::Workspace { .. } => {
147 return Either::Left(std::iter::once(Err(
148 LoweringError::InvalidWorkspaceSource(requirement.name.clone()),
149 )));
150 }
151 }
152 }
153 }
154 }
155
156 let Some(sources) = sources else {
157 return Either::Left(std::iter::once(Self::preserve_git_source(
158 requirement,
159 git_member,
160 )));
161 };
162
163 let remaining = {
166 let mut total = MarkerTree::FALSE;
168 for source in sources.iter() {
169 total = total.or(source.marker());
170 }
171
172 let mut remaining = total.negate();
174 remaining = remaining.and(requirement.marker);
175
176 Self(Requirement {
177 marker: remaining,
178 ..Requirement::from(requirement.clone())
179 })
180 };
181
182 Either::Right(
183 join_all(sources.into_iter().map(|source| {
184 let requirement = &requirement;
185 async move {
186 let (source, mut marker) = match source {
187 Source::Git {
188 git,
189 subdirectory,
190 path,
191 rev,
192 tag,
193 branch,
194 lfs,
195 marker,
196 ..
197 } => {
198 let source = git_source(
199 git.into_url(),
200 subdirectory.map(Box::<Path>::from),
201 path.map(Box::<Path>::from).map(PathBuf::from),
202 rev,
203 tag,
204 branch,
205 lfs,
206 )?;
207 (source, marker)
208 }
209 Source::Url {
210 url,
211 subdirectory,
212 marker,
213 ..
214 } => {
215 let source =
216 url_source(requirement, url, subdirectory.map(Box::<Path>::from))?;
217 (source, marker)
218 }
219 Source::Path {
220 path,
221 editable,
222 package,
223 marker,
224 ..
225 } => {
226 let source = path_source(
227 path,
228 git_member,
229 origin,
230 project_dir,
231 workspace.install_path(),
232 editable,
233 package,
234 true,
235 )?;
236 (source, marker)
237 }
238 Source::Registry {
239 index,
240 marker,
241 extra,
242 group,
243 } => {
244 let Some(index) = locations
247 .indexes()
248 .filter(|index| matches!(index.origin, Some(Origin::Cli)))
249 .chain(project_indexes.iter())
250 .chain(workspace.indexes().iter())
251 .find(|Index { name, .. }| {
252 name.as_ref().is_some_and(|name| *name == index)
253 })
254 else {
255 let hint = missing_index_hint(locations, &index);
256 return Err(LoweringError::MissingIndex {
257 package: requirement.name.clone(),
258 index,
259 hint,
260 });
261 };
262 if let Some(credentials) = index.credentials()? {
263 credentials_cache.store_credentials(index.raw_url(), credentials);
264 }
265 let index = IndexMetadata {
266 url: index.url.clone(),
267 format: index.format,
268 };
269 let conflict = project_name.and_then(|project_name| {
270 if let Some(extra) = extra {
271 Some(ConflictItem::from((project_name.clone(), extra)))
272 } else {
273 group.map(|group| {
274 ConflictItem::from((project_name.clone(), group))
275 })
276 }
277 });
278 let source = registry_source(requirement, index, conflict);
279 (source, marker)
280 }
281 Source::Workspace {
282 workspace: workspace_ref,
283 editable: source_editable,
284 marker,
285 ..
286 } => {
287 let source = workspace_source(
288 requirement,
289 &workspace_ref,
290 source_editable,
291 editable,
292 origin,
293 project_dir,
294 workspace.install_path(),
295 Some(workspace),
296 git_member,
297 cache,
298 workspace_cache,
299 )
300 .await?;
301 (source, marker)
302 }
303 };
304
305 marker = marker.and(requirement.marker);
306
307 Ok(Self(Requirement {
308 name: requirement.name.clone(),
309 extras: requirement.extras.clone(),
310 groups: Box::new([]),
311 marker,
312 source,
313 scope: RequirementScope::Global,
314 origin: requirement.origin.clone(),
315 }))
316 }
317 }))
318 .await
319 .into_iter()
320 .chain(std::iter::once(Ok(remaining)))
321 .filter(|requirement| match requirement {
322 Ok(requirement) => !requirement.0.marker.is_false(),
323 Err(_) => true,
324 }),
325 )
326 }
327
328 pub async fn from_non_workspace_requirement<'data>(
331 requirement: uv_pep508::Requirement<VerbatimParsedUrl>,
332 dir: &'data Path,
333 sources: &'data BTreeMap<PackageName, Sources>,
334 indexes: &'data [Index],
335 locations: &'data IndexLocations,
336 cache: &'data Cache,
337 workspace_cache: &'data WorkspaceCache,
338 credentials_cache: &'data CredentialsCache,
339 ) -> impl Iterator<Item = Result<Self, LoweringError>> + 'data {
340 let source = sources.get(&requirement.name).cloned();
341
342 let Some(source) = source else {
343 return Either::Left(std::iter::once(Ok(Self(Requirement::from(requirement)))));
344 };
345
346 let source = source
348 .iter()
349 .filter(|source| {
350 source.extra().is_none_or(|target| {
351 requirement
352 .marker
353 .top_level_extra_name()
354 .is_some_and(|extra| &*extra == target)
355 })
356 })
357 .cloned()
358 .collect::<Sources>();
359
360 let remaining = {
363 let mut total = MarkerTree::FALSE;
365 for source in source.iter() {
366 total = total.or(source.marker());
367 }
368
369 let mut remaining = total.negate();
371 remaining = remaining.and(requirement.marker);
372
373 Self(Requirement {
374 marker: remaining,
375 ..Requirement::from(requirement.clone())
376 })
377 };
378
379 Either::Right(
380 join_all(source.into_iter().map(|source| {
381 let requirement = &requirement;
382 async move {
383 let (source, mut marker) = match source {
384 Source::Git {
385 git,
386 subdirectory,
387 path,
388 rev,
389 tag,
390 branch,
391 lfs,
392 marker,
393 ..
394 } => {
395 let source = git_source(
396 git.into_url(),
397 subdirectory.map(Box::<Path>::from),
398 path.map(Box::<Path>::from).map(PathBuf::from),
399 rev,
400 tag,
401 branch,
402 lfs,
403 )?;
404 (source, marker)
405 }
406 Source::Url {
407 url,
408 subdirectory,
409 marker,
410 ..
411 } => {
412 let source =
413 url_source(requirement, url, subdirectory.map(Box::<Path>::from))?;
414 (source, marker)
415 }
416 Source::Path {
417 path,
418 editable,
419 package,
420 marker,
421 ..
422 } => {
423 let source = path_source(
424 path,
425 None,
426 RequirementOrigin::Project,
427 dir,
428 dir,
429 editable,
430 package,
431 true,
432 )?;
433 (source, marker)
434 }
435 Source::Registry { index, marker, .. } => {
436 let Some(index) = locations
437 .indexes()
438 .filter(|index| matches!(index.origin, Some(Origin::Cli)))
439 .chain(indexes.iter())
440 .find(|Index { name, .. }| {
441 name.as_ref().is_some_and(|name| *name == index)
442 })
443 else {
444 let hint = missing_index_hint(locations, &index);
445 return Err(LoweringError::MissingIndex {
446 package: requirement.name.clone(),
447 index,
448 hint,
449 });
450 };
451 if let Some(credentials) = index.credentials()? {
452 credentials_cache.store_credentials(index.raw_url(), credentials);
453 }
454 let index = IndexMetadata {
455 url: index.url.clone(),
456 format: index.format,
457 };
458 let conflict = None;
459 let source = registry_source(requirement, index, conflict);
460 (source, marker)
461 }
462 Source::Workspace {
463 workspace: workspace_ref,
464 editable,
465 marker,
466 ..
467 } => {
468 let source = workspace_source(
469 requirement,
470 &workspace_ref,
471 editable,
472 true,
473 RequirementOrigin::Project,
474 dir,
475 dir,
476 None,
477 None,
478 cache,
479 workspace_cache,
480 )
481 .await?;
482 (source, marker)
483 }
484 };
485
486 marker = 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 scope: RequirementScope::Global,
495 origin: requirement.origin.clone(),
496 }))
497 }
498 }))
499 .await
500 .into_iter()
501 .chain(std::iter::once(Ok(remaining)))
502 .filter(|requirement| match requirement {
503 Ok(requirement) => !requirement.0.marker.is_false(),
504 Err(_) => true,
505 }),
506 )
507 }
508
509 pub(crate) fn preserve_git_source(
512 requirement: uv_pep508::Requirement<VerbatimParsedUrl>,
513 git_member: Option<&GitWorkspaceMember>,
514 ) -> Result<Self, LoweringError> {
515 let Some(git_member) = git_member else {
516 return Ok(Self(Requirement::from(requirement)));
517 };
518
519 let Some(VersionOrUrl::Url(url)) = &requirement.version_or_url else {
520 return Ok(Self(Requirement::from(requirement)));
521 };
522
523 let (install_path, is_archive) = match &url.parsed_url {
524 ParsedUrl::Directory(directory) => (directory.install_path.as_ref(), false),
525 ParsedUrl::Path(path) => (path.install_path.as_ref(), true),
526 _ => return Ok(Self(Requirement::from(requirement))),
527 };
528
529 let install_path = git_path(install_path)?;
530 let fetch_root = git_path(git_member.fetch_root)?;
531 if !install_path.starts_with(&fetch_root) {
532 return Ok(Self(Requirement::from(requirement)));
533 }
534
535 Ok(Self(Requirement {
536 name: requirement.name,
537 groups: Box::new([]),
538 extras: requirement.extras,
539 marker: requirement.marker,
540 source: if is_archive {
541 git_archive_source_from_path(&install_path, git_member)?
542 } else {
543 git_directory_source_from_path(&install_path, git_member)?
544 },
545 scope: RequirementScope::Global,
546 origin: requirement.origin,
547 }))
548 }
549
550 pub fn into_inner(self) -> Requirement {
552 self.0
553 }
554}
555
556#[derive(Debug, Error)]
559pub enum LoweringError {
560 #[error(
561 "`{0}` is included as a workspace member, but is missing an entry in `tool.uv.sources` (e.g., `{0} = {{ workspace = true }}`)"
562 )]
563 MissingWorkspaceSource(PackageName),
564 #[error(
565 "`{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 }}`)."
566 )]
567 NonWorkspaceSource(PackageName, SourceKind),
568 #[error(
569 "`{0}` references a workspace in `tool.uv.sources` (e.g., `{0} = {{ workspace = true }}`), but is not a workspace member"
570 )]
571 UndeclaredWorkspacePackage(PackageName),
572 #[error(
573 "`{0}` is included as a workspace member, but does not use `workspace = true` in `tool.uv.sources`"
574 )]
575 InvalidWorkspaceSource(PackageName),
576 #[error("Can only specify one of: `rev`, `tag`, or `branch`")]
577 MoreThanOneGitRef,
578 #[error(transparent)]
579 GitUrlParse(#[from] GitUrlParseError),
580 #[error("Package `{package}` references an undeclared index: `{index}`")]
581 MissingIndex {
582 package: PackageName,
583 index: IndexName,
584 hint: Option<String>,
585 },
586 #[error("Workspace members are not allowed in non-workspace contexts")]
587 WorkspaceMember,
588 #[error(transparent)]
589 InvalidUrl(#[from] SafeUrlError),
590 #[error(transparent)]
591 IndexCredentials(#[from] IndexCredentialsError),
592 #[error(transparent)]
593 InvalidVerbatimUrl(#[from] uv_pep508::VerbatimUrlError),
594 #[error("Fragments are not allowed in URLs: {0}")]
595 ForbiddenFragment(SafeUrl),
596 #[error(
597 "`{0}` is associated with a URL source, but references a Git repository. Consider using a Git source instead (e.g., `{0} = {{ git = \"{1}\" }}`)"
598 )]
599 MissingGitSource(PackageName, SafeUrl),
600 #[error("`workspace = false` is not yet supported")]
601 WorkspaceFalse,
602 #[error(transparent)]
603 Workspace(#[from] WorkspaceError),
604 #[error(
605 "Workspace source path `{}` must point to a workspace root (found workspace at `{}`)", path.simplified_display(), root.simplified_display()
606 )]
607 WorkspaceSourceNotRoot { path: PathBuf, root: PathBuf },
608 #[error("Source with `editable = true` must refer to a local directory, not a file: {0}")]
609 EditableFile(String),
610 #[error("Source with `package = true` must refer to a local directory, not a file: {0}")]
611 PackagedFile(String),
612 #[error(
613 "Git repository references local file source, but only directories are supported as transitive Git dependencies: {0}"
614 )]
615 GitFile(String),
616 #[error("Git repository references local directory outside the repository: {0}")]
617 GitDirectory(String),
618 #[error(transparent)]
619 ParsedUrl(#[from] ParsedUrlError),
620 #[error("Path must be UTF-8: {0}")]
621 NonUtf8Path(PathBuf),
622 #[error(transparent)] RelativeTo(io::Error),
624}
625
626impl uv_errors::Hinted for LoweringError {
627 fn hints(&self) -> uv_errors::Hints<'_> {
628 match self {
629 Self::MissingIndex {
630 hint: Some(hint), ..
631 } => uv_errors::Hints::from(hint.clone()),
632 _ => uv_errors::Hints::none(),
633 }
634 }
635}
636
637#[derive(Debug, Copy, Clone)]
638pub enum SourceKind {
639 Path,
640 Url,
641 Git,
642 Registry,
643}
644
645impl std::fmt::Display for SourceKind {
646 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
647 match self {
648 Self::Path => write!(f, "path"),
649 Self::Url => write!(f, "URL"),
650 Self::Git => write!(f, "Git"),
651 Self::Registry => write!(f, "registry"),
652 }
653 }
654}
655
656fn missing_index_hint(locations: &IndexLocations, index: &IndexName) -> Option<String> {
659 let config_index = locations
660 .simple_indexes()
661 .filter(|idx| !matches!(idx.origin, Some(Origin::Cli)))
662 .find(|idx| idx.name.as_ref().is_some_and(|name| *name == *index));
663
664 config_index.and_then(|idx| {
665 let source = match idx.origin {
666 Some(Origin::User) => "a user-level `uv.toml`",
667 Some(Origin::System) => "a system-level `uv.toml`",
668 Some(Origin::Project) => "a project-level `uv.toml`",
669 Some(Origin::Cli | Origin::RequirementsTxt) | None => return None,
670 };
671 Some(format!(
672 "Index `{index}` was found in {source}, but indexes \
673 referenced via `tool.uv.sources` must be defined in the project's \
674 `pyproject.toml`"
675 ))
676 })
677}
678
679fn git_source(
681 git: SafeUrl,
682 subdirectory: Option<Box<Path>>,
683 path: Option<PathBuf>,
684 rev: Option<String>,
685 tag: Option<String>,
686 branch: Option<String>,
687 lfs: Option<bool>,
688) -> Result<RequirementSource, LoweringError> {
689 let reference = match (rev, tag, branch) {
690 (None, None, None) => GitReference::DefaultBranch,
691 (Some(rev), None, None) => GitReference::from_rev(rev),
692 (None, Some(tag), None) => GitReference::Tag(tag),
693 (None, None, Some(branch)) => GitReference::Branch(branch),
694 _ => return Err(LoweringError::MoreThanOneGitRef),
695 };
696
697 let mut url = SafeUrl::parse(&format!("git+{git}"))?;
699 if let Some(rev) = reference.as_str() {
700 let path = format!("{}@{}", url.path(), rev);
701 url.set_path(&path);
702 }
703 let mut frags: Vec<String> = Vec::new();
704 if let Some(subdirectory) = subdirectory.as_ref() {
705 let subdirectory = subdirectory
706 .to_str()
707 .ok_or_else(|| LoweringError::NonUtf8Path(subdirectory.to_path_buf()))?;
708 frags.push(format!("subdirectory={subdirectory}"));
709 }
710 let lfs = GitLfs::from(lfs);
714 if lfs.enabled() {
716 frags.push("lfs=true".to_string());
717 }
718 if let Some(path) = path.as_ref() {
719 let path = path
720 .to_str()
721 .ok_or_else(|| LoweringError::NonUtf8Path(path.clone()))?;
722 frags.push(format!("path={path}"));
723 }
724 if !frags.is_empty() {
725 url.set_fragment(Some(&frags.join("&")));
726 }
727 let url = VerbatimUrl::from_url(url);
728
729 let git = GitUrl::from_fields(git, reference, None, lfs)?;
730
731 if let Some(path) = path {
732 let ext = match DistExtension::from_path(&path) {
733 Ok(ext) => ext,
734 Err(err) => {
735 return Err(ParsedUrlError::MissingExtensionPath(path, err).into());
736 }
737 };
738 Ok(RequirementSource::GitPath {
739 url,
740 git,
741 install_path: path,
742 ext,
743 })
744 } else {
745 Ok(RequirementSource::GitDirectory {
746 url,
747 git,
748 subdirectory,
749 })
750 }
751}
752
753fn url_source(
755 requirement: &uv_pep508::Requirement<VerbatimParsedUrl>,
756 url: SafeUrl<Raw>,
757 subdirectory: Option<Box<Path>>,
758) -> Result<RequirementSource, LoweringError> {
759 let mut verbatim_url = url.as_url().clone();
760 if verbatim_url.fragment().is_some() {
761 return Err(LoweringError::ForbiddenFragment(url.into_url()));
762 }
763 if let Some(subdirectory) = subdirectory.as_ref() {
764 let subdirectory = subdirectory
765 .to_str()
766 .ok_or_else(|| LoweringError::NonUtf8Path(subdirectory.to_path_buf()))?;
767 verbatim_url.set_fragment(Some(&format!("subdirectory={subdirectory}")));
768 }
769
770 let ext = match DistExtension::from_path(url.path()) {
771 Ok(ext) => ext,
772 Err(..) if looks_like_git_repository(&url) => {
773 return Err(LoweringError::MissingGitSource(
774 requirement.name.clone(),
775 url.as_url().clone(),
776 ));
777 }
778 Err(err) => {
779 return Err(ParsedUrlError::MissingExtensionUrl(url.to_string(), err).into());
780 }
781 };
782
783 let verbatim_url = VerbatimUrl::from_url(verbatim_url);
784 Ok(RequirementSource::Url {
785 location: url,
786 subdirectory,
787 ext,
788 url: verbatim_url,
789 })
790}
791
792fn registry_source(
794 requirement: &uv_pep508::Requirement<VerbatimParsedUrl>,
795 index: IndexMetadata,
796 conflict: Option<ConflictItem>,
797) -> RequirementSource {
798 match &requirement.version_or_url {
799 None => RequirementSource::Registry {
800 specifier: VersionSpecifiers::empty(),
801 index: Some(index),
802 conflict,
803 },
804 Some(VersionOrUrl::VersionSpecifier(version)) => RequirementSource::Registry {
805 specifier: version.clone(),
806 index: Some(index),
807 conflict,
808 },
809 Some(VersionOrUrl::Url(_)) => RequirementSource::Registry {
810 specifier: VersionSpecifiers::empty(),
811 index: Some(index),
812 conflict,
813 },
814 }
815}
816
817async fn workspace_source(
818 requirement: &uv_pep508::Requirement<VerbatimParsedUrl>,
819 workspace_ref: &WorkspaceReference,
820 source_editable: Option<bool>,
821 default_editable: bool,
822 origin: RequirementOrigin,
823 project_dir: &Path,
824 workspace_root: &Path,
825 current_workspace: Option<&Workspace>,
826 git_member: Option<&GitWorkspaceMember<'_>>,
827 cache: &Cache,
828 workspace_cache: &WorkspaceCache,
829) -> Result<RequirementSource, LoweringError> {
830 let base = match origin {
831 RequirementOrigin::Project => project_dir,
832 RequirementOrigin::Workspace => workspace_root,
833 };
834
835 match workspace_ref {
836 WorkspaceReference::Bool(false) => Err(LoweringError::WorkspaceFalse),
837 WorkspaceReference::Bool(true) => {
838 let workspace = current_workspace.ok_or(LoweringError::WorkspaceMember)?;
839 let member = workspace.packages().get(&requirement.name).ok_or_else(|| {
840 LoweringError::UndeclaredWorkspacePackage(requirement.name.clone())
841 })?;
842
843 let value = workspace.required_members().get(&requirement.name);
844 let is_required_member = value.is_some();
845 let is_package = member.pyproject_toml().is_package(!is_required_member);
846 let editable = if is_package {
847 Some(value.copied().flatten().unwrap_or(default_editable))
848 } else {
849 Some(false)
850 };
851 path_source(
852 member.root(),
853 git_member,
854 origin,
855 project_dir,
856 workspace_root,
857 editable,
858 Some(is_package),
859 false,
860 )
861 }
862 WorkspaceReference::Path(path) => {
863 let workspace_path = VerbatimUrl::from_path(path.as_ref(), base)?
864 .to_file_path()
865 .map_err(|()| {
866 LoweringError::RelativeTo(io::Error::other("Invalid path in file URL"))
867 })?;
868 let target_workspace = Workspace::discover(
869 &workspace_path,
870 &DiscoveryOptions::default(),
871 cache,
872 workspace_cache,
873 )
874 .await?;
875
876 if target_workspace.install_path() != &workspace_path {
877 return Err(LoweringError::WorkspaceSourceNotRoot {
878 path: workspace_path,
879 root: target_workspace.install_path().clone(),
880 });
881 }
882
883 let member = target_workspace
884 .packages()
885 .get(&requirement.name)
886 .ok_or_else(|| {
887 LoweringError::UndeclaredWorkspacePackage(requirement.name.clone())
888 })?;
889
890 let is_package = member.pyproject_toml().is_package(false);
891 let editable = if is_package {
892 Some(source_editable.unwrap_or(true))
893 } else {
894 Some(false)
895 };
896 let member_path =
897 uv_fs::relative_to(member.root(), base).unwrap_or_else(|_| member.root().into());
898
899 path_source(
900 member_path,
901 git_member,
902 origin,
903 project_dir,
904 workspace_root,
905 editable,
906 Some(is_package),
907 true,
908 )
909 }
910 }
911}
912
913fn path_source(
915 path: impl AsRef<Path>,
916 git_member: Option<&GitWorkspaceMember>,
917 origin: RequirementOrigin,
918 project_dir: &Path,
919 workspace_root: &Path,
920 editable: Option<bool>,
921 package: Option<bool>,
922 preserve_given: bool,
923) -> Result<RequirementSource, LoweringError> {
924 let path = path.as_ref();
925 let base = match origin {
926 RequirementOrigin::Project => project_dir,
927 RequirementOrigin::Workspace => workspace_root,
928 };
929 let url = VerbatimUrl::from_path(path, base)?;
930 let url = if preserve_given {
931 url.with_given(path.to_string_lossy())
932 } else {
933 url
934 };
935 let install_path = url
936 .to_file_path()
937 .map_err(|()| LoweringError::RelativeTo(io::Error::other("Invalid path in file URL")))?;
938
939 let is_dir = if let Ok(metadata) = install_path.metadata() {
940 metadata.is_dir()
941 } else {
942 install_path.extension().is_none()
943 };
944 if is_dir {
945 if let Some(git_member) = git_member {
946 return git_directory_source_from_path(install_path, git_member);
947 }
948
949 if editable == Some(true) {
950 Ok(RequirementSource::Directory {
951 install_path: install_path.into_boxed_path(),
952 url,
953 editable,
954 r#virtual: Some(false),
955 })
956 } else {
957 let is_package = package.unwrap_or_else(|| {
961 let pyproject_path = install_path.join("pyproject.toml");
962 fs_err::read_to_string(&pyproject_path)
963 .ok()
964 .and_then(|contents| PyProjectToml::from_string(contents, pyproject_path).ok())
965 .is_none_or(|pyproject_toml| pyproject_toml.is_package(false))
967 });
968
969 let r#virtual = !is_package;
971
972 Ok(RequirementSource::Directory {
973 install_path: install_path.into_boxed_path(),
974 url,
975 editable: Some(false),
976 r#virtual: Some(r#virtual),
977 })
978 }
979 } else {
980 if let Some(git_member) = git_member {
981 return git_archive_source_from_path(install_path, git_member);
982 }
983 if editable == Some(true) {
984 return Err(LoweringError::EditableFile(url.to_string()));
985 }
986 if package == Some(true) {
987 return Err(LoweringError::PackagedFile(url.to_string()));
988 }
989 Ok(RequirementSource::Path {
990 ext: DistExtension::from_path(&install_path)
991 .map_err(|err| ParsedUrlError::MissingExtensionPath(path.to_path_buf(), err))?,
992 install_path: install_path.into_boxed_path(),
993 url,
994 })
995 }
996}
997
998fn git_directory_source_from_path(
999 install_path: impl AsRef<Path>,
1000 git_member: &GitWorkspaceMember,
1001) -> Result<RequirementSource, LoweringError> {
1002 let git = git_member.git_source.git.clone();
1003 let install_path = git_path(install_path.as_ref())?;
1004 let fetch_root = git_path(git_member.fetch_root)?;
1005 let subdirectory = uv_fs::relative_to(&install_path, fetch_root)
1006 .map_err(|_| LoweringError::GitDirectory(install_path.display().to_string()))?;
1007 let subdirectory = normalize_path(subdirectory);
1008 let subdirectory = if subdirectory == PathBuf::new() {
1009 None
1010 } else {
1011 Some(subdirectory.into_owned().into_boxed_path())
1012 };
1013 let url = SafeUrl::from(ParsedGitDirectoryUrl {
1014 url: git.clone(),
1015 subdirectory: subdirectory.clone(),
1016 });
1017 Ok(RequirementSource::GitDirectory {
1018 git,
1019 subdirectory,
1020 url: VerbatimUrl::from_url(url),
1021 })
1022}
1023
1024fn git_archive_source_from_path(
1025 install_path: impl AsRef<Path>,
1026 git_member: &GitWorkspaceMember,
1027) -> Result<RequirementSource, LoweringError> {
1028 let git = git_member.git_source.git.clone();
1029 let install_path = git_path(install_path.as_ref())?;
1030 let fetch_root = git_path(git_member.fetch_root)?;
1031 let install_path =
1032 uv_fs::relative_to(install_path, fetch_root).map_err(LoweringError::RelativeTo)?;
1033 let install_path = normalize_path(install_path).into_owned();
1034 let ext = DistExtension::from_path(&install_path)
1035 .map_err(|err| ParsedUrlError::MissingExtensionPath(install_path.clone(), err))?;
1036 let url = SafeUrl::from(ParsedGitPathUrl {
1037 url: git.clone(),
1038 install_path: install_path.clone(),
1039 ext,
1040 });
1041 Ok(RequirementSource::GitPath {
1042 git,
1043 install_path,
1044 ext,
1045 url: VerbatimUrl::from_url(url),
1046 })
1047}
1048
1049fn git_path(path: &Path) -> Result<PathBuf, LoweringError> {
1050 path.simple_canonicalize()
1051 .or_else(|_| normalize_absolute_path(path))
1052 .map_err(LoweringError::RelativeTo)
1053}