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