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