uv_distribution/metadata/
lowering.rs

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