Skip to main content

uv_distribution/metadata/
lowering.rs

1use std::collections::BTreeMap;
2use std::io;
3use std::path::{Path, PathBuf};
4
5use either::Either;
6use futures::future::join_all;
7
8use thiserror::Error;
9use uv_auth::CredentialsCache;
10use uv_cache::Cache;
11use uv_distribution_filename::DistExtension;
12use uv_distribution_types::{
13    Index, IndexCredentialsError, IndexLocations, IndexMetadata, IndexName, Origin, Requirement,
14    RequirementScope, RequirementSource,
15};
16use uv_fs::{Simplified, normalize_absolute_path, normalize_path};
17use uv_git_types::{GitLfs, GitReference, GitUrl, GitUrlParseError};
18use uv_macros::DebugNoInline;
19use uv_normalize::{ExtraName, GroupName, PackageName};
20use uv_pep440::VersionSpecifiers;
21use uv_pep508::{MarkerTree, VerbatimUrl, VersionOrUrl, looks_like_git_repository};
22use uv_pypi_types::{
23    ConflictItem, ParsedGitDirectoryUrl, ParsedGitPathUrl, ParsedUrl, ParsedUrlError,
24    VerbatimParsedUrl,
25};
26use uv_redacted::policy::Raw;
27use uv_redacted::{SafeUrl, SafeUrlError};
28use uv_workspace::pyproject::{PyProjectToml, Source, Sources, WorkspaceReference};
29use uv_workspace::{DiscoveryOptions, Workspace, WorkspaceCache, WorkspaceError};
30
31use crate::metadata::GitWorkspaceMember;
32
33#[derive(Debug, Clone)]
34pub struct LoweredRequirement(Requirement);
35
36#[derive(Debug, Clone, Copy)]
37enum RequirementOrigin {
38    /// The `tool.uv.sources` were read from the project.
39    Project,
40    /// The `tool.uv.sources` were read from the workspace root.
41    Workspace,
42}
43
44impl LoweredRequirement {
45    /// Combine `project.dependencies` or `project.optional-dependencies` with `tool.uv.sources`.
46    pub(crate) async fn from_requirement<'data>(
47        requirement: uv_pep508::Requirement<VerbatimParsedUrl>,
48        project_name: Option<&'data PackageName>,
49        project_dir: &'data Path,
50        project_sources: &'data BTreeMap<PackageName, Sources>,
51        project_indexes: &'data [Index],
52        extra: Option<&ExtraName>,
53        group: Option<&GroupName>,
54        locations: &'data IndexLocations,
55        workspace: &'data Workspace,
56        git_member: Option<&'data GitWorkspaceMember<'data>>,
57        editable: bool,
58        cache: &'data Cache,
59        workspace_cache: &'data WorkspaceCache,
60        credentials_cache: &'data CredentialsCache,
61    ) -> impl Iterator<Item = Result<Self, LoweringError>> + use<'data> + 'data {
62        // Identify the source from the `tool.uv.sources` table.
63        let (sources, origin) = if let Some(source) = project_sources.get(&requirement.name) {
64            (Some(source), RequirementOrigin::Project)
65        } else if let Some(source) = workspace.sources().get(&requirement.name) {
66            (Some(source), RequirementOrigin::Workspace)
67        } else {
68            (None, RequirementOrigin::Project)
69        };
70
71        // If the source only applies to a given extra or dependency group, filter it out.
72        let sources = sources.map(|sources| {
73            sources
74                .iter()
75                .filter(|source| {
76                    if let Some(target) = source.extra()
77                        && extra != Some(target)
78                    {
79                        return false;
80                    }
81
82                    if let Some(target) = source.group()
83                        && group != Some(target)
84                    {
85                        return false;
86                    }
87
88                    true
89                })
90                .cloned()
91                .collect::<Sources>()
92        });
93
94        // If you use a package that's part of the workspace...
95        if workspace.packages().contains_key(&requirement.name) {
96            // And it's not a recursive self-inclusion (extras that activate other extras), e.g.
97            // `framework[machine_learning]` depends on `framework[cuda]`.
98            if project_name.is_none_or(|project_name| *project_name != requirement.name) {
99                // It must be declared as a workspace source.
100                let Some(sources) = sources.as_ref() else {
101                    // No sources were declared for the workspace package.
102                    return Either::Left(std::iter::once(Err(
103                        LoweringError::MissingWorkspaceSource(requirement.name.clone()),
104                    )));
105                };
106
107                for source in sources.iter() {
108                    match source {
109                        Source::Git { .. } => {
110                            return Either::Left(std::iter::once(Err(
111                                LoweringError::NonWorkspaceSource(
112                                    requirement.name.clone(),
113                                    SourceKind::Git,
114                                ),
115                            )));
116                        }
117                        Source::Url { .. } => {
118                            return Either::Left(std::iter::once(Err(
119                                LoweringError::NonWorkspaceSource(
120                                    requirement.name.clone(),
121                                    SourceKind::Url,
122                                ),
123                            )));
124                        }
125                        Source::Path { .. } => {
126                            return Either::Left(std::iter::once(Err(
127                                LoweringError::NonWorkspaceSource(
128                                    requirement.name.clone(),
129                                    SourceKind::Path,
130                                ),
131                            )));
132                        }
133                        Source::Registry { .. } => {
134                            return Either::Left(std::iter::once(Err(
135                                LoweringError::NonWorkspaceSource(
136                                    requirement.name.clone(),
137                                    SourceKind::Registry,
138                                ),
139                            )));
140                        }
141                        Source::Workspace {
142                            workspace: WorkspaceReference::Bool(true),
143                            ..
144                        } => {
145                            // OK
146                        }
147                        Source::Workspace { .. } => {
148                            return Either::Left(std::iter::once(Err(
149                                LoweringError::InvalidWorkspaceSource(requirement.name.clone()),
150                            )));
151                        }
152                    }
153                }
154            }
155        }
156
157        let Some(sources) = sources else {
158            return Either::Left(std::iter::once(Self::preserve_git_source(
159                requirement,
160                git_member,
161            )));
162        };
163
164        // Determine whether the markers cover the full space for the requirement. If not, fill the
165        // remaining space with the negation of the sources.
166        let remaining = {
167            // Determine the space covered by the sources.
168            let mut total = MarkerTree::FALSE;
169            for source in sources.iter() {
170                total = total.or(source.marker());
171            }
172
173            // Determine the space covered by the requirement.
174            let mut remaining = total.negate();
175            remaining = remaining.and(requirement.marker);
176
177            Self(Requirement {
178                marker: remaining,
179                ..Requirement::from(requirement.clone())
180            })
181        };
182
183        Either::Right(
184            join_all(sources.into_iter().map(|source| {
185                let requirement = &requirement;
186                async move {
187                    let (source, mut marker) = match source {
188                        Source::Git {
189                            git,
190                            subdirectory,
191                            path,
192                            rev,
193                            tag,
194                            branch,
195                            lfs,
196                            marker,
197                            ..
198                        } => {
199                            let source = git_source(
200                                git.into_url(),
201                                subdirectory.map(Box::<Path>::from),
202                                path.map(Box::<Path>::from).map(PathBuf::from),
203                                rev,
204                                tag,
205                                branch,
206                                lfs,
207                            )?;
208                            (source, marker)
209                        }
210                        Source::Url {
211                            url,
212                            subdirectory,
213                            marker,
214                            ..
215                        } => {
216                            let source =
217                                url_source(requirement, url, subdirectory.map(Box::<Path>::from))?;
218                            (source, marker)
219                        }
220                        Source::Path {
221                            path,
222                            editable,
223                            package,
224                            marker,
225                            ..
226                        } => {
227                            let source = path_source(
228                                path,
229                                git_member,
230                                origin,
231                                project_dir,
232                                workspace.install_path(),
233                                editable,
234                                package,
235                                true,
236                            )?;
237                            (source, marker)
238                        }
239                        Source::Registry {
240                            index,
241                            marker,
242                            extra,
243                            group,
244                        } => {
245                            // Identify the named index from either the project indexes or the workspace indexes,
246                            // in that order.
247                            let Some(index) = locations
248                                .indexes()
249                                .filter(|index| matches!(index.origin, Some(Origin::Cli)))
250                                .chain(project_indexes.iter())
251                                .chain(workspace.indexes().iter())
252                                .find(|Index { name, .. }| {
253                                    name.as_ref().is_some_and(|name| *name == index)
254                                })
255                            else {
256                                let hint = missing_index_hint(locations, &index);
257                                return Err(LoweringError::MissingIndex {
258                                    package: requirement.name.clone(),
259                                    index,
260                                    hint,
261                                });
262                            };
263                            if let Some(credentials) = index.credentials()? {
264                                credentials_cache.store_credentials(index.raw_url(), credentials);
265                            }
266                            let index = IndexMetadata {
267                                url: index.url.clone(),
268                                format: index.format,
269                            };
270                            let conflict = project_name.and_then(|project_name| {
271                                if let Some(extra) = extra {
272                                    Some(ConflictItem::from((project_name.clone(), extra)))
273                                } else {
274                                    group.map(|group| {
275                                        ConflictItem::from((project_name.clone(), group))
276                                    })
277                                }
278                            });
279                            let source = registry_source(requirement, index, conflict);
280                            (source, marker)
281                        }
282                        Source::Workspace {
283                            workspace: workspace_ref,
284                            editable: source_editable,
285                            marker,
286                            ..
287                        } => {
288                            let source = workspace_source(
289                                requirement,
290                                &workspace_ref,
291                                source_editable,
292                                editable,
293                                origin,
294                                project_dir,
295                                workspace.install_path(),
296                                Some(workspace),
297                                git_member,
298                                cache,
299                                workspace_cache,
300                            )
301                            .await?;
302                            (source, marker)
303                        }
304                    };
305
306                    marker = marker.and(requirement.marker);
307
308                    Ok(Self(Requirement {
309                        name: requirement.name.clone(),
310                        extras: requirement.extras.clone(),
311                        groups: Box::new([]),
312                        marker,
313                        source,
314                        scope: RequirementScope::Global,
315                        origin: requirement.origin.clone(),
316                    }))
317                }
318            }))
319            .await
320            .into_iter()
321            .chain(std::iter::once(Ok(remaining)))
322            .filter(|requirement| match requirement {
323                Ok(requirement) => !requirement.0.marker.is_false(),
324                Err(_) => true,
325            }),
326        )
327    }
328
329    /// Lower a [`uv_pep508::Requirement`] in a non-workspace setting (for example, in a PEP 723
330    /// script, which runs in an isolated context).
331    pub async fn from_non_workspace_requirement<'data>(
332        requirement: uv_pep508::Requirement<VerbatimParsedUrl>,
333        dir: &'data Path,
334        sources: &'data BTreeMap<PackageName, Sources>,
335        indexes: &'data [Index],
336        locations: &'data IndexLocations,
337        cache: &'data Cache,
338        workspace_cache: &'data WorkspaceCache,
339        credentials_cache: &'data CredentialsCache,
340    ) -> impl Iterator<Item = Result<Self, LoweringError>> + 'data {
341        let source = sources.get(&requirement.name).cloned();
342
343        let Some(source) = source else {
344            return Either::Left(std::iter::once(Ok(Self(Requirement::from(requirement)))));
345        };
346
347        // If the source only applies to a given extra, filter it out.
348        let source = source
349            .iter()
350            .filter(|source| {
351                source.extra().is_none_or(|target| {
352                    requirement
353                        .marker
354                        .top_level_extra_name()
355                        .is_some_and(|extra| &*extra == target)
356                })
357            })
358            .cloned()
359            .collect::<Sources>();
360
361        // Determine whether the markers cover the full space for the requirement. If not, fill the
362        // remaining space with the negation of the sources.
363        let remaining = {
364            // Determine the space covered by the sources.
365            let mut total = MarkerTree::FALSE;
366            for source in source.iter() {
367                total = total.or(source.marker());
368            }
369
370            // Determine the space covered by the requirement.
371            let mut remaining = total.negate();
372            remaining = remaining.and(requirement.marker);
373
374            Self(Requirement {
375                marker: remaining,
376                ..Requirement::from(requirement.clone())
377            })
378        };
379
380        Either::Right(
381            join_all(source.into_iter().map(|source| {
382                let requirement = &requirement;
383                async move {
384                    let (source, mut marker) = match source {
385                        Source::Git {
386                            git,
387                            subdirectory,
388                            path,
389                            rev,
390                            tag,
391                            branch,
392                            lfs,
393                            marker,
394                            ..
395                        } => {
396                            let source = git_source(
397                                git.into_url(),
398                                subdirectory.map(Box::<Path>::from),
399                                path.map(Box::<Path>::from).map(PathBuf::from),
400                                rev,
401                                tag,
402                                branch,
403                                lfs,
404                            )?;
405                            (source, marker)
406                        }
407                        Source::Url {
408                            url,
409                            subdirectory,
410                            marker,
411                            ..
412                        } => {
413                            let source =
414                                url_source(requirement, url, subdirectory.map(Box::<Path>::from))?;
415                            (source, marker)
416                        }
417                        Source::Path {
418                            path,
419                            editable,
420                            package,
421                            marker,
422                            ..
423                        } => {
424                            let source = path_source(
425                                path,
426                                None,
427                                RequirementOrigin::Project,
428                                dir,
429                                dir,
430                                editable,
431                                package,
432                                true,
433                            )?;
434                            (source, marker)
435                        }
436                        Source::Registry { index, marker, .. } => {
437                            let Some(index) = locations
438                                .indexes()
439                                .filter(|index| matches!(index.origin, Some(Origin::Cli)))
440                                .chain(indexes.iter())
441                                .find(|Index { name, .. }| {
442                                    name.as_ref().is_some_and(|name| *name == index)
443                                })
444                            else {
445                                let hint = missing_index_hint(locations, &index);
446                                return Err(LoweringError::MissingIndex {
447                                    package: requirement.name.clone(),
448                                    index,
449                                    hint,
450                                });
451                            };
452                            if let Some(credentials) = index.credentials()? {
453                                credentials_cache.store_credentials(index.raw_url(), credentials);
454                            }
455                            let index = IndexMetadata {
456                                url: index.url.clone(),
457                                format: index.format,
458                            };
459                            let conflict = None;
460                            let source = registry_source(requirement, index, conflict);
461                            (source, marker)
462                        }
463                        Source::Workspace {
464                            workspace: workspace_ref,
465                            editable,
466                            marker,
467                            ..
468                        } => {
469                            let source = workspace_source(
470                                requirement,
471                                &workspace_ref,
472                                editable,
473                                true,
474                                RequirementOrigin::Project,
475                                dir,
476                                dir,
477                                None,
478                                None,
479                                cache,
480                                workspace_cache,
481                            )
482                            .await?;
483                            (source, marker)
484                        }
485                    };
486
487                    marker = marker.and(requirement.marker);
488
489                    Ok(Self(Requirement {
490                        name: requirement.name.clone(),
491                        extras: requirement.extras.clone(),
492                        groups: Box::new([]),
493                        marker,
494                        source,
495                        scope: RequirementScope::Global,
496                        origin: requirement.origin.clone(),
497                    }))
498                }
499            }))
500            .await
501            .into_iter()
502            .chain(std::iter::once(Ok(remaining)))
503            .filter(|requirement| match requirement {
504                Ok(requirement) => !requirement.0.marker.is_false(),
505                Err(_) => true,
506            }),
507        )
508    }
509
510    /// Preserve the Git origin for direct path dependencies discovered while lowering metadata from
511    /// a checked-out Git repository.
512    pub(crate) fn preserve_git_source(
513        requirement: uv_pep508::Requirement<VerbatimParsedUrl>,
514        git_member: Option<&GitWorkspaceMember>,
515    ) -> Result<Self, LoweringError> {
516        let Some(git_member) = git_member else {
517            return Ok(Self(Requirement::from(requirement)));
518        };
519
520        let Some(VersionOrUrl::Url(url)) = &requirement.version_or_url else {
521            return Ok(Self(Requirement::from(requirement)));
522        };
523
524        let (install_path, is_archive) = match &url.parsed_url {
525            ParsedUrl::Directory(directory) => (directory.install_path.as_ref(), false),
526            ParsedUrl::Path(path) => (path.install_path.as_ref(), true),
527            _ => return Ok(Self(Requirement::from(requirement))),
528        };
529
530        let install_path = git_path(install_path)?;
531        let fetch_root = git_path(git_member.fetch_root)?;
532        if !install_path.starts_with(&fetch_root) {
533            return Ok(Self(Requirement::from(requirement)));
534        }
535
536        Ok(Self(Requirement {
537            name: requirement.name,
538            groups: Box::new([]),
539            extras: requirement.extras,
540            marker: requirement.marker,
541            source: if is_archive {
542                git_archive_source_from_path(&install_path, git_member)?
543            } else {
544                git_directory_source_from_path(&install_path, git_member)?
545            },
546            scope: RequirementScope::Global,
547            origin: requirement.origin,
548        }))
549    }
550
551    /// Convert back into a [`Requirement`].
552    pub fn into_inner(self) -> Requirement {
553        self.0
554    }
555}
556
557/// An error parsing and merging `tool.uv.sources` with
558/// `project.{dependencies,optional-dependencies}`.
559#[derive(DebugNoInline, Error)]
560pub enum LoweringError {
561    #[error(
562        "`{0}` is included as a workspace member, but is missing an entry in `tool.uv.sources` (e.g., `{0} = {{ workspace = true }}`)"
563    )]
564    MissingWorkspaceSource(PackageName),
565    #[error(
566        "`{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 }}`)."
567    )]
568    NonWorkspaceSource(PackageName, SourceKind),
569    #[error(
570        "`{0}` references a workspace in `tool.uv.sources` (e.g., `{0} = {{ workspace = true }}`), but is not a workspace member"
571    )]
572    UndeclaredWorkspacePackage(PackageName),
573    #[error(
574        "`{0}` is included as a workspace member, but does not use `workspace = true` in `tool.uv.sources`"
575    )]
576    InvalidWorkspaceSource(PackageName),
577    #[error("Can only specify one of: `rev`, `tag`, or `branch`")]
578    MoreThanOneGitRef,
579    #[error(transparent)]
580    GitUrlParse(#[from] GitUrlParseError),
581    #[error("Package `{package}` references an undeclared index: `{index}`")]
582    MissingIndex {
583        package: PackageName,
584        index: IndexName,
585        hint: Option<String>,
586    },
587    #[error("Workspace members are not allowed in non-workspace contexts")]
588    WorkspaceMember,
589    #[error(transparent)]
590    InvalidUrl(#[from] SafeUrlError),
591    #[error(transparent)]
592    IndexCredentials(#[from] IndexCredentialsError),
593    #[error(transparent)]
594    InvalidVerbatimUrl(#[from] uv_pep508::VerbatimUrlError),
595    #[error("Fragments are not allowed in URLs: {0}")]
596    ForbiddenFragment(SafeUrl),
597    #[error(
598        "`{0}` is associated with a URL source, but references a Git repository. Consider using a Git source instead (e.g., `{0} = {{ git = \"{1}\" }}`)"
599    )]
600    MissingGitSource(PackageName, SafeUrl),
601    #[error("`workspace = false` is not yet supported")]
602    WorkspaceFalse,
603    #[error(transparent)]
604    Workspace(#[from] WorkspaceError),
605    #[error(
606        "Workspace source path `{}` must point to a workspace root (found workspace at `{}`)", path.simplified_display(), root.simplified_display()
607    )]
608    WorkspaceSourceNotRoot { path: PathBuf, root: PathBuf },
609    #[error("Source with `editable = true` must refer to a local directory, not a file: {0}")]
610    EditableFile(String),
611    #[error("Source with `package = true` must refer to a local directory, not a file: {0}")]
612    PackagedFile(String),
613    #[error(
614        "Git repository references local file source, but only directories are supported as transitive Git dependencies: {0}"
615    )]
616    GitFile(String),
617    #[error("Git repository references local directory outside the repository: {0}")]
618    GitDirectory(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::Hinted 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: SafeUrl,
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 = SafeUrl::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: SafeUrl<Raw>,
758    subdirectory: Option<Box<Path>>,
759) -> Result<RequirementSource, LoweringError> {
760    let mut verbatim_url = url.as_url().clone();
761    if verbatim_url.fragment().is_some() {
762        return Err(LoweringError::ForbiddenFragment(url.into_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.as_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
818async fn workspace_source(
819    requirement: &uv_pep508::Requirement<VerbatimParsedUrl>,
820    workspace_ref: &WorkspaceReference,
821    source_editable: Option<bool>,
822    default_editable: bool,
823    origin: RequirementOrigin,
824    project_dir: &Path,
825    workspace_root: &Path,
826    current_workspace: Option<&Workspace>,
827    git_member: Option<&GitWorkspaceMember<'_>>,
828    cache: &Cache,
829    workspace_cache: &WorkspaceCache,
830) -> Result<RequirementSource, LoweringError> {
831    let base = match origin {
832        RequirementOrigin::Project => project_dir,
833        RequirementOrigin::Workspace => workspace_root,
834    };
835
836    match workspace_ref {
837        WorkspaceReference::Bool(false) => Err(LoweringError::WorkspaceFalse),
838        WorkspaceReference::Bool(true) => {
839            let workspace = current_workspace.ok_or(LoweringError::WorkspaceMember)?;
840            let member = workspace.packages().get(&requirement.name).ok_or_else(|| {
841                LoweringError::UndeclaredWorkspacePackage(requirement.name.clone())
842            })?;
843
844            let value = workspace.required_members().get(&requirement.name);
845            let is_required_member = value.is_some();
846            let is_package = member.pyproject_toml().is_package(!is_required_member);
847            let editable = if is_package {
848                Some(value.copied().flatten().unwrap_or(default_editable))
849            } else {
850                Some(false)
851            };
852            path_source(
853                member.root(),
854                git_member,
855                origin,
856                project_dir,
857                workspace_root,
858                editable,
859                Some(is_package),
860                false,
861            )
862        }
863        WorkspaceReference::Path(path) => {
864            let workspace_path = VerbatimUrl::from_path(path.as_ref(), base)?
865                .to_file_path()
866                .map_err(|()| {
867                    LoweringError::RelativeTo(io::Error::other("Invalid path in file URL"))
868                })?;
869            let target_workspace = Workspace::discover(
870                &workspace_path,
871                &DiscoveryOptions::default(),
872                cache,
873                workspace_cache,
874            )
875            .await?;
876
877            if target_workspace.install_path() != &workspace_path {
878                return Err(LoweringError::WorkspaceSourceNotRoot {
879                    path: workspace_path,
880                    root: target_workspace.install_path().clone(),
881                });
882            }
883
884            let member = target_workspace
885                .packages()
886                .get(&requirement.name)
887                .ok_or_else(|| {
888                    LoweringError::UndeclaredWorkspacePackage(requirement.name.clone())
889                })?;
890
891            let is_package = member.pyproject_toml().is_package(false);
892            let editable = if is_package {
893                Some(source_editable.unwrap_or(true))
894            } else {
895                Some(false)
896            };
897            let member_path =
898                uv_fs::relative_to(member.root(), base).unwrap_or_else(|_| member.root().into());
899
900            path_source(
901                member_path,
902                git_member,
903                origin,
904                project_dir,
905                workspace_root,
906                editable,
907                Some(is_package),
908                true,
909            )
910        }
911    }
912}
913
914/// Convert a path string to a file or directory source.
915fn path_source(
916    path: impl AsRef<Path>,
917    git_member: Option<&GitWorkspaceMember>,
918    origin: RequirementOrigin,
919    project_dir: &Path,
920    workspace_root: &Path,
921    editable: Option<bool>,
922    package: Option<bool>,
923    preserve_given: bool,
924) -> Result<RequirementSource, LoweringError> {
925    let path = path.as_ref();
926    let base = match origin {
927        RequirementOrigin::Project => project_dir,
928        RequirementOrigin::Workspace => workspace_root,
929    };
930    let url = VerbatimUrl::from_path(path, base)?;
931    let url = if preserve_given {
932        url.with_given(path.to_string_lossy())
933    } else {
934        url
935    };
936    let install_path = url
937        .to_file_path()
938        .map_err(|()| LoweringError::RelativeTo(io::Error::other("Invalid path in file URL")))?;
939
940    let is_dir = if let Ok(metadata) = install_path.metadata() {
941        metadata.is_dir()
942    } else {
943        install_path.extension().is_none()
944    };
945    if is_dir {
946        if let Some(git_member) = git_member {
947            return git_directory_source_from_path(install_path, git_member);
948        }
949
950        if editable == Some(true) {
951            Ok(RequirementSource::Directory {
952                install_path: install_path.into_boxed_path(),
953                url,
954                editable,
955                r#virtual: Some(false),
956            })
957        } else {
958            // Determine whether the project is a package or virtual.
959            // If the `package` option is unset, check if `tool.uv.package` is set
960            // on the path source (otherwise, default to `true`).
961            let is_package = package.unwrap_or_else(|| {
962                let pyproject_path = install_path.join("pyproject.toml");
963                fs_err::read_to_string(&pyproject_path)
964                    .ok()
965                    .and_then(|contents| PyProjectToml::from_string(contents, pyproject_path).ok())
966                    // We don't require a build system for path dependencies
967                    .is_none_or(|pyproject_toml| pyproject_toml.is_package(false))
968            });
969
970            // If the project is not a package, treat it as a virtual dependency.
971            let r#virtual = !is_package;
972
973            Ok(RequirementSource::Directory {
974                install_path: install_path.into_boxed_path(),
975                url,
976                editable: Some(false),
977                r#virtual: Some(r#virtual),
978            })
979        }
980    } else {
981        if let Some(git_member) = git_member {
982            return git_archive_source_from_path(install_path, git_member);
983        }
984        if editable == Some(true) {
985            return Err(LoweringError::EditableFile(url.to_string()));
986        }
987        if package == Some(true) {
988            return Err(LoweringError::PackagedFile(url.to_string()));
989        }
990        Ok(RequirementSource::Path {
991            ext: DistExtension::from_path(&install_path)
992                .map_err(|err| ParsedUrlError::MissingExtensionPath(path.to_path_buf(), err))?,
993            install_path: install_path.into_boxed_path(),
994            url,
995        })
996    }
997}
998
999fn git_directory_source_from_path(
1000    install_path: impl AsRef<Path>,
1001    git_member: &GitWorkspaceMember,
1002) -> Result<RequirementSource, LoweringError> {
1003    let git = git_member.git_source.git.clone();
1004    let install_path = git_path(install_path.as_ref())?;
1005    let fetch_root = git_path(git_member.fetch_root)?;
1006    let subdirectory = uv_fs::relative_to(&install_path, fetch_root)
1007        .map_err(|_| LoweringError::GitDirectory(install_path.display().to_string()))?;
1008    let subdirectory = normalize_path(subdirectory);
1009    let subdirectory = if subdirectory == PathBuf::new() {
1010        None
1011    } else {
1012        Some(subdirectory.into_owned().into_boxed_path())
1013    };
1014    let url = SafeUrl::from(ParsedGitDirectoryUrl {
1015        url: git.clone(),
1016        subdirectory: subdirectory.clone(),
1017    });
1018    Ok(RequirementSource::GitDirectory {
1019        git,
1020        subdirectory,
1021        url: VerbatimUrl::from_url(url),
1022    })
1023}
1024
1025fn git_archive_source_from_path(
1026    install_path: impl AsRef<Path>,
1027    git_member: &GitWorkspaceMember,
1028) -> Result<RequirementSource, LoweringError> {
1029    let git = git_member.git_source.git.clone();
1030    let install_path = git_path(install_path.as_ref())?;
1031    let fetch_root = git_path(git_member.fetch_root)?;
1032    let install_path =
1033        uv_fs::relative_to(install_path, fetch_root).map_err(LoweringError::RelativeTo)?;
1034    let install_path = normalize_path(install_path).into_owned();
1035    let ext = DistExtension::from_path(&install_path)
1036        .map_err(|err| ParsedUrlError::MissingExtensionPath(install_path.clone(), err))?;
1037    let url = SafeUrl::from(ParsedGitPathUrl {
1038        url: git.clone(),
1039        install_path: install_path.clone(),
1040        ext,
1041    });
1042    Ok(RequirementSource::GitPath {
1043        git,
1044        install_path,
1045        ext,
1046        url: VerbatimUrl::from_url(url),
1047    })
1048}
1049
1050fn git_path(path: &Path) -> Result<PathBuf, LoweringError> {
1051    path.simple_canonicalize()
1052        .or_else(|_| normalize_absolute_path(path))
1053        .map_err(LoweringError::RelativeTo)
1054}