Skip to main content

uv_distribution/metadata/
lowering.rs

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    /// The `tool.uv.sources` were read from the project.
38    Project,
39    /// The `tool.uv.sources` were read from the workspace root.
40    Workspace,
41}
42
43impl LoweredRequirement {
44    /// Combine `project.dependencies` or `project.optional-dependencies` with `tool.uv.sources`.
45    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        // Identify the source from the `tool.uv.sources` table.
62        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        // If the source only applies to a given extra or dependency group, filter it out.
71        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 you use a package that's part of the workspace...
94        if workspace.packages().contains_key(&requirement.name) {
95            // And it's not a recursive self-inclusion (extras that activate other extras), e.g.
96            // `framework[machine_learning]` depends on `framework[cuda]`.
97            if project_name.is_none_or(|project_name| *project_name != requirement.name) {
98                // It must be declared as a workspace source.
99                let Some(sources) = sources.as_ref() else {
100                    // No sources were declared for the workspace package.
101                    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                            // OK
145                        }
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        // Determine whether the markers cover the full space for the requirement. If not, fill the
164        // remaining space with the negation of the sources.
165        let remaining = {
166            // Determine the space covered by the sources.
167            let mut total = MarkerTree::FALSE;
168            for source in sources.iter() {
169                total = total.or(source.marker());
170            }
171
172            // Determine the space covered by the requirement.
173            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                            // Identify the named index from either the project indexes or the workspace indexes,
245                            // in that order.
246                            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    /// Lower a [`uv_pep508::Requirement`] in a non-workspace setting (for example, in a PEP 723
329    /// script, which runs in an isolated context).
330    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        // If the source only applies to a given extra, filter it out.
347        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        // Determine whether the markers cover the full space for the requirement. If not, fill the
361        // remaining space with the negation of the sources.
362        let remaining = {
363            // Determine the space covered by the sources.
364            let mut total = MarkerTree::FALSE;
365            for source in source.iter() {
366                total = total.or(source.marker());
367            }
368
369            // Determine the space covered by the requirement.
370            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    /// Preserve the Git origin for direct path dependencies discovered while lowering metadata from
510    /// a checked-out Git repository.
511    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    /// Convert back into a [`Requirement`].
551    pub fn into_inner(self) -> Requirement {
552        self.0
553    }
554}
555
556/// An error parsing and merging `tool.uv.sources` with
557/// `project.{dependencies,optional-dependencies}`.
558#[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)] // Function attaches the context
623    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
656/// Generate a hint for a missing index if the index name is found in a configuration file
657/// (e.g., `uv.toml`) rather than in the project's `pyproject.toml`.
658fn 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
679/// Convert a Git source into a [`RequirementSource`].
680fn 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    // Create a PEP 508-compatible URL.
698    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    // Loads Git LFS Enablement according to priority.
711    // First: lfs = true, lfs = false from pyproject.toml
712    // Second: UV_GIT_LFS from environment
713    let lfs = GitLfs::from(lfs);
714    // Preserve that we're using Git LFS in the Verbatim Url representations
715    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
753/// Convert a URL source into a [`RequirementSource`].
754fn 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
792/// Convert a registry source into a [`RequirementSource`].
793fn 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
913/// Convert a path string to a file or directory source.
914fn 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            // Determine whether the project is a package or virtual.
958            // If the `package` option is unset, check if `tool.uv.package` is set
959            // on the path source (otherwise, default to `true`).
960            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                    // We don't require a build system for path dependencies
966                    .is_none_or(|pyproject_toml| pyproject_toml.is_package(false))
967            });
968
969            // If the project is not a package, treat it as a virtual dependency.
970            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}