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 repository = git.clone();
731    let git = GitUrl::from_fields(repository, reference, None, lfs)?;
732
733    if let Some(path) = path {
734        let ext = match DistExtension::from_path(&path) {
735            Ok(ext) => ext,
736            Err(err) => {
737                return Err(ParsedUrlError::MissingExtensionPath(path, err).into());
738            }
739        };
740        Ok(RequirementSource::GitPath {
741            url,
742            git,
743            install_path: path,
744            ext,
745        })
746    } else {
747        Ok(RequirementSource::GitDirectory {
748            url,
749            git,
750            subdirectory,
751        })
752    }
753}
754
755/// Convert a URL source into a [`RequirementSource`].
756fn url_source(
757    requirement: &uv_pep508::Requirement<VerbatimParsedUrl>,
758    url: DisplaySafeUrl,
759    subdirectory: Option<Box<Path>>,
760) -> Result<RequirementSource, LoweringError> {
761    let mut verbatim_url = url.clone();
762    if verbatim_url.fragment().is_some() {
763        return Err(LoweringError::ForbiddenFragment(url));
764    }
765    if let Some(subdirectory) = subdirectory.as_ref() {
766        let subdirectory = subdirectory
767            .to_str()
768            .ok_or_else(|| LoweringError::NonUtf8Path(subdirectory.to_path_buf()))?;
769        verbatim_url.set_fragment(Some(&format!("subdirectory={subdirectory}")));
770    }
771
772    let ext = match DistExtension::from_path(url.path()) {
773        Ok(ext) => ext,
774        Err(..) if looks_like_git_repository(&url) => {
775            return Err(LoweringError::MissingGitSource(
776                requirement.name.clone(),
777                url.clone(),
778            ));
779        }
780        Err(err) => {
781            return Err(ParsedUrlError::MissingExtensionUrl(url.to_string(), err).into());
782        }
783    };
784
785    let verbatim_url = VerbatimUrl::from_url(verbatim_url);
786    Ok(RequirementSource::Url {
787        location: url,
788        subdirectory,
789        ext,
790        url: verbatim_url,
791    })
792}
793
794/// Convert a registry source into a [`RequirementSource`].
795fn registry_source(
796    requirement: &uv_pep508::Requirement<VerbatimParsedUrl>,
797    index: IndexMetadata,
798    conflict: Option<ConflictItem>,
799) -> RequirementSource {
800    match &requirement.version_or_url {
801        None => RequirementSource::Registry {
802            specifier: VersionSpecifiers::empty(),
803            index: Some(index),
804            conflict,
805        },
806        Some(VersionOrUrl::VersionSpecifier(version)) => RequirementSource::Registry {
807            specifier: version.clone(),
808            index: Some(index),
809            conflict,
810        },
811        Some(VersionOrUrl::Url(_)) => RequirementSource::Registry {
812            specifier: VersionSpecifiers::empty(),
813            index: Some(index),
814            conflict,
815        },
816    }
817}
818
819/// Convert a path string to a file or directory source.
820fn path_source(
821    path: impl AsRef<Path>,
822    git_member: Option<&GitWorkspaceMember>,
823    origin: RequirementOrigin,
824    project_dir: &Path,
825    workspace_root: &Path,
826    editable: Option<bool>,
827    package: Option<bool>,
828) -> Result<RequirementSource, LoweringError> {
829    let path = path.as_ref();
830    let base = match origin {
831        RequirementOrigin::Project => project_dir,
832        RequirementOrigin::Workspace => workspace_root,
833    };
834    let url = VerbatimUrl::from_path(path, base)?.with_given(path.to_string_lossy());
835    let install_path = url
836        .to_file_path()
837        .map_err(|()| LoweringError::RelativeTo(io::Error::other("Invalid path in file URL")))?;
838
839    let is_dir = if let Ok(metadata) = install_path.metadata() {
840        metadata.is_dir()
841    } else {
842        install_path.extension().is_none()
843    };
844    if is_dir {
845        if let Some(git_member) = git_member {
846            return git_directory_source_from_path(install_path, git_member);
847        }
848
849        if editable == Some(true) {
850            Ok(RequirementSource::Directory {
851                install_path: install_path.into_boxed_path(),
852                url,
853                editable,
854                r#virtual: Some(false),
855            })
856        } else {
857            // Determine whether the project is a package or virtual.
858            // If the `package` option is unset, check if `tool.uv.package` is set
859            // on the path source (otherwise, default to `true`).
860            let is_package = package.unwrap_or_else(|| {
861                let pyproject_path = install_path.join("pyproject.toml");
862                fs_err::read_to_string(&pyproject_path)
863                    .ok()
864                    .and_then(|contents| PyProjectToml::from_string(contents, pyproject_path).ok())
865                    // We don't require a build system for path dependencies
866                    .is_none_or(|pyproject_toml| pyproject_toml.is_package(false))
867            });
868
869            // If the project is not a package, treat it as a virtual dependency.
870            let r#virtual = !is_package;
871
872            Ok(RequirementSource::Directory {
873                install_path: install_path.into_boxed_path(),
874                url,
875                editable: Some(false),
876                r#virtual: Some(r#virtual),
877            })
878        }
879    } else {
880        if let Some(git_member) = git_member {
881            return git_archive_source_from_path(install_path, git_member);
882        }
883        if editable == Some(true) {
884            return Err(LoweringError::EditableFile(url.to_string()));
885        }
886        if package == Some(true) {
887            return Err(LoweringError::PackagedFile(url.to_string()));
888        }
889        Ok(RequirementSource::Path {
890            ext: DistExtension::from_path(&install_path)
891                .map_err(|err| ParsedUrlError::MissingExtensionPath(path.to_path_buf(), err))?,
892            install_path: install_path.into_boxed_path(),
893            url,
894        })
895    }
896}
897
898fn git_directory_source_from_path(
899    install_path: impl AsRef<Path>,
900    git_member: &GitWorkspaceMember,
901) -> Result<RequirementSource, LoweringError> {
902    let git = git_member.git_source.git.clone();
903    let install_path = git_path(install_path.as_ref())?;
904    let fetch_root = git_path(git_member.fetch_root)?;
905    let subdirectory =
906        uv_fs::relative_to(install_path, fetch_root).map_err(LoweringError::RelativeTo)?;
907    let subdirectory = normalize_path(subdirectory);
908    let subdirectory = if subdirectory == PathBuf::new() {
909        None
910    } else {
911        Some(subdirectory.into_owned().into_boxed_path())
912    };
913    let url = DisplaySafeUrl::from(ParsedGitDirectoryUrl {
914        url: git.clone(),
915        subdirectory: subdirectory.clone(),
916    });
917    Ok(RequirementSource::GitDirectory {
918        git,
919        subdirectory,
920        url: VerbatimUrl::from_url(url),
921    })
922}
923
924fn git_archive_source_from_path(
925    install_path: impl AsRef<Path>,
926    git_member: &GitWorkspaceMember,
927) -> Result<RequirementSource, LoweringError> {
928    let git = git_member.git_source.git.clone();
929    let install_path = git_path(install_path.as_ref())?;
930    let fetch_root = git_path(git_member.fetch_root)?;
931    let install_path =
932        uv_fs::relative_to(install_path, fetch_root).map_err(LoweringError::RelativeTo)?;
933    let install_path = normalize_path(install_path).into_owned();
934    let ext = DistExtension::from_path(&install_path)
935        .map_err(|err| ParsedUrlError::MissingExtensionPath(install_path.clone(), err))?;
936    let url = DisplaySafeUrl::from(ParsedGitPathUrl {
937        url: git.clone(),
938        install_path: install_path.clone(),
939        ext,
940    });
941    Ok(RequirementSource::GitPath {
942        git,
943        install_path,
944        ext,
945        url: VerbatimUrl::from_url(url),
946    })
947}
948
949fn git_path(path: &Path) -> Result<PathBuf, LoweringError> {
950    path.simple_canonicalize()
951        .or_else(|_| normalize_absolute_path(path))
952        .map_err(LoweringError::RelativeTo)
953}