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