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