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