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