uv_distribution/metadata/
lowering.rs

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