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