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