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