Skip to main content

uv_distribution_types/
requirement.rs

1use std::fmt::{Display, Formatter};
2use std::io;
3use std::path::{Path, PathBuf};
4use std::str::FromStr;
5
6use thiserror::Error;
7use uv_cache_key::{CacheKey, CacheKeyHasher};
8use uv_distribution_filename::DistExtension;
9use uv_fs::{CWD, PortablePath, PortablePathBuf, normalize_path, try_relative_to_if};
10use uv_git_types::{GitLfs, GitOid, GitReference, GitUrl, GitUrlParseError, OidParseError};
11use uv_normalize::{ExtraName, GroupName, PackageName};
12use uv_pep440::VersionSpecifiers;
13use uv_pep508::{
14    MarkerEnvironment, MarkerTree, RequirementOrigin, VerbatimUrl, VersionOrUrl, marker,
15};
16use uv_redacted::policy::{Raw, Stripped};
17use uv_redacted::{SafeUrl, SafeUrlError};
18
19use crate::{IndexMetadata, IndexUrl};
20
21use uv_pypi_types::{
22    ConflictItem, HashError, Hashes, ParsedArchiveUrl, ParsedDirectoryUrl, ParsedGitDirectoryUrl,
23    ParsedGitPathUrl, ParsedPathUrl, ParsedUrl, ParsedUrlError, VerbatimParsedUrl,
24};
25
26#[derive(Debug, Error)]
27enum RequirementError {
28    #[error(transparent)]
29    VerbatimUrlError(#[from] uv_pep508::VerbatimUrlError),
30    #[error(transparent)]
31    ParsedUrlError(#[from] ParsedUrlError),
32    #[error(transparent)]
33    UrlParseError(#[from] SafeUrlError),
34    #[error(transparent)]
35    OidParseError(#[from] OidParseError),
36    #[error(transparent)]
37    GitUrlParse(#[from] GitUrlParseError),
38}
39
40/// A representation of dependency on a package, an extension over a PEP 508's requirement.
41///
42/// The main change is using [`RequirementSource`] to represent all supported package sources over
43/// [`VersionOrUrl`], which collapses all URL sources into a single stringly type.
44///
45/// Additionally, this requirement type makes room for dependency groups, which lack a standardized
46/// representation in PEP 508. In the context of this type, extras and groups are assumed to be
47/// mutually exclusive, in that if `extras` is non-empty, `groups` must be empty and vice versa.
48#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
49pub struct Requirement {
50    pub name: PackageName,
51    #[serde(skip_serializing_if = "<[ExtraName]>::is_empty", default)]
52    pub extras: Box<[ExtraName]>,
53    #[serde(skip_serializing_if = "<[GroupName]>::is_empty", default)]
54    pub groups: Box<[GroupName]>,
55    #[serde(
56        skip_serializing_if = "marker::ser::is_empty",
57        serialize_with = "marker::ser::serialize",
58        default
59    )]
60    pub marker: MarkerTree,
61    #[serde(flatten)]
62    pub source: RequirementSource,
63    #[serde(skip)]
64    pub scope: RequirementScope,
65    #[serde(skip)]
66    pub origin: Option<RequirementOrigin>,
67}
68
69/// Whether a requirement is global or belongs to a package's dependency group.
70#[derive(Debug, Clone, Default, Eq, Hash, PartialEq, PartialOrd, Ord)]
71pub enum RequirementScope {
72    #[default]
73    Global,
74    Group {
75        package: PackageName,
76        group: GroupName,
77    },
78}
79
80impl RequirementScope {
81    /// Return the conflict item whose activation makes this requirement visible.
82    pub fn conflict_item(&self) -> Option<ConflictItem> {
83        match self {
84            Self::Global => None,
85            Self::Group { package, group } => {
86                Some(ConflictItem::from((package.clone(), group.clone())))
87            }
88        }
89    }
90}
91
92impl Requirement {
93    /// Returns whether the markers apply for the given environment.
94    ///
95    /// When `env` is `None`, this specifically evaluates all marker
96    /// expressions based on the environment to `true`. That is, this provides
97    /// environment independent marker evaluation.
98    pub fn evaluate_markers(&self, env: Option<&MarkerEnvironment>, extras: &[ExtraName]) -> bool {
99        self.marker.evaluate_optional_environment(env, extras)
100    }
101
102    /// Convert to a [`Requirement`] with a relative path based on the given root.
103    pub fn relative_to(self, path: &Path) -> Result<Self, io::Error> {
104        Ok(Self {
105            source: self.source.relative_to(path)?,
106            ..self
107        })
108    }
109
110    /// Convert to a [`Requirement`] with an absolute path based on the given root.
111    #[must_use]
112    pub(crate) fn into_absolute(self, path: &Path) -> Self {
113        Self {
114            source: self.source.into_absolute(path),
115            ..self
116        }
117    }
118
119    /// Set whether this requirement's local source should be represented by a relative path.
120    ///
121    /// When `false`, preserve the original input's path preference. Non-local sources are unchanged.
122    pub fn set_force_relative(&mut self, force_relative: bool) {
123        if let RequirementSource::Path { url, .. } | RequirementSource::Directory { url, .. } =
124            &mut self.source
125            && url.force_relative() != force_relative
126        {
127            *url = url.clone().with_force_relative(force_relative);
128        }
129    }
130
131    /// Return the hashes of the requirement, as specified in the URL fragment.
132    pub fn hashes(&self) -> Result<Option<Hashes>, HashError> {
133        let (RequirementSource::Url { ref url, .. } | RequirementSource::Path { ref url, .. }) =
134            self.source
135        else {
136            return Ok(None);
137        };
138        let Some(fragment) = url.fragment() else {
139            return Ok(None);
140        };
141        Hashes::parse_url_fragment(fragment)
142    }
143
144    /// Set the source file containing the requirement.
145    #[must_use]
146    pub fn with_origin(self, origin: RequirementOrigin) -> Self {
147        Self {
148            origin: Some(origin),
149            ..self
150        }
151    }
152}
153
154impl std::hash::Hash for Requirement {
155    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
156        let Self {
157            name,
158            extras,
159            groups,
160            marker,
161            source,
162            scope,
163            origin: _,
164        } = self;
165        name.hash(state);
166        extras.hash(state);
167        groups.hash(state);
168        marker.hash(state);
169        source.hash(state);
170        scope.hash(state);
171    }
172}
173
174impl PartialEq for Requirement {
175    fn eq(&self, other: &Self) -> bool {
176        let Self {
177            name,
178            extras,
179            groups,
180            marker,
181            source,
182            scope,
183            origin: _,
184        } = self;
185        let Self {
186            name: other_name,
187            extras: other_extras,
188            groups: other_groups,
189            marker: other_marker,
190            source: other_source,
191            scope: other_scope,
192            origin: _,
193        } = other;
194        name == other_name
195            && extras == other_extras
196            && groups == other_groups
197            && marker == other_marker
198            && source == other_source
199            && scope == other_scope
200    }
201}
202
203impl Eq for Requirement {}
204
205impl Ord for Requirement {
206    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
207        let Self {
208            name,
209            extras,
210            groups,
211            marker,
212            source,
213            scope,
214            origin: _,
215        } = self;
216        let Self {
217            name: other_name,
218            extras: other_extras,
219            groups: other_groups,
220            marker: other_marker,
221            source: other_source,
222            scope: other_scope,
223            origin: _,
224        } = other;
225        name.cmp(other_name)
226            .then_with(|| extras.cmp(other_extras))
227            .then_with(|| groups.cmp(other_groups))
228            .then_with(|| marker.cmp(other_marker))
229            .then_with(|| source.cmp(other_source))
230            .then_with(|| scope.cmp(other_scope))
231    }
232}
233
234impl PartialOrd for Requirement {
235    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
236        Some(self.cmp(other))
237    }
238}
239
240impl From<Requirement> for uv_pep508::Requirement<VerbatimUrl> {
241    /// Convert a [`Requirement`] to a [`uv_pep508::Requirement`].
242    fn from(requirement: Requirement) -> Self {
243        Self {
244            name: requirement.name,
245            extras: requirement.extras,
246            marker: requirement.marker,
247            origin: requirement.origin,
248            version_or_url: match requirement.source {
249                RequirementSource::Registry { specifier, .. } => {
250                    Some(VersionOrUrl::VersionSpecifier(specifier))
251                }
252                RequirementSource::Url { url, .. }
253                | RequirementSource::GitPath { url, .. }
254                | RequirementSource::GitDirectory { url, .. }
255                | RequirementSource::Path { url, .. }
256                | RequirementSource::Directory { url, .. } => Some(VersionOrUrl::Url(url)),
257            },
258        }
259    }
260}
261
262impl From<Requirement> for uv_pep508::Requirement<VerbatimParsedUrl> {
263    /// Convert a [`Requirement`] to a [`uv_pep508::Requirement`].
264    fn from(requirement: Requirement) -> Self {
265        Self {
266            name: requirement.name,
267            extras: requirement.extras,
268            marker: requirement.marker,
269            origin: requirement.origin,
270            version_or_url: match requirement.source {
271                RequirementSource::Registry { specifier, .. } => {
272                    Some(VersionOrUrl::VersionSpecifier(specifier))
273                }
274                RequirementSource::Url {
275                    location,
276                    subdirectory,
277                    ext,
278                    url,
279                } => Some(VersionOrUrl::Url(VerbatimParsedUrl {
280                    parsed_url: ParsedUrl::Archive(ParsedArchiveUrl {
281                        url: location.into_url(),
282                        subdirectory,
283                        ext,
284                    }),
285                    verbatim: url,
286                })),
287                RequirementSource::GitDirectory {
288                    git,
289                    subdirectory,
290                    url,
291                } => Some(VersionOrUrl::Url(VerbatimParsedUrl {
292                    parsed_url: ParsedUrl::GitDirectory(ParsedGitDirectoryUrl {
293                        url: git,
294                        subdirectory,
295                    }),
296                    verbatim: url,
297                })),
298                RequirementSource::GitPath {
299                    git,
300                    install_path,
301                    ext,
302                    url,
303                } => Some(VersionOrUrl::Url(VerbatimParsedUrl {
304                    parsed_url: ParsedUrl::GitPath(ParsedGitPathUrl {
305                        url: git,
306                        install_path,
307                        ext,
308                    }),
309                    verbatim: url,
310                })),
311                RequirementSource::Path {
312                    install_path,
313                    ext,
314                    url,
315                } => Some(VersionOrUrl::Url(VerbatimParsedUrl {
316                    parsed_url: ParsedUrl::Path(ParsedPathUrl {
317                        url: url.to_url(),
318                        install_path,
319                        ext,
320                    }),
321                    verbatim: url,
322                })),
323                RequirementSource::Directory {
324                    install_path,
325                    editable,
326                    r#virtual,
327                    url,
328                } => Some(VersionOrUrl::Url(VerbatimParsedUrl {
329                    parsed_url: ParsedUrl::Directory(ParsedDirectoryUrl {
330                        url: url.to_url(),
331                        install_path,
332                        editable,
333                        r#virtual,
334                    }),
335                    verbatim: url,
336                })),
337            },
338        }
339    }
340}
341
342impl From<uv_pep508::Requirement<VerbatimParsedUrl>> for Requirement {
343    /// Convert a [`uv_pep508::Requirement`] to a [`Requirement`].
344    fn from(requirement: uv_pep508::Requirement<VerbatimParsedUrl>) -> Self {
345        let source = match requirement.version_or_url {
346            None => RequirementSource::Registry {
347                specifier: VersionSpecifiers::empty(),
348                index: None,
349                conflict: None,
350            },
351            // The most popular case: just a name, a version range and maybe extras.
352            Some(VersionOrUrl::VersionSpecifier(specifier)) => RequirementSource::Registry {
353                specifier,
354                index: None,
355                conflict: None,
356            },
357            Some(VersionOrUrl::Url(url)) => {
358                RequirementSource::from_parsed_url(url.parsed_url, url.verbatim)
359            }
360        };
361        Self {
362            name: requirement.name,
363            groups: Box::new([]),
364            extras: requirement.extras,
365            marker: requirement.marker,
366            source,
367            scope: RequirementScope::Global,
368            origin: requirement.origin,
369        }
370    }
371}
372
373impl Display for Requirement {
374    /// Display the [`Requirement`], with the intention of being shown directly to a user, rather
375    /// than for inclusion in a `requirements.txt` file.
376    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
377        write!(f, "{}", self.name)?;
378        if !self.extras.is_empty() {
379            write!(
380                f,
381                "[{}]",
382                self.extras
383                    .iter()
384                    .map(ToString::to_string)
385                    .collect::<Vec<_>>()
386                    .join(",")
387            )?;
388        }
389        match &self.source {
390            RequirementSource::Registry {
391                specifier, index, ..
392            } => {
393                write!(f, "{specifier}")?;
394                if let Some(index) = index {
395                    write!(f, " (index: {})", index.url)?;
396                }
397            }
398            RequirementSource::Url { url, .. } => {
399                write!(f, " @ {url}")?;
400            }
401            RequirementSource::GitDirectory {
402                url: _,
403                git,
404                subdirectory,
405            } => {
406                write!(f, " @ git+{}", git.url())?;
407                if let Some(reference) = git.reference().as_url_rev() {
408                    write!(f, "@{reference}")?;
409                }
410                if let Some(subdirectory) = subdirectory {
411                    writeln!(f, "#subdirectory={}", subdirectory.display())?;
412                }
413                if git.lfs().enabled() {
414                    writeln!(
415                        f,
416                        "{}lfs=true",
417                        if subdirectory.is_some() { "&" } else { "#" }
418                    )?;
419                }
420            }
421            RequirementSource::GitPath {
422                url: _,
423                git,
424                install_path,
425                ext: _,
426            } => {
427                write!(f, " @ git+{}", git.url())?;
428                if let Some(reference) = git.reference().as_url_rev() {
429                    write!(f, "@{reference}")?;
430                }
431                write!(f, "#path={}", install_path.display())?;
432                if git.lfs().enabled() {
433                    write!(f, "&lfs=true")?;
434                }
435                writeln!(f)?;
436            }
437            RequirementSource::Path { url, .. } => {
438                write!(f, " @ {url}")?;
439            }
440            RequirementSource::Directory { url, .. } => {
441                write!(f, " @ {url}")?;
442            }
443        }
444        if let Some(marker) = self.marker.contents() {
445            write!(f, " ; {marker}")?;
446        }
447        Ok(())
448    }
449}
450
451impl CacheKey for Requirement {
452    fn cache_key(&self, state: &mut CacheKeyHasher) {
453        self.name.as_str().cache_key(state);
454
455        self.groups.len().cache_key(state);
456        for group in &self.groups {
457            group.as_str().cache_key(state);
458        }
459
460        self.extras.len().cache_key(state);
461        for extra in &self.extras {
462            extra.as_str().cache_key(state);
463        }
464
465        if let Some(marker) = self.marker.contents() {
466            1u8.cache_key(state);
467            marker.to_string().cache_key(state);
468        } else {
469            0u8.cache_key(state);
470        }
471
472        match &self.source {
473            RequirementSource::Registry {
474                specifier,
475                index,
476                conflict: _,
477            } => {
478                0u8.cache_key(state);
479                specifier.len().cache_key(state);
480                for spec in specifier.iter() {
481                    spec.operator().as_str().cache_key(state);
482                    spec.version().cache_key(state);
483                }
484                if let Some(index) = index {
485                    1u8.cache_key(state);
486                    index.url.cache_key(state);
487                } else {
488                    0u8.cache_key(state);
489                }
490                // `conflict` is intentionally omitted
491            }
492            RequirementSource::Url {
493                location,
494                subdirectory,
495                ext,
496                url,
497            } => {
498                1u8.cache_key(state);
499                location.cache_key(state);
500                if let Some(subdirectory) = subdirectory {
501                    1u8.cache_key(state);
502                    subdirectory.display().to_string().cache_key(state);
503                } else {
504                    0u8.cache_key(state);
505                }
506                ext.name().cache_key(state);
507                url.cache_key(state);
508            }
509            RequirementSource::GitDirectory {
510                git,
511                subdirectory,
512                url,
513            } => {
514                2u8.cache_key(state);
515                git.to_string().cache_key(state);
516                if let Some(subdirectory) = subdirectory {
517                    1u8.cache_key(state);
518                    subdirectory.display().to_string().cache_key(state);
519                } else {
520                    0u8.cache_key(state);
521                }
522                if git.lfs().enabled() {
523                    1u8.cache_key(state);
524                }
525                url.cache_key(state);
526            }
527            RequirementSource::GitPath {
528                git,
529                install_path,
530                ext,
531                url,
532            } => {
533                5u8.cache_key(state);
534                git.to_string().cache_key(state);
535                install_path.cache_key(state);
536                ext.name().cache_key(state);
537                if git.lfs().enabled() {
538                    1u8.cache_key(state);
539                }
540                url.cache_key(state);
541            }
542            RequirementSource::Path {
543                install_path,
544                ext,
545                url,
546            } => {
547                3u8.cache_key(state);
548                install_path.cache_key(state);
549                ext.name().cache_key(state);
550                url.cache_key(state);
551            }
552            RequirementSource::Directory {
553                install_path,
554                editable,
555                r#virtual,
556                url,
557            } => {
558                4u8.cache_key(state);
559                install_path.cache_key(state);
560                editable.cache_key(state);
561                r#virtual.cache_key(state);
562                url.cache_key(state);
563            }
564        }
565
566        // `origin` is intentionally omitted
567    }
568}
569
570/// The different locations with can install a distribution from: Version specifier (from an index),
571/// HTTP(S) URL, git repository, and path.
572///
573/// We store both the parsed fields (such as the plain url and the subdirectory) and the joined
574/// PEP 508 style url (e.g. `file:///<path>#subdirectory=<subdirectory>`) since we need both in
575/// different locations.
576#[derive(
577    Hash, Debug, Clone, Eq, PartialEq, Ord, PartialOrd, serde::Serialize, serde::Deserialize,
578)]
579#[serde(try_from = "RequirementSourceWire", into = "RequirementSourceWire")]
580pub enum RequirementSource {
581    /// The requirement has a version specifier, such as `foo >1,<2`.
582    Registry {
583        specifier: VersionSpecifiers,
584        /// Choose a version from the index at the given URL.
585        index: Option<IndexMetadata>,
586        /// The conflict item associated with the source, if any.
587        conflict: Option<ConflictItem>,
588    },
589    // TODO(konsti): Track and verify version specifier from `project.dependencies` matches the
590    // version in remote location.
591    /// A remote `http://` or `https://` URL, either a built distribution,
592    /// e.g. `foo @ https://example.org/foo-1.0-py3-none-any.whl`, or a source distribution,
593    /// e.g.`foo @ https://example.org/foo-1.0.zip`.
594    Url {
595        /// The remote location of the archive file, without subdirectory fragment.
596        location: SafeUrl<Raw>,
597        /// For source distributions, the path to the distribution if it is not in the archive
598        /// root.
599        subdirectory: Option<Box<Path>>,
600        /// The file extension, e.g. `tar.gz`, `zip`, etc.
601        ext: DistExtension,
602        /// The PEP 508 style URL in the format
603        /// `<scheme>://<domain>/<path>#subdirectory=<subdirectory>`.
604        url: VerbatimUrl,
605    },
606    /// A remote Git source tree, over either HTTPS or SSH.
607    GitDirectory {
608        /// The repository URL and reference to the commit to use.
609        git: GitUrl,
610        /// The path to the source distribution if it is not in the repository root.
611        subdirectory: Option<Box<Path>>,
612        /// The PEP 508 style url in the format
613        /// `git+<scheme>://<domain>/<path>@<rev>#subdirectory=<subdirectory>`.
614        url: VerbatimUrl,
615    },
616    /// A remote Git archive, over either HTTPS or SSH.
617    GitPath {
618        /// The repository URL and reference to the commit to use.
619        git: GitUrl,
620        /// The path to the file in the repository.
621        install_path: PathBuf,
622        /// The file extension, e.g. `tar.gz`, `zip`, etc.
623        ext: DistExtension,
624        /// The PEP 508 style url in the format
625        /// `git+<scheme>://<domain>/<path>@<rev>#subdirectory=<subdirectory>`.
626        url: VerbatimUrl,
627    },
628    /// A local built or source distribution, either from a path or a `file://` URL. It can either
629    /// be a binary distribution (a `.whl` file) or a source distribution archive (a `.zip` or
630    /// `.tar.gz` file).
631    Path {
632        /// The absolute path to the distribution which we use for installing.
633        install_path: Box<Path>,
634        /// The file extension, e.g. `tar.gz`, `zip`, etc.
635        ext: DistExtension,
636        /// The PEP 508 style URL in the format
637        /// `file:///<path>#subdirectory=<subdirectory>`.
638        url: VerbatimUrl,
639    },
640    /// A local source tree (a directory with a pyproject.toml in, or a legacy
641    /// source distribution with only a setup.py but non pyproject.toml in it).
642    Directory {
643        /// The absolute path to the distribution which we use for installing.
644        install_path: Box<Path>,
645        /// For a source tree (a directory), whether to install as an editable.
646        editable: Option<bool>,
647        /// For a source tree (a directory), whether the project should be built and installed.
648        r#virtual: Option<bool>,
649        /// The PEP 508 style URL in the format
650        /// `file:///<path>#subdirectory=<subdirectory>`.
651        url: VerbatimUrl,
652    },
653}
654
655impl RequirementSource {
656    /// Construct a [`RequirementSource`] for a URL source, given a URL parsed into components and
657    /// the PEP 508 string (after the `@`) as [`VerbatimUrl`].
658    pub(crate) fn from_parsed_url(parsed_url: ParsedUrl, url: VerbatimUrl) -> Self {
659        match parsed_url {
660            ParsedUrl::Path(local_file) => Self::Path {
661                install_path: local_file.install_path.clone(),
662                ext: local_file.ext,
663                url,
664            },
665            ParsedUrl::Directory(directory) => Self::Directory {
666                install_path: directory.install_path.clone(),
667                editable: directory.editable,
668                r#virtual: directory.r#virtual,
669                url,
670            },
671            ParsedUrl::GitDirectory(git) => Self::GitDirectory {
672                url,
673                git: git.url,
674                subdirectory: git.subdirectory,
675            },
676            ParsedUrl::GitPath(git) => Self::GitPath {
677                url,
678                git: git.url,
679                install_path: git.install_path.clone(),
680                ext: git.ext,
681            },
682            ParsedUrl::Archive(archive) => Self::Url {
683                url,
684                location: archive.url.with_policy(Raw),
685                subdirectory: archive.subdirectory,
686                ext: archive.ext,
687            },
688        }
689    }
690
691    /// Convert the source to a [`VerbatimParsedUrl`], if it's a URL source.
692    pub fn to_verbatim_parsed_url(&self) -> Option<VerbatimParsedUrl> {
693        match self {
694            Self::Registry { .. } => None,
695            Self::Url {
696                location,
697                subdirectory,
698                ext,
699                url,
700            } => Some(VerbatimParsedUrl {
701                parsed_url: ParsedUrl::Archive(ParsedArchiveUrl::from_source(
702                    location.as_url().clone(),
703                    subdirectory.clone(),
704                    *ext,
705                )),
706                verbatim: url.clone(),
707            }),
708            Self::Path {
709                install_path,
710                ext,
711                url,
712            } => Some(VerbatimParsedUrl {
713                parsed_url: ParsedUrl::Path(ParsedPathUrl::from_source(
714                    install_path.clone(),
715                    *ext,
716                    url.to_url(),
717                )),
718                verbatim: url.clone(),
719            }),
720            Self::Directory {
721                install_path,
722                editable,
723                r#virtual,
724                url,
725            } => Some(VerbatimParsedUrl {
726                parsed_url: ParsedUrl::Directory(ParsedDirectoryUrl::from_source(
727                    install_path.clone(),
728                    *editable,
729                    *r#virtual,
730                    url.to_url(),
731                )),
732                verbatim: url.clone(),
733            }),
734            Self::GitDirectory {
735                git,
736                subdirectory,
737                url,
738            } => Some(VerbatimParsedUrl {
739                parsed_url: ParsedUrl::GitDirectory(ParsedGitDirectoryUrl::from_source(
740                    git.clone(),
741                    subdirectory.clone(),
742                )),
743                verbatim: url.clone(),
744            }),
745            Self::GitPath {
746                git,
747                install_path,
748                ext,
749                url,
750            } => Some(VerbatimParsedUrl {
751                parsed_url: ParsedUrl::GitPath(ParsedGitPathUrl::from_source(
752                    git.clone(),
753                    install_path.clone(),
754                    *ext,
755                )),
756                verbatim: url.clone(),
757            }),
758        }
759    }
760
761    /// Returns `true` if the source is empty.
762    pub fn is_empty(&self) -> bool {
763        match self {
764            Self::Registry { specifier, .. } => specifier.is_empty(),
765            Self::Url { .. }
766            | Self::GitPath { .. }
767            | Self::GitDirectory { .. }
768            | Self::Path { .. }
769            | Self::Directory { .. } => false,
770        }
771    }
772
773    /// If the source is the registry, return the version specifiers
774    pub fn version_specifiers(&self) -> Option<&VersionSpecifiers> {
775        match self {
776            Self::Registry { specifier, .. } => Some(specifier),
777            Self::Url { .. }
778            | Self::GitPath { .. }
779            | Self::GitDirectory { .. }
780            | Self::Path { .. }
781            | Self::Directory { .. } => None,
782        }
783    }
784
785    /// Convert the source to a [`RequirementSource`] relative to the given path.
786    fn relative_to(self, path: &Path) -> Result<Self, io::Error> {
787        match self {
788            Self::Registry { .. }
789            | Self::Url { .. }
790            | Self::GitPath { .. }
791            | Self::GitDirectory { .. } => Ok(self),
792            Self::Path {
793                install_path,
794                ext,
795                url,
796            } => Ok(Self::Path {
797                install_path: try_relative_to_if(&install_path, path, url.prefers_relative())?
798                    .into_boxed_path(),
799                ext,
800                url,
801            }),
802            Self::Directory {
803                install_path,
804                editable,
805                r#virtual,
806                url,
807                ..
808            } => Ok(Self::Directory {
809                install_path: try_relative_to_if(&install_path, path, url.prefers_relative())?
810                    .into_boxed_path(),
811                editable,
812                r#virtual,
813                url,
814            }),
815        }
816    }
817
818    /// Convert the source to a [`RequirementSource`] with an absolute path based on the given root.
819    #[must_use]
820    fn into_absolute(self, root: &Path) -> Self {
821        match self {
822            Self::Registry { .. }
823            | Self::Url { .. }
824            | Self::GitPath { .. }
825            | Self::GitDirectory { .. } => self,
826            Self::Path {
827                install_path,
828                ext,
829                url,
830            } => Self::Path {
831                install_path: normalize_path(root.join(install_path))
832                    .into_owned()
833                    .into_boxed_path(),
834                ext,
835                url,
836            },
837            Self::Directory {
838                install_path,
839                editable,
840                r#virtual,
841                url,
842                ..
843            } => Self::Directory {
844                install_path: normalize_path(root.join(install_path))
845                    .into_owned()
846                    .into_boxed_path(),
847                editable,
848                r#virtual,
849                url,
850            },
851        }
852    }
853}
854
855impl Display for RequirementSource {
856    /// Display the [`RequirementSource`], with the intention of being shown directly to a user,
857    /// rather than for inclusion in a `requirements.txt` file.
858    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
859        match self {
860            Self::Registry {
861                specifier, index, ..
862            } => {
863                write!(f, "{specifier}")?;
864                if let Some(index) = index {
865                    write!(f, " (index: {})", index.url)?;
866                }
867            }
868            Self::Url { url, .. } => {
869                write!(f, " {url}")?;
870            }
871            Self::GitDirectory {
872                url: _,
873                git,
874                subdirectory,
875            } => {
876                write!(f, " git+{}", git.url())?;
877                if let Some(reference) = git.reference().as_url_rev() {
878                    write!(f, "@{reference}")?;
879                }
880                if let Some(subdirectory) = subdirectory {
881                    writeln!(f, "#subdirectory={}", subdirectory.display())?;
882                }
883                if git.lfs().enabled() {
884                    writeln!(
885                        f,
886                        "{}lfs=true",
887                        if subdirectory.is_some() { "&" } else { "#" }
888                    )?;
889                }
890            }
891            Self::GitPath {
892                url: _,
893                git,
894                install_path,
895                ext: _,
896            } => {
897                write!(f, " git+{}", git.url())?;
898                if let Some(reference) = git.reference().as_url_rev() {
899                    write!(f, "@{reference}")?;
900                }
901                write!(f, "#path={}", install_path.display())?;
902                if git.lfs().enabled() {
903                    write!(f, "&lfs=true")?;
904                }
905                writeln!(f)?;
906            }
907            Self::Path { url, .. } => {
908                write!(f, "{url}")?;
909            }
910            Self::Directory { url, .. } => {
911                write!(f, "{url}")?;
912            }
913        }
914        Ok(())
915    }
916}
917
918#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
919#[serde(untagged)]
920enum RequirementSourceWire {
921    /// Ex) `source = { git = "<https://github.com/astral-test/uv-public-pypackage?rev=0.0.1#0dacfd662c64cb4ceb16e6cf65a157a8b715b979>" }`
922    Git { git: String },
923    /// Ex) `source = { url = "<https://example.org/foo-1.0.zip>" }`
924    Direct {
925        url: SafeUrl<Raw>,
926        subdirectory: Option<PortablePathBuf>,
927    },
928    /// Ex) `source = { path = "/home/ferris/iniconfig-2.0.0-py3-none-any.whl" }`
929    Path { path: PortablePathBuf },
930    /// Ex) `source = { directory = "/home/ferris/iniconfig" }`
931    Directory { directory: PortablePathBuf },
932    /// Ex) `source = { editable = "/home/ferris/iniconfig" }`
933    Editable { editable: PortablePathBuf },
934    /// Ex) `source = { editable = "/home/ferris/iniconfig" }`
935    Virtual { r#virtual: PortablePathBuf },
936    /// Ex) `source = { specifier = "foo >1,<2" }`
937    Registry {
938        #[serde(skip_serializing_if = "VersionSpecifiers::is_empty", default)]
939        specifier: VersionSpecifiers,
940        index: Option<SafeUrl<Stripped>>,
941        conflict: Option<ConflictItem>,
942    },
943}
944
945impl From<RequirementSource> for RequirementSourceWire {
946    fn from(value: RequirementSource) -> Self {
947        match value {
948            RequirementSource::Registry {
949                specifier,
950                index,
951                conflict,
952            } => {
953                let index = index.map(|index| index.url.into_url());
954                Self::Registry {
955                    specifier,
956                    index,
957                    conflict,
958                }
959            }
960            RequirementSource::Url {
961                subdirectory,
962                location,
963                ext: _,
964                url: _,
965            } => Self::Direct {
966                url: location,
967                subdirectory: subdirectory.map(PortablePathBuf::from),
968            },
969            RequirementSource::GitDirectory {
970                git,
971                subdirectory,
972                url: _,
973            } => {
974                let mut url = git.url().clone();
975
976                // Remove the credentials.
977                url.remove_credentials();
978
979                // Clear out any existing state.
980                url.set_fragment(None);
981                url.set_query(None);
982
983                // Put the subdirectory in the query.
984                if let Some(subdirectory) = subdirectory
985                    .as_deref()
986                    .map(PortablePath::from)
987                    .as_ref()
988                    .map(PortablePath::to_string)
989                {
990                    url.query_pairs_mut()
991                        .append_pair("subdirectory", &subdirectory);
992                }
993
994                // Persist lfs=true in the distribution metadata only when explicitly enabled.
995                if git.lfs().enabled() {
996                    url.query_pairs_mut().append_pair("lfs", "true");
997                }
998
999                // Put the requested reference in the query.
1000                match git.reference() {
1001                    GitReference::Commit(commit) => {
1002                        url.query_pairs_mut().append_pair("rev", commit.as_str());
1003                    }
1004                    GitReference::Branch(branch) => {
1005                        url.query_pairs_mut().append_pair("branch", branch.as_str());
1006                    }
1007                    GitReference::Tag(tag) => {
1008                        url.query_pairs_mut().append_pair("tag", tag.as_str());
1009                    }
1010                    GitReference::BranchOrTag(rev)
1011                    | GitReference::BranchOrTagOrCommit(rev)
1012                    | GitReference::NamedRef(rev) => {
1013                        url.query_pairs_mut().append_pair("rev", rev.as_str());
1014                    }
1015                    GitReference::DefaultBranch => {}
1016                }
1017
1018                // Put the resolved commit in the fragment.
1019                if let Some(commit) = git.resolved_commit() {
1020                    url.set_fragment(Some(commit.as_str()));
1021                }
1022
1023                Self::Git {
1024                    git: url.to_string(),
1025                }
1026            }
1027            RequirementSource::GitPath {
1028                git,
1029                install_path,
1030                ext: _,
1031                url: _,
1032            } => {
1033                let mut url = git.url().clone();
1034
1035                // Remove the credentials.
1036                url.remove_credentials();
1037
1038                // Clear out any existing state.
1039                url.set_fragment(None);
1040                url.set_query(None);
1041
1042                // Put the path in the query.
1043                if let Some(install_path) = install_path.to_str() {
1044                    url.query_pairs_mut().append_pair("path", install_path);
1045                }
1046
1047                // Put the requested reference in the query.
1048                match git.reference() {
1049                    GitReference::Commit(commit) => {
1050                        url.query_pairs_mut().append_pair("rev", commit.as_str());
1051                    }
1052                    GitReference::Branch(branch) => {
1053                        url.query_pairs_mut().append_pair("branch", branch.as_str());
1054                    }
1055                    GitReference::Tag(tag) => {
1056                        url.query_pairs_mut().append_pair("tag", tag.as_str());
1057                    }
1058                    GitReference::BranchOrTag(rev)
1059                    | GitReference::BranchOrTagOrCommit(rev)
1060                    | GitReference::NamedRef(rev) => {
1061                        url.query_pairs_mut().append_pair("rev", rev.as_str());
1062                    }
1063                    GitReference::DefaultBranch => {}
1064                }
1065
1066                // Persist lfs=true in the distribution metadata only when explicitly enabled.
1067                if git.lfs().enabled() {
1068                    url.query_pairs_mut().append_pair("lfs", "true");
1069                }
1070
1071                // Put the resolved commit in the fragment.
1072                if let Some(commit) = git.resolved_commit() {
1073                    url.set_fragment(Some(commit.as_str()));
1074                }
1075
1076                Self::Git {
1077                    git: url.to_string(),
1078                }
1079            }
1080            RequirementSource::Path {
1081                install_path,
1082                ext: _,
1083                url: _,
1084            } => Self::Path {
1085                path: PortablePathBuf::from(install_path),
1086            },
1087            RequirementSource::Directory {
1088                install_path,
1089                editable,
1090                r#virtual,
1091                url: _,
1092            } => {
1093                if editable.unwrap_or(false) {
1094                    Self::Editable {
1095                        editable: PortablePathBuf::from(install_path),
1096                    }
1097                } else if r#virtual.unwrap_or(false) {
1098                    Self::Virtual {
1099                        r#virtual: PortablePathBuf::from(install_path),
1100                    }
1101                } else {
1102                    Self::Directory {
1103                        directory: PortablePathBuf::from(install_path),
1104                    }
1105                }
1106            }
1107        }
1108    }
1109}
1110
1111impl TryFrom<RequirementSourceWire> for RequirementSource {
1112    type Error = RequirementError;
1113
1114    fn try_from(wire: RequirementSourceWire) -> Result<Self, RequirementError> {
1115        match wire {
1116            RequirementSourceWire::Registry {
1117                specifier,
1118                index,
1119                conflict,
1120            } => Ok(Self::Registry {
1121                specifier,
1122                index: index
1123                    .map(|index| IndexMetadata::from(IndexUrl::from(VerbatimUrl::from_url(index)))),
1124                conflict,
1125            }),
1126            RequirementSourceWire::Git { git } => {
1127                let mut repository = SafeUrl::parse(&git)?;
1128
1129                let mut reference = GitReference::DefaultBranch;
1130                let mut subdirectory: Option<PortablePathBuf> = None;
1131                let mut lfs = GitLfs::Disabled;
1132                let mut path: Option<PortablePathBuf> = None;
1133                for (key, val) in repository.query_pairs() {
1134                    match &*key {
1135                        "tag" => reference = GitReference::Tag(val.into_owned()),
1136                        "branch" => reference = GitReference::Branch(val.into_owned()),
1137                        "rev" => reference = GitReference::from_rev(val.into_owned()),
1138                        "subdirectory" => {
1139                            subdirectory = Some(PortablePathBuf::from(val.as_ref()));
1140                        }
1141                        "lfs" => lfs = GitLfs::from(val.eq_ignore_ascii_case("true")),
1142                        "path" => {
1143                            path = Some(PortablePathBuf::from(val.as_ref()));
1144                        }
1145                        _ => {}
1146                    }
1147                }
1148
1149                let precise = repository.fragment().map(GitOid::from_str).transpose()?;
1150
1151                // Clear out any existing state.
1152                repository.set_fragment(None);
1153                repository.set_query(None);
1154
1155                // Remove the credentials.
1156                repository.remove_credentials();
1157
1158                // Create a PEP 508-compatible URL.
1159                let mut url = SafeUrl::parse(&format!("git+{repository}"))?;
1160                if let Some(rev) = reference.as_url_rev() {
1161                    let path = format!("{}@{}", url.path(), rev);
1162                    url.set_path(&path);
1163                }
1164                let mut frags: Vec<String> = Vec::new();
1165                if let Some(subdirectory) = subdirectory.as_ref() {
1166                    frags.push(format!("subdirectory={subdirectory}"));
1167                }
1168                // Preserve that we're using Git LFS in the Verbatim Url representations
1169                if lfs.enabled() {
1170                    frags.push("lfs=true".to_string());
1171                }
1172                if let Some(path) = path.as_ref() {
1173                    frags.push(format!("path={path}"));
1174                }
1175                if !frags.is_empty() {
1176                    url.set_fragment(Some(&frags.join("&")));
1177                }
1178                let url = VerbatimUrl::from_url(url);
1179                let git = GitUrl::from_fields(repository, reference, precise, lfs)?;
1180
1181                if let Some(install_path) = path.map(Box::<Path>::from).map(PathBuf::from) {
1182                    Ok(Self::GitPath {
1183                        git,
1184                        ext: DistExtension::from_path(install_path.as_path()).map_err(|err| {
1185                            ParsedUrlError::MissingExtensionPath(install_path.clone(), err)
1186                        })?,
1187                        install_path,
1188                        url,
1189                    })
1190                } else {
1191                    Ok(Self::GitDirectory {
1192                        git,
1193                        subdirectory: subdirectory.map(Box::<Path>::from),
1194                        url,
1195                    })
1196                }
1197            }
1198            RequirementSourceWire::Direct { url, subdirectory } => {
1199                let location = url.clone();
1200
1201                // Create a PEP 508-compatible URL.
1202                let mut url = url.into_url();
1203                if let Some(subdirectory) = &subdirectory {
1204                    url.set_fragment(Some(&format!("subdirectory={subdirectory}")));
1205                }
1206
1207                Ok(Self::Url {
1208                    location,
1209                    subdirectory: subdirectory.map(Box::<Path>::from),
1210                    ext: DistExtension::from_path(url.path())
1211                        .map_err(|err| ParsedUrlError::MissingExtensionUrl(url.to_string(), err))?,
1212                    url: VerbatimUrl::from_url(url),
1213                })
1214            }
1215            // TODO(charlie): The use of `CWD` here is incorrect. These should be resolved relative
1216            // to the workspace root, but we don't have access to it here. When comparing these
1217            // sources in the lockfile, we replace the URL anyway. Ideally, we'd either remove the
1218            // URL field or make it optional.
1219            RequirementSourceWire::Path { path } => {
1220                let path = Box::<Path>::from(path);
1221                let url = VerbatimUrl::from_normalized_path(normalize_path(CWD.join(&path)))?;
1222                Ok(Self::Path {
1223                    ext: DistExtension::from_path(&path).map_err(|err| {
1224                        ParsedUrlError::MissingExtensionPath(path.to_path_buf(), err)
1225                    })?,
1226                    install_path: path,
1227                    url,
1228                })
1229            }
1230            RequirementSourceWire::Directory { directory } => {
1231                let directory = Box::<Path>::from(directory);
1232                let url = VerbatimUrl::from_normalized_path(normalize_path(CWD.join(&directory)))?;
1233                Ok(Self::Directory {
1234                    install_path: directory,
1235                    editable: Some(false),
1236                    r#virtual: Some(false),
1237                    url,
1238                })
1239            }
1240            RequirementSourceWire::Editable { editable } => {
1241                let editable = Box::<Path>::from(editable);
1242                let url = VerbatimUrl::from_normalized_path(normalize_path(CWD.join(&editable)))?;
1243                Ok(Self::Directory {
1244                    install_path: editable,
1245                    editable: Some(true),
1246                    r#virtual: Some(false),
1247                    url,
1248                })
1249            }
1250            RequirementSourceWire::Virtual { r#virtual } => {
1251                let r#virtual = Box::<Path>::from(r#virtual);
1252                let url = VerbatimUrl::from_normalized_path(normalize_path(CWD.join(&r#virtual)))?;
1253                Ok(Self::Directory {
1254                    install_path: r#virtual,
1255                    editable: Some(false),
1256                    r#virtual: Some(true),
1257                    url,
1258                })
1259            }
1260        }
1261    }
1262}
1263
1264#[cfg(test)]
1265mod tests {
1266    use std::error::Error;
1267    use std::path::PathBuf;
1268
1269    use uv_pep508::{MarkerTree, VerbatimUrl};
1270
1271    use crate::{Requirement, RequirementScope, RequirementSource};
1272
1273    #[test]
1274    fn roundtrip() {
1275        let requirement = Requirement {
1276            name: "foo".parse().unwrap(),
1277            extras: Box::new([]),
1278            groups: Box::new([]),
1279            marker: MarkerTree::TRUE,
1280            source: RequirementSource::Registry {
1281                specifier: ">1,<2".parse().unwrap(),
1282                index: None,
1283                conflict: None,
1284            },
1285            scope: RequirementScope::Global,
1286            origin: None,
1287        };
1288
1289        let raw = toml::to_string(&requirement).unwrap();
1290        let deserialized: Requirement = toml::from_str(&raw).unwrap();
1291        assert_eq!(requirement, deserialized);
1292
1293        let path = if cfg!(windows) {
1294            "C:\\home\\ferris\\foo"
1295        } else {
1296            "/home/ferris/foo"
1297        };
1298        let requirement = Requirement {
1299            name: "foo".parse().unwrap(),
1300            extras: Box::new([]),
1301            groups: Box::new([]),
1302            marker: MarkerTree::TRUE,
1303            source: RequirementSource::Directory {
1304                install_path: PathBuf::from(path).into_boxed_path(),
1305                editable: Some(false),
1306                r#virtual: Some(false),
1307                url: VerbatimUrl::from_absolute_path(path).unwrap(),
1308            },
1309            scope: RequirementScope::Global,
1310            origin: None,
1311        };
1312
1313        let raw = toml::to_string(&requirement).unwrap();
1314        let deserialized: Requirement = toml::from_str(&raw).unwrap();
1315        assert_eq!(requirement, deserialized);
1316    }
1317
1318    #[test]
1319    fn roundtrip_git_commit() -> Result<(), Box<dyn Error>> {
1320        let raw = "git = \"https://example.com/repository?rev=0dacfd662c64cb4ceb16e6cf65a157a8b715b979\"\n";
1321        let source: RequirementSource = toml::from_str(raw)?;
1322        assert_eq!(toml::to_string(&source)?, raw);
1323
1324        // A redundant precise fragment denotes the same requirement as the full commit revision.
1325        let resolved: RequirementSource = toml::from_str(
1326            "git = \"https://example.com/repository?rev=0dacfd662c64cb4ceb16e6cf65a157a8b715b979#0dacfd662c64cb4ceb16e6cf65a157a8b715b979\"",
1327        )?;
1328        assert_eq!(source, resolved);
1329
1330        Ok(())
1331    }
1332
1333    #[test]
1334    fn display_git_path_lfs() {
1335        let source: RequirementSource = toml::from_str(
1336            r#"git = "https://github.com/astral-sh/archive-in-git-test?lfs=true&path=archives%2Finiconfig-2.0.0-py3-none-any.whl""#,
1337        )
1338        .unwrap();
1339
1340        assert_eq!(
1341            source.to_string(),
1342            " git+https://github.com/astral-sh/archive-in-git-test#path=archives/iniconfig-2.0.0-py3-none-any.whl&lfs=true\n"
1343        );
1344
1345        let requirement = Requirement {
1346            name: "iniconfig".parse().unwrap(),
1347            extras: Box::new([]),
1348            groups: Box::new([]),
1349            marker: MarkerTree::TRUE,
1350            source,
1351            scope: RequirementScope::Global,
1352            origin: None,
1353        };
1354        assert_eq!(
1355            requirement.to_string(),
1356            "iniconfig @ git+https://github.com/astral-sh/archive-in-git-test#path=archives/iniconfig-2.0.0-py3-none-any.whl&lfs=true\n"
1357        );
1358    }
1359}