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