Skip to main content

uv_distribution/metadata/
lowering.rs

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