Skip to main content

uv_resolver/lock/export/
pylock_toml.rs

1use std::borrow::Cow;
2use std::collections::HashSet;
3use std::ffi::OsStr;
4use std::path::{Path, PathBuf};
5use std::str::FromStr;
6use std::sync::Arc;
7
8use futures::{StreamExt, TryStreamExt};
9use jiff::Timestamp;
10use jiff::civil::{Date, DateTime, Time};
11use jiff::tz::{Offset, TimeZone};
12use petgraph::graph::NodeIndex;
13use serde::Deserialize;
14use tokio_util::compat::FuturesAsyncReadCompatExt;
15use toml::Table as TomlTable;
16use toml_edit::{Array, ArrayOfTables, Item, Table, Value, value};
17use url::Url;
18
19use uv_client::{RegistryClient, WrappedReqwestError};
20use uv_configuration::{
21    BuildOptions, DependencyGroupsWithDefaults, EditableMode, ExtrasSpecificationWithDefaults,
22    InstallOptions,
23};
24use uv_distribution_filename::{
25    BuildTag, DistExtension, ExtensionError, SourceDistExtension, SourceDistFilename,
26    SourceDistFilenameError, WheelFilename, WheelFilenameError,
27};
28use uv_distribution_types::{
29    BuiltDist, DirectUrlBuiltDist, DirectUrlSourceDist, DirectorySourceDist, Dist, Edge,
30    FileLocation, FirstParty, GitDirectorySourceDist, IndexUrl, Name, Node, PathBuiltDist,
31    PathSourceDist, RegistryBuiltDist, RegistryBuiltWheel, RegistrySourceDist, RemoteSource,
32    RequiresPython, Resolution, ResolvedDist, SourceDist, ToUrlError, UrlString,
33};
34use uv_extract::hash::{HashReader, Hasher};
35use uv_fs::{PortablePathBuf, normalize_path, try_relative_to_if};
36use uv_git::{RepositoryReference, ResolvedRepositoryReference};
37use uv_git_types::{GitLfs, GitOid, GitReference, GitUrl, GitUrlParseError};
38use uv_normalize::{ExtraName, GroupName, PackageName};
39use uv_pep440::Version;
40use uv_pep508::{MarkerEnvironment, MarkerTree, VerbatimUrl};
41use uv_platform_tags::{TagCompatibility, TagPriority, Tags};
42use uv_pypi_types::{
43    HashAlgorithm, HashDigest, HashDigests, Hashes, ParsedGitDirectoryUrl, VcsKind,
44};
45use uv_redacted::DisplaySafeUrl;
46use uv_small_str::SmallString;
47use uv_warnings::warn_user_once;
48
49use crate::lock::export::ExportableRequirements;
50use crate::lock::{Source, WheelTagHint, is_wheel_unreachable};
51use crate::{Installable, LockError, ResolverOutput};
52
53/// Format an array so that each element is on its own line and has a trailing comma.
54fn each_element_on_its_line_array(elements: impl Iterator<Item = impl Into<Value>>) -> Array {
55    let mut array = elements
56        .map(|item| {
57            let mut value = item.into();
58            value.decor_mut().set_prefix("\n    ");
59            value
60        })
61        .collect::<Array>();
62    array.set_trailing_comma(true);
63    array.set_trailing("\n");
64    array
65}
66
67#[derive(Debug, thiserror::Error)]
68pub enum PylockTomlErrorKind {
69    #[error("Multiple active package entries found for `{0}`")]
70    DuplicateActivePackage(PackageName),
71    #[error(
72        "Archive `{}` has size {actual}, but the lockfile records {expected}",
73        path.display()
74    )]
75    ArchiveSizeMismatch {
76        path: PathBuf,
77        expected: u64,
78        actual: u64,
79    },
80    #[error("Package `{0}` requires Python {2}, but the target Python version is {1}")]
81    IncompatibleRequiresPython(PackageName, Version, RequiresPython),
82    #[error(
83        "Package `{0}` includes both a registry (`packages.wheels`) and a directory source (`packages.directory`)"
84    )]
85    WheelWithDirectory(PackageName),
86    #[error(
87        "Package `{0}` includes both a registry (`packages.wheels`) and a VCS source (`packages.vcs`)"
88    )]
89    WheelWithVcs(PackageName),
90    #[error(
91        "Package `{0}` includes both a registry (`packages.wheels`) and an archive source (`packages.archive`)"
92    )]
93    WheelWithArchive(PackageName),
94    #[error(
95        "Package `{0}` includes both a registry (`packages.sdist`) and a directory source (`packages.directory`)"
96    )]
97    SdistWithDirectory(PackageName),
98    #[error(
99        "Package `{0}` includes both a registry (`packages.sdist`) and a VCS source (`packages.vcs`)"
100    )]
101    SdistWithVcs(PackageName),
102    #[error(
103        "Package `{0}` includes both a registry (`packages.sdist`) and an archive source (`packages.archive`)"
104    )]
105    SdistWithArchive(PackageName),
106    #[error(
107        "Package `{0}` includes both a directory (`packages.directory`) and a VCS source (`packages.vcs`)"
108    )]
109    DirectoryWithVcs(PackageName),
110    #[error(
111        "Package `{0}` includes both a directory (`packages.directory`) and an archive source (`packages.archive`)"
112    )]
113    DirectoryWithArchive(PackageName),
114    #[error(
115        "Package `{0}` includes both a VCS (`packages.vcs`) and an archive source (`packages.archive`)"
116    )]
117    VcsWithArchive(PackageName),
118    #[error(
119        "Package `{0}` must include one of: `wheels`, `directory`, `archive`, `sdist`, or `vcs`"
120    )]
121    MissingSource(PackageName),
122    #[error("Package `{0}` uses a Git archive, which pylock.toml export does not support")]
123    GitArchiveUnsupported(PackageName),
124    #[error("Package `{0}` does not include a compatible wheel for the current platform")]
125    MissingWheel(PackageName),
126    #[error("`packages.wheel` entry for `{0}` must have a `path` or `url`")]
127    WheelMissingPathUrl(PackageName),
128    #[error("`packages.sdist` entry for `{0}` must have a `path` or `url`")]
129    SdistMissingPathUrl(PackageName),
130    #[error("`packages.archive` entry for `{0}` must have a `path` or `url`")]
131    ArchiveMissingPathUrl(PackageName),
132    #[error("`packages.vcs` entry for `{0}` must have a `url` or `path`")]
133    VcsMissingPathUrl(PackageName),
134    #[error("`{1}` entry for `{0}` has no hashes and no URL or path to compute them")]
135    MissingHashes(PackageName, &'static str),
136    // Request and status errors use `WrappedReqwestError`, while errors reading the response body
137    // use `io::Error`. Both are download failures.
138    #[error("Failed to download `{0}` to compute missing hashes")]
139    DownloadFile(Box<DisplaySafeUrl>, #[source] WrappedReqwestError),
140    #[error("Failed to download `{0}` to compute missing hashes")]
141    StreamFile(Box<DisplaySafeUrl>, #[source] std::io::Error),
142    #[error("Failed to read `{0}` to compute missing hashes")]
143    ReadFile(Box<Path>, #[source] std::io::Error),
144    #[error("URL must end in a valid wheel filename: `{0}`")]
145    UrlMissingFilename(DisplaySafeUrl),
146    #[error("Invalid artifact URL: `{0}`")]
147    InvalidArtifactUrl(UrlString),
148    #[error("Path must end in a valid wheel filename: `{0}`")]
149    PathMissingFilename(Box<Path>),
150    #[error("Failed to convert path to URL")]
151    PathToUrl,
152    #[error("Failed to convert URL to path")]
153    UrlToPath,
154    #[error(
155        "Package `{0}` can't be installed because it doesn't have a source distribution or wheel for the current platform"
156    )]
157    NeitherSourceDistNorWheel(PackageName),
158    #[error(
159        "Package `{0}` can't be installed because it is marked as both `--no-binary` and `--no-build`"
160    )]
161    NoBinaryNoBuild(PackageName),
162    #[error(
163        "Package `{0}` can't be installed because it is marked as `--no-binary` but has no source distribution"
164    )]
165    NoBinary(PackageName),
166    #[error(
167        "Package `{0}` can't be installed because it is marked as `--no-build` but has no binary distribution"
168    )]
169    NoBuild(PackageName),
170    #[error(
171        "Package `{0}` can't be installed because the binary distribution is incompatible with the current platform"
172    )]
173    IncompatibleWheelOnly(PackageName),
174    #[error(
175        "Package `{0}` can't be installed because it is marked as `--no-binary` but is itself a binary distribution"
176    )]
177    NoBinaryWheelOnly(PackageName),
178    #[error(transparent)]
179    WheelFilename(#[from] WheelFilenameError),
180    #[error(transparent)]
181    SourceDistFilename(#[from] SourceDistFilenameError),
182    #[error(transparent)]
183    ToUrl(#[from] ToUrlError),
184    #[error(transparent)]
185    GitUrlParse(#[from] GitUrlParseError),
186    #[error(transparent)]
187    LockError(#[from] LockError),
188    #[error(transparent)]
189    Extension(#[from] ExtensionError),
190    #[error(transparent)]
191    Jiff(#[from] jiff::Error),
192    #[error(transparent)]
193    Io(#[from] std::io::Error),
194    #[error(transparent)]
195    Deserialize(#[from] toml::de::Error),
196}
197
198#[derive(Debug)]
199pub struct PylockTomlError {
200    kind: Box<PylockTomlErrorKind>,
201    hint: Option<WheelTagHint>,
202}
203
204impl std::error::Error for PylockTomlError {
205    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
206        self.kind.source()
207    }
208}
209
210impl std::fmt::Display for PylockTomlError {
211    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
212        write!(f, "{}", self.kind)
213    }
214}
215
216impl uv_errors::Hint for PylockTomlError {
217    fn hints(&self) -> uv_errors::Hints<'_> {
218        if let Some(hint) = &self.hint {
219            uv_errors::Hints::from(hint.to_string())
220        } else {
221            uv_errors::Hints::none()
222        }
223    }
224}
225
226fn validate_path_size(path: &Path, expected: Option<u64>) -> Result<(), PylockTomlErrorKind> {
227    let Some(expected) = expected else {
228        return Ok(());
229    };
230    let actual = fs_err::metadata(path)?.len();
231    if actual != expected {
232        return Err(PylockTomlErrorKind::ArchiveSizeMismatch {
233            path: path.to_path_buf(),
234            expected,
235            actual,
236        });
237    }
238    Ok(())
239}
240
241impl<E> From<E> for PylockTomlError
242where
243    PylockTomlErrorKind: From<E>,
244{
245    fn from(err: E) -> Self {
246        Self {
247            kind: Box::new(PylockTomlErrorKind::from(err)),
248            hint: None,
249        }
250    }
251}
252
253#[derive(Debug, serde::Serialize, serde::Deserialize)]
254#[serde(rename_all = "kebab-case")]
255pub struct PylockToml {
256    #[serde(deserialize_with = "deserialize_lock_version")]
257    lock_version: Version,
258    created_by: String,
259    #[serde(skip_serializing_if = "Option::is_none")]
260    pub requires_python: Option<RequiresPython>,
261    #[serde(skip_serializing_if = "Vec::is_empty", default)]
262    pub extras: Vec<ExtraName>,
263    #[serde(skip_serializing_if = "Vec::is_empty", default)]
264    pub dependency_groups: Vec<GroupName>,
265    #[serde(skip_serializing_if = "Vec::is_empty", default)]
266    pub default_groups: Vec<GroupName>,
267    pub packages: Vec<PylockTomlPackage>,
268    #[serde(skip_serializing_if = "Vec::is_empty", default)]
269    attestation_identities: Vec<PylockTomlAttestationIdentity>,
270}
271
272fn deserialize_lock_version<'de, D>(deserializer: D) -> Result<Version, D::Error>
273where
274    D: serde::Deserializer<'de>,
275{
276    let version = Version::deserialize(deserializer)?;
277    if version.release().first() != Some(&1) {
278        return Err(serde::de::Error::custom(format_args!(
279            "unsupported lock version (`{version}`, but only major version 1 is supported)"
280        )));
281    }
282
283    Ok(version)
284}
285
286/// Deserialize artifact [`Hashes`], warning about invalid empty tables.
287fn deserialize_hashes<'de, D>(deserializer: D) -> Result<Hashes, D::Error>
288where
289    D: serde::Deserializer<'de>,
290{
291    // Check the original table, since `Hashes` discards unsupported algorithms.
292    let hashes = TomlTable::deserialize(deserializer)?;
293    if hashes.is_empty() {
294        warn_user_once!(
295            "Empty hash tables in `pylock.toml` will be rejected in a future uv version. Rerun the original `uv export` or `uv pip compile` command to regenerate the file."
296        );
297    }
298    hashes.try_into().map_err(serde::de::Error::custom)
299}
300
301/// The location of a distribution file with missing hashes.
302enum HashSource {
303    Url(DisplaySafeUrl),
304    Path(PathBuf),
305}
306
307impl HashSource {
308    fn new(
309        name: &PackageName,
310        field: &'static str,
311        url: Option<&DisplaySafeUrl>,
312        path: Option<&PortablePathBuf>,
313        install_path: &Path,
314    ) -> Result<Self, PylockTomlErrorKind> {
315        if let Some(url) = url {
316            if url.scheme() == "file" {
317                Ok(Self::Path(
318                    url.to_file_path()
319                        .map_err(|()| PylockTomlErrorKind::UrlToPath)?,
320                ))
321            } else {
322                Ok(Self::Url(url.clone()))
323            }
324        } else if let Some(path) = path {
325            Ok(Self::Path(install_path.join(path)))
326        } else {
327            Err(PylockTomlErrorKind::MissingHashes(name.clone(), field))
328        }
329    }
330
331    /// Download or read the file and compute its hashes.
332    async fn hash(self, client: &RegistryClient) -> Result<Hashes, PylockTomlErrorKind> {
333        let mut hashers = [Hasher::from(HashAlgorithm::Sha256)];
334        match self {
335            Self::Url(url) => {
336                let response = client
337                    .uncached_client(&url)
338                    .get(Url::from(url.clone()))
339                    .header(
340                        // `reqwest` defaults to accepting compressed responses.
341                        // Specify identity encoding to get consistent .whl downloading
342                        // behavior from servers. ref: https://github.com/pypa/pip/pull/1688
343                        "accept-encoding",
344                        reqwest::header::HeaderValue::from_static("identity"),
345                    )
346                    .send()
347                    .await
348                    .and_then(|response| response.error_for_status().map_err(Into::into))
349                    .map_err(|err| {
350                        PylockTomlErrorKind::DownloadFile(
351                            Box::new(url.clone()),
352                            WrappedReqwestError::from(err),
353                        )
354                    })?;
355                let reader = response
356                    .bytes_stream()
357                    .map_err(std::io::Error::other)
358                    .into_async_read();
359                HashReader::new(reader.compat(), &mut hashers)
360                    .finish()
361                    .await
362                    .map_err(|err| PylockTomlErrorKind::StreamFile(Box::new(url), err))?;
363            }
364            Self::Path(path) => {
365                let file = fs_err::tokio::File::open(&path).await.map_err(|err| {
366                    PylockTomlErrorKind::ReadFile(path.clone().into_boxed_path(), err)
367                })?;
368                HashReader::new(file, &mut hashers)
369                    .finish()
370                    .await
371                    .map_err(|err| PylockTomlErrorKind::ReadFile(path.into_boxed_path(), err))?;
372            }
373        }
374        let [hasher] = hashers;
375        Ok(Hashes {
376            sha256: Some(HashDigest::from(hasher).digest),
377            ..Hashes::default()
378        })
379    }
380}
381
382#[derive(Debug, serde::Serialize, serde::Deserialize)]
383#[serde(rename_all = "kebab-case")]
384pub struct PylockTomlPackage {
385    pub name: PackageName,
386    #[serde(skip_serializing_if = "Option::is_none")]
387    pub version: Option<Version>,
388    #[serde(skip_serializing_if = "Option::is_none")]
389    pub index: Option<DisplaySafeUrl>,
390    #[serde(
391        skip_serializing_if = "uv_pep508::marker::ser::is_empty",
392        serialize_with = "uv_pep508::marker::ser::serialize",
393        default
394    )]
395    marker: MarkerTree,
396    #[serde(skip_serializing_if = "Option::is_none")]
397    requires_python: Option<RequiresPython>,
398    #[serde(skip_serializing_if = "Vec::is_empty", default)]
399    dependencies: Vec<PylockTomlDependency>,
400    #[serde(skip_serializing_if = "Option::is_none")]
401    vcs: Option<PylockTomlVcs>,
402    #[serde(skip_serializing_if = "Option::is_none")]
403    directory: Option<PylockTomlDirectory>,
404    #[serde(skip_serializing_if = "Option::is_none")]
405    archive: Option<PylockTomlArchive>,
406    #[serde(skip_serializing_if = "Option::is_none")]
407    sdist: Option<PylockTomlSdist>,
408    #[serde(skip_serializing_if = "Option::is_none")]
409    wheels: Option<Vec<PylockTomlWheel>>,
410}
411
412#[derive(Debug, serde::Serialize, serde::Deserialize)]
413#[serde(rename_all = "kebab-case")]
414#[expect(clippy::empty_structs_with_brackets)]
415struct PylockTomlDependency {}
416
417#[derive(Debug, serde::Serialize, serde::Deserialize)]
418#[serde(rename_all = "kebab-case")]
419struct PylockTomlDirectory {
420    path: PortablePathBuf,
421    #[serde(skip_serializing_if = "Option::is_none")]
422    editable: Option<bool>,
423    #[serde(skip_serializing_if = "Option::is_none")]
424    subdirectory: Option<PortablePathBuf>,
425}
426
427#[derive(Debug, serde::Serialize, serde::Deserialize)]
428#[serde(rename_all = "kebab-case")]
429struct PylockTomlVcs {
430    r#type: VcsKind,
431    #[serde(skip_serializing_if = "Option::is_none")]
432    url: Option<DisplaySafeUrl>,
433    #[serde(skip_serializing_if = "Option::is_none")]
434    path: Option<PortablePathBuf>,
435    #[serde(skip_serializing_if = "Option::is_none")]
436    requested_revision: Option<String>,
437    commit_id: GitOid,
438    #[serde(skip_serializing_if = "Option::is_none")]
439    subdirectory: Option<PortablePathBuf>,
440}
441
442#[derive(Debug, serde::Serialize, serde::Deserialize)]
443#[serde(rename_all = "kebab-case")]
444struct PylockTomlArchive {
445    #[serde(skip_serializing_if = "Option::is_none")]
446    url: Option<DisplaySafeUrl>,
447    #[serde(skip_serializing_if = "Option::is_none")]
448    path: Option<PortablePathBuf>,
449    #[serde(skip_serializing_if = "Option::is_none")]
450    size: Option<u64>,
451    #[serde(
452        skip_serializing_if = "Option::is_none",
453        serialize_with = "timestamp_to_toml_datetime",
454        deserialize_with = "timestamp_from_toml_datetime",
455        default
456    )]
457    upload_time: Option<Timestamp>,
458    #[serde(skip_serializing_if = "Option::is_none")]
459    subdirectory: Option<PortablePathBuf>,
460    #[serde(deserialize_with = "deserialize_hashes")]
461    hashes: Hashes,
462}
463
464#[derive(Debug, serde::Serialize, serde::Deserialize)]
465#[serde(rename_all = "kebab-case")]
466struct PylockTomlSdist {
467    #[serde(skip_serializing_if = "Option::is_none")]
468    name: Option<SmallString>,
469    #[serde(skip_serializing_if = "Option::is_none")]
470    url: Option<DisplaySafeUrl>,
471    #[serde(skip_serializing_if = "Option::is_none")]
472    path: Option<PortablePathBuf>,
473    #[serde(
474        skip_serializing_if = "Option::is_none",
475        serialize_with = "timestamp_to_toml_datetime",
476        deserialize_with = "timestamp_from_toml_datetime",
477        default
478    )]
479    upload_time: Option<Timestamp>,
480    #[serde(skip_serializing_if = "Option::is_none")]
481    size: Option<u64>,
482    #[serde(deserialize_with = "deserialize_hashes")]
483    hashes: Hashes,
484}
485
486#[derive(Debug, serde::Serialize, serde::Deserialize)]
487#[serde(rename_all = "kebab-case")]
488struct PylockTomlWheel {
489    #[serde(skip_serializing_if = "Option::is_none")]
490    name: Option<WheelFilename>,
491    #[serde(skip_serializing_if = "Option::is_none")]
492    url: Option<DisplaySafeUrl>,
493    #[serde(skip_serializing_if = "Option::is_none")]
494    path: Option<PortablePathBuf>,
495    #[serde(
496        skip_serializing_if = "Option::is_none",
497        serialize_with = "timestamp_to_toml_datetime",
498        deserialize_with = "timestamp_from_toml_datetime",
499        default
500    )]
501    upload_time: Option<Timestamp>,
502    #[serde(skip_serializing_if = "Option::is_none")]
503    size: Option<u64>,
504    #[serde(deserialize_with = "deserialize_hashes")]
505    hashes: Hashes,
506}
507
508#[derive(Debug, serde::Serialize, serde::Deserialize)]
509#[serde(rename_all = "kebab-case")]
510struct PylockTomlAttestationIdentity {
511    kind: String,
512}
513
514impl<'lock> PylockToml {
515    /// Construct a [`PylockToml`] from a [`ResolverOutput`].
516    ///
517    /// If `tags` is provided, only wheels compatible with the given tags will be included.
518    /// If `build_options` is provided, packages marked as `--only-binary` will not include
519    /// source distributions.
520    pub fn from_resolution(
521        resolution: &ResolverOutput,
522        omit: &[PackageName],
523        install_path: &Path,
524        tags: Option<&Tags>,
525        build_options: &BuildOptions,
526    ) -> Result<Self, PylockTomlErrorKind> {
527        // The lock version is always `1.0` at time of writing.
528        let lock_version = Version::new([1, 0]);
529
530        // The created by field is always `uv` at time of writing.
531        let created_by = "uv".to_string();
532
533        // Use the `requires-python` from the target lockfile.
534        let requires_python = resolution.requires_python.clone();
535
536        // We don't support locking for multiple extras at time of writing.
537        let extras = vec![];
538
539        // We don't support locking for multiple dependency groups at time of writing.
540        let dependency_groups = vec![];
541
542        // We don't support locking for multiple dependency groups at time of writing.
543        let default_groups = vec![];
544
545        // We don't support attestation identities at time of writing.
546        let attestation_identities = vec![];
547
548        // Convert each node to a `pylock.toml`-style package.
549        let mut packages = Vec::with_capacity(resolution.graph.node_count());
550        for (node_index, node) in resolution.base_dists() {
551            let ResolvedDist::Installable { dist, version } = &node.dist else {
552                continue;
553            };
554            if omit.contains(dist.name()) {
555                continue;
556            }
557
558            // "The version MUST NOT be included when it cannot be guaranteed to be consistent with the code used (i.e. when a source tree is used)."
559            let version = version
560                .as_ref()
561                .filter(|_| !matches!(&**dist, Dist::Source(SourceDist::Directory(..))));
562
563            // Create a `pylock.toml`-style package.
564            let mut package = PylockTomlPackage {
565                name: dist.name().clone(),
566                version: version.cloned(),
567                marker: node.marker.pep508(),
568                requires_python: None,
569                dependencies: vec![],
570                index: None,
571                vcs: None,
572                directory: None,
573                archive: None,
574                sdist: None,
575                wheels: None,
576            };
577
578            match &**dist {
579                Dist::Built(BuiltDist::DirectUrl(dist)) => {
580                    package.archive = Some(PylockTomlArchive {
581                        url: Some((*dist.location).clone()),
582                        path: None,
583                        size: dist.size(),
584                        upload_time: None,
585                        subdirectory: None,
586                        hashes: Hashes::from(node.hashes.clone()),
587                    });
588                }
589                Dist::Built(BuiltDist::Path(dist)) => {
590                    let path = try_relative_to_if(
591                        &dist.install_path,
592                        install_path,
593                        !dist.url.was_given_absolute(),
594                    )
595                    .map(Box::<Path>::from)
596                    .unwrap_or_else(|_| dist.install_path.clone());
597                    package.archive = Some(PylockTomlArchive {
598                        url: None,
599                        path: Some(PortablePathBuf::from(path)),
600                        size: dist.size(),
601                        upload_time: None,
602                        subdirectory: None,
603                        hashes: Hashes::from(node.hashes.clone()),
604                    });
605                }
606                Dist::Built(BuiltDist::GitPath(_)) => {
607                    return Err(PylockTomlErrorKind::GitArchiveUnsupported(package.name));
608                }
609                Dist::Built(BuiltDist::Registry(dist)) => {
610                    package.wheels = Self::filter_and_convert_wheels(
611                        resolution,
612                        tags,
613                        &requires_python,
614                        node_index,
615                        &dist.wheels,
616                        build_options.no_binary_package(dist.name()),
617                    )?;
618
619                    // Filter sdist based on build options (--only-binary).
620                    let no_build = build_options.no_build_package(dist.name());
621
622                    if !no_build {
623                        if let Some(sdist) = dist.sdist.as_ref() {
624                            let url = sdist
625                                .file
626                                .url
627                                .to_url()
628                                .map_err(PylockTomlErrorKind::ToUrl)?;
629                            package.sdist = Some(PylockTomlSdist {
630                                // Optional "when the last component of path/ url would be the same value".
631                                name: if url
632                                    .filename()
633                                    .is_ok_and(|filename| filename == *sdist.file.filename)
634                                {
635                                    None
636                                } else {
637                                    Some(sdist.file.filename.clone())
638                                },
639                                upload_time: sdist
640                                    .file
641                                    .upload_time_utc_ms
642                                    .map(Timestamp::from_millisecond)
643                                    .transpose()?,
644                                url: Some(url),
645                                path: None,
646                                size: sdist.file.size,
647                                hashes: Hashes::from(sdist.file.hashes.clone()),
648                            });
649                        }
650                    }
651                }
652                Dist::Source(SourceDist::DirectUrl(dist)) => {
653                    package.archive = Some(PylockTomlArchive {
654                        url: Some((*dist.location).clone()),
655                        path: None,
656                        size: dist.size(),
657                        upload_time: None,
658                        subdirectory: dist.subdirectory.clone().map(PortablePathBuf::from),
659                        hashes: Hashes::from(node.hashes.clone()),
660                    });
661                }
662                Dist::Source(SourceDist::Directory(dist)) => {
663                    let path = try_relative_to_if(
664                        &dist.install_path,
665                        install_path,
666                        !dist.url.was_given_absolute(),
667                    )
668                    .map(Box::<Path>::from)
669                    .unwrap_or_else(|_| dist.install_path.clone());
670                    package.directory = Some(PylockTomlDirectory {
671                        path: PortablePathBuf::from(path),
672                        editable: dist.editable,
673                        subdirectory: None,
674                    });
675                }
676                Dist::Source(SourceDist::GitDirectory(dist)) => {
677                    package.vcs = Some(PylockTomlVcs {
678                        r#type: VcsKind::Git,
679                        url: Some(dist.git.url().clone()),
680                        path: None,
681                        requested_revision: dist.git.reference().as_str().map(ToString::to_string),
682                        commit_id: dist.git.precise().unwrap_or_else(|| {
683                            panic!("Git distribution is missing a precise hash: {dist}")
684                        }),
685                        subdirectory: dist.subdirectory.clone().map(PortablePathBuf::from),
686                    });
687                }
688                Dist::Source(SourceDist::GitPath(_)) => {
689                    return Err(PylockTomlErrorKind::GitArchiveUnsupported(package.name));
690                }
691                Dist::Source(SourceDist::Path(dist)) => {
692                    let path = try_relative_to_if(
693                        &dist.install_path,
694                        install_path,
695                        !dist.url.was_given_absolute(),
696                    )
697                    .map(Box::<Path>::from)
698                    .unwrap_or_else(|_| dist.install_path.clone());
699                    package.archive = Some(PylockTomlArchive {
700                        url: None,
701                        path: Some(PortablePathBuf::from(path)),
702                        size: dist.size(),
703                        upload_time: None,
704                        subdirectory: None,
705                        hashes: Hashes::from(node.hashes.clone()),
706                    });
707                }
708                Dist::Source(SourceDist::Registry(dist)) => {
709                    package.wheels = Self::filter_and_convert_wheels(
710                        resolution,
711                        tags,
712                        &requires_python,
713                        node_index,
714                        &dist.wheels,
715                        build_options.no_binary_package(&dist.name),
716                    )?;
717
718                    // Filter sdist based on build options (--only-binary).
719                    let no_build = build_options.no_build_package(&dist.name);
720
721                    if !no_build {
722                        let url = dist.file.url.to_url().map_err(PylockTomlErrorKind::ToUrl)?;
723                        package.sdist = Some(PylockTomlSdist {
724                            // Optional "when the last component of path/ url would be the same value".
725                            name: if url
726                                .filename()
727                                .is_ok_and(|filename| filename == *dist.file.filename)
728                            {
729                                None
730                            } else {
731                                Some(dist.file.filename.clone())
732                            },
733                            upload_time: dist
734                                .file
735                                .upload_time_utc_ms
736                                .map(Timestamp::from_millisecond)
737                                .transpose()?,
738                            url: Some(url),
739                            path: None,
740                            size: dist.file.size,
741                            hashes: Hashes::from(dist.file.hashes.clone()),
742                        });
743                    }
744                }
745            }
746
747            // Add the package to the list of packages.
748            packages.push(package);
749        }
750
751        // Sort the packages by name, then version.
752        packages.sort_by(|a, b| a.name.cmp(&b.name).then(a.version.cmp(&b.version)));
753
754        // Return the constructed `pylock.toml`.
755        Ok(Self {
756            lock_version,
757            created_by,
758            requires_python: Some(requires_python),
759            extras,
760            dependency_groups,
761            default_groups,
762            packages,
763            attestation_identities,
764        })
765    }
766
767    /// Filter wheels based on build options (--no-binary) and incompatible tags and return the
768    /// rest.
769    ///
770    /// Returns `Ok(None)` if no wheels are compatible.
771    fn filter_and_convert_wheels(
772        resolution: &ResolverOutput,
773        tags: Option<&Tags>,
774        requires_python: &RequiresPython,
775        node_index: NodeIndex,
776        wheels: &[RegistryBuiltWheel],
777        no_binary: bool,
778    ) -> Result<Option<Vec<PylockTomlWheel>>, PylockTomlErrorKind> {
779        if no_binary {
780            return Ok(None);
781        }
782
783        // Filter wheels based on tag compatibility and requires-python.
784        let wheels: Vec<_> = wheels
785            .iter()
786            .filter(|wheel| {
787                !is_wheel_unreachable(
788                    &wheel.filename,
789                    resolution,
790                    requires_python,
791                    node_index,
792                    tags,
793                )
794            })
795            .collect();
796
797        if wheels.is_empty() {
798            return Ok(None);
799        }
800
801        let wheels = wheels
802            .into_iter()
803            .map(|wheel| {
804                let url = wheel
805                    .file
806                    .url
807                    .to_url()
808                    .map_err(PylockTomlErrorKind::ToUrl)?;
809                Ok(PylockTomlWheel {
810                    // Optional "when the last component of path/ url would be the same value".
811                    name: if url
812                        .filename()
813                        .is_ok_and(|filename| filename == *wheel.file.filename)
814                    {
815                        None
816                    } else {
817                        Some(wheel.filename.clone())
818                    },
819                    upload_time: wheel
820                        .file
821                        .upload_time_utc_ms
822                        .map(Timestamp::from_millisecond)
823                        .transpose()?,
824                    url: Some(
825                        wheel
826                            .file
827                            .url
828                            .to_url()
829                            .map_err(PylockTomlErrorKind::ToUrl)?,
830                    ),
831                    path: None,
832                    size: wheel.file.size,
833                    hashes: Hashes::from(wheel.file.hashes.clone()),
834                })
835            })
836            .collect::<Result<Vec<_>, PylockTomlErrorKind>>()?;
837        Ok(Some(wheels))
838    }
839
840    /// Construct a [`PylockToml`] from a uv lockfile.
841    pub fn from_lock(
842        target: &impl Installable<'lock>,
843        prune: &[PackageName],
844        extras: &ExtrasSpecificationWithDefaults,
845        dev: &DependencyGroupsWithDefaults,
846        annotate: bool,
847        editable: Option<&EditableMode>,
848        install_options: &'lock InstallOptions,
849    ) -> Result<Self, PylockTomlErrorKind> {
850        // Extract the packages from the lock file.
851        let ExportableRequirements(mut nodes) = ExportableRequirements::from_lock(
852            target,
853            prune,
854            extras,
855            dev,
856            annotate,
857            install_options,
858        )?;
859
860        // Sort the nodes.
861        nodes.sort_unstable_by_key(|node| &node.package.id);
862
863        // The lock version is always `1.0` at time of writing.
864        let lock_version = Version::new([1, 0]);
865
866        // The created by field is always `uv` at time of writing.
867        let created_by = "uv".to_string();
868
869        // Use the `requires-python` from the target lockfile.
870        let requires_python = target.lock().requires_python.clone();
871
872        // We don't support locking for multiple extras at time of writing.
873        let extras = vec![];
874
875        // We don't support locking for multiple dependency groups at time of writing.
876        let dependency_groups = vec![];
877
878        // We don't support locking for multiple dependency groups at time of writing.
879        let default_groups = vec![];
880
881        // We don't support attestation identities at time of writing.
882        let attestation_identities = vec![];
883
884        // Convert each node to a `pylock.toml`-style package.
885        let mut packages = Vec::with_capacity(nodes.len());
886        for node in nodes {
887            let package = node.package;
888
889            // Extract the `packages.wheels` field.
890            //
891            // This field only includes wheels from a registry. Wheels included via direct URL or
892            // direct path instead map to the `packages.archive` field.
893            let wheels = match &package.id.source {
894                Source::Registry(source) => {
895                    let wheels = package
896                        .wheels
897                        .iter()
898                        .map(|wheel| wheel.to_registry_wheel(source, target.install_path()))
899                        .collect::<Result<Vec<RegistryBuiltWheel>, LockError>>()?;
900                    Some(
901                        wheels
902                            .into_iter()
903                            .map(|wheel| {
904                                let url = wheel
905                                    .file
906                                    .url
907                                    .to_url()
908                                    .map_err(PylockTomlErrorKind::ToUrl)?;
909                                Ok(PylockTomlWheel {
910                                    // Optional "when the last component of path/ url would be the same value".
911                                    name: if url
912                                        .filename()
913                                        .is_ok_and(|filename| filename == *wheel.file.filename)
914                                    {
915                                        None
916                                    } else {
917                                        Some(wheel.filename.clone())
918                                    },
919                                    upload_time: wheel
920                                        .file
921                                        .upload_time_utc_ms
922                                        .map(Timestamp::from_millisecond)
923                                        .transpose()?,
924                                    url: Some(url),
925                                    path: None,
926                                    size: wheel.file.size,
927                                    hashes: Hashes::from(wheel.file.hashes),
928                                })
929                            })
930                            .collect::<Result<Vec<_>, PylockTomlErrorKind>>()?,
931                    )
932                }
933                Source::Path(..) => None,
934                Source::Git(..) => None,
935                Source::Direct(..) => None,
936                Source::Directory(..) => None,
937                Source::Editable(..) => None,
938                Source::Virtual(..) => {
939                    // Omit virtual packages entirely; they shouldn't be installed.
940                    continue;
941                }
942            };
943
944            // Extract the source distribution from the lockfile entry.
945            let sdist = package.to_source_dist(target.install_path(), FirstParty::No)?;
946
947            // Extract some common fields from the source distribution.
948            let size = package
949                .sdist
950                .as_ref()
951                .and_then(super::super::SourceDist::size);
952            let hash = package.sdist.as_ref().and_then(|sdist| sdist.hash());
953
954            // Extract the `packages.directory` field.
955            let directory = match &sdist {
956                Some(SourceDist::Directory(sdist)) => Some(PylockTomlDirectory {
957                    path: PortablePathBuf::from(
958                        sdist
959                            .url
960                            .given()
961                            .map(PathBuf::from)
962                            .unwrap_or_else(|| sdist.install_path.to_path_buf())
963                            .into_boxed_path(),
964                    ),
965                    editable: match editable
966                        .and_then(|editable| editable.for_package(&package.id.name))
967                    {
968                        None => sdist.editable,
969                        Some(false) => None,
970                        Some(true) => Some(true),
971                    },
972                    subdirectory: None,
973                }),
974                _ => None,
975            };
976
977            // Extract the `packages.vcs` field.
978            let vcs = match &sdist {
979                Some(SourceDist::GitDirectory(sdist)) => Some(PylockTomlVcs {
980                    r#type: VcsKind::Git,
981                    url: Some(sdist.git.url().clone()),
982                    path: None,
983                    requested_revision: sdist.git.reference().as_str().map(ToString::to_string),
984                    commit_id: sdist.git.precise().unwrap_or_else(|| {
985                        panic!("Git distribution is missing a precise hash: {sdist}")
986                    }),
987                    subdirectory: sdist.subdirectory.clone().map(PortablePathBuf::from),
988                }),
989                _ => None,
990            };
991
992            // Extract the `packages.archive` field, which can either be a direct URL or a local
993            // path, pointing to either a source distribution or a wheel.
994            let archive = match &sdist {
995                Some(SourceDist::DirectUrl(sdist)) => Some(PylockTomlArchive {
996                    url: Some(sdist.url.to_url()),
997                    path: None,
998                    size,
999                    upload_time: None,
1000                    subdirectory: sdist.subdirectory.clone().map(PortablePathBuf::from),
1001                    hashes: hash.cloned().map(Hashes::from).unwrap_or_default(),
1002                }),
1003                Some(SourceDist::Path(sdist)) => Some(PylockTomlArchive {
1004                    url: None,
1005                    path: Some(PortablePathBuf::from(
1006                        sdist
1007                            .url
1008                            .given()
1009                            .map(PathBuf::from)
1010                            .unwrap_or_else(|| sdist.install_path.to_path_buf())
1011                            .into_boxed_path(),
1012                    )),
1013                    size,
1014                    upload_time: None,
1015                    subdirectory: None,
1016                    hashes: hash.cloned().map(Hashes::from).unwrap_or_default(),
1017                }),
1018                _ => match &package.id.source {
1019                    Source::Registry(..) => None,
1020                    Source::Path(source) => package.wheels.first().map(|wheel| PylockTomlArchive {
1021                        url: None,
1022                        path: Some(PortablePathBuf::from(source.clone())),
1023                        size: wheel.size,
1024                        upload_time: None,
1025                        subdirectory: None,
1026                        hashes: wheel.hash.clone().map(Hashes::from).unwrap_or_default(),
1027                    }),
1028                    Source::Git(..) => None,
1029                    Source::Direct(source, ..) => {
1030                        if let Some(wheel) = package.wheels.first() {
1031                            Some(PylockTomlArchive {
1032                                url: Some(source.to_url()?),
1033                                path: None,
1034                                size: wheel.size,
1035                                upload_time: None,
1036                                subdirectory: None,
1037                                hashes: wheel.hash.clone().map(Hashes::from).unwrap_or_default(),
1038                            })
1039                        } else {
1040                            None
1041                        }
1042                    }
1043                    Source::Directory(..) => None,
1044                    Source::Editable(..) => None,
1045                    Source::Virtual(..) => None,
1046                },
1047            };
1048
1049            // Extract the `packages.sdist` field.
1050            let sdist = match &sdist {
1051                Some(SourceDist::Registry(sdist)) => {
1052                    let url = sdist
1053                        .file
1054                        .url
1055                        .to_url()
1056                        .map_err(PylockTomlErrorKind::ToUrl)?;
1057                    Some(PylockTomlSdist {
1058                        // Optional "when the last component of path/ url would be the same value".
1059                        name: if url
1060                            .filename()
1061                            .is_ok_and(|filename| filename == *sdist.file.filename)
1062                        {
1063                            None
1064                        } else {
1065                            Some(sdist.file.filename.clone())
1066                        },
1067                        upload_time: sdist
1068                            .file
1069                            .upload_time_utc_ms
1070                            .map(Timestamp::from_millisecond)
1071                            .transpose()?,
1072                        url: Some(url),
1073                        path: None,
1074                        size,
1075                        hashes: hash.cloned().map(Hashes::from).unwrap_or_default(),
1076                    })
1077                }
1078                _ => None,
1079            };
1080
1081            // Extract the `packages.index` field.
1082            let index = package
1083                .index(target.install_path())?
1084                .map(IndexUrl::into_url);
1085
1086            // Extract the `packages.name` field.
1087            let name = package.id.name.clone();
1088
1089            // Extract the `packages.version` field.
1090            // "The version MUST NOT be included when it cannot be guaranteed to be consistent with the code used (i.e. when a source tree is used)."
1091            let version = package
1092                .id
1093                .version
1094                .as_ref()
1095                .filter(|_| directory.is_none())
1096                .cloned();
1097
1098            let package = PylockTomlPackage {
1099                name,
1100                version,
1101                marker: node.marker,
1102                requires_python: None,
1103                dependencies: vec![],
1104                index,
1105                vcs,
1106                directory,
1107                archive,
1108                sdist,
1109                wheels,
1110            };
1111
1112            packages.push(package);
1113        }
1114
1115        Ok(Self {
1116            lock_version,
1117            created_by,
1118            requires_python: Some(requires_python),
1119            extras,
1120            dependency_groups,
1121            default_groups,
1122            packages,
1123            attestation_identities,
1124        })
1125    }
1126
1127    /// Returns `true` if any distribution file is missing the hashes required by PEP 751.
1128    pub fn has_missing_hashes(&self) -> bool {
1129        self.packages.iter().any(|package| {
1130            package
1131                .archive
1132                .as_ref()
1133                .is_some_and(|archive| archive.hashes.is_empty())
1134                || package
1135                    .sdist
1136                    .as_ref()
1137                    .is_some_and(|sdist| sdist.hashes.is_empty())
1138                || package
1139                    .wheels
1140                    .iter()
1141                    .flatten()
1142                    .any(|wheel| wheel.hashes.is_empty())
1143        })
1144    }
1145
1146    /// Download and hash all distribution files that are missing hashes, e.g., because the
1147    /// registry didn't provide them, since `packages.*.hashes` is a required key in PEP 751.
1148    ///
1149    /// Local files are read from disk, with relative paths resolved against `install_path`.
1150    pub async fn generate_missing_hashes(
1151        &mut self,
1152        client: &RegistryClient,
1153        concurrency: usize,
1154        install_path: &Path,
1155    ) -> Result<(), PylockTomlErrorKind> {
1156        // TODO(tk): Maybe make hash completion part of the export API so callers cannot accidentally skip it.
1157
1158        // Collect the files that are missing hashes.
1159        let mut jobs = Vec::new();
1160        for package in &mut self.packages {
1161            if let Some(archive) = &mut package.archive
1162                && archive.hashes.is_empty()
1163            {
1164                let source = HashSource::new(
1165                    &package.name,
1166                    "packages.archive",
1167                    archive.url.as_ref(),
1168                    archive.path.as_ref(),
1169                    install_path,
1170                )?;
1171                jobs.push((&mut archive.hashes, source));
1172            }
1173            if let Some(sdist) = &mut package.sdist
1174                && sdist.hashes.is_empty()
1175            {
1176                let source = HashSource::new(
1177                    &package.name,
1178                    "packages.sdist",
1179                    sdist.url.as_ref(),
1180                    sdist.path.as_ref(),
1181                    install_path,
1182                )?;
1183                jobs.push((&mut sdist.hashes, source));
1184            }
1185            for wheel in package.wheels.iter_mut().flatten() {
1186                if wheel.hashes.is_empty() {
1187                    let source = HashSource::new(
1188                        &package.name,
1189                        "packages.wheels",
1190                        wheel.url.as_ref(),
1191                        wheel.path.as_ref(),
1192                        install_path,
1193                    )?;
1194                    jobs.push((&mut wheel.hashes, source));
1195                }
1196            }
1197        }
1198
1199        // Fetch and hash the files.
1200        let hashed = futures::stream::iter(jobs)
1201            .map(|(destination, source)| async move {
1202                let hashes = source.hash(client).await?;
1203                Ok::<_, PylockTomlErrorKind>((destination, hashes))
1204            })
1205            .buffer_unordered(concurrency)
1206            .try_collect::<Vec<_>>()
1207            .await?;
1208
1209        for (destination, hashes) in hashed {
1210            *destination = hashes;
1211        }
1212
1213        Ok(())
1214    }
1215
1216    /// Returns the TOML representation of this lockfile.
1217    pub fn to_toml(&self) -> Result<String, toml_edit::ser::Error> {
1218        // We construct a TOML document manually instead of going through Serde to enable
1219        // the use of inline tables.
1220        let mut doc = toml_edit::DocumentMut::new();
1221
1222        doc.insert("lock-version", value(self.lock_version.to_string()));
1223        doc.insert("created-by", value(self.created_by.as_str()));
1224        if let Some(ref requires_python) = self.requires_python {
1225            doc.insert("requires-python", value(requires_python.to_string()));
1226        }
1227        if !self.extras.is_empty() {
1228            doc.insert(
1229                "extras",
1230                value(each_element_on_its_line_array(
1231                    self.extras.iter().map(ToString::to_string),
1232                )),
1233            );
1234        }
1235        if !self.dependency_groups.is_empty() {
1236            doc.insert(
1237                "dependency-groups",
1238                value(each_element_on_its_line_array(
1239                    self.dependency_groups.iter().map(ToString::to_string),
1240                )),
1241            );
1242        }
1243        if !self.default_groups.is_empty() {
1244            doc.insert(
1245                "default-groups",
1246                value(each_element_on_its_line_array(
1247                    self.default_groups.iter().map(ToString::to_string),
1248                )),
1249            );
1250        }
1251        if !self.attestation_identities.is_empty() {
1252            let attestation_identities = self
1253                .attestation_identities
1254                .iter()
1255                .map(|attestation_identity| {
1256                    serde::Serialize::serialize(
1257                        &attestation_identity,
1258                        toml_edit::ser::ValueSerializer::new(),
1259                    )
1260                })
1261                .collect::<Result<Vec<_>, _>>()?;
1262            let attestation_identities = match attestation_identities.as_slice() {
1263                [] => Array::new(),
1264                [attestation_identity] => Array::from_iter([attestation_identity]),
1265                attestation_identities => {
1266                    each_element_on_its_line_array(attestation_identities.iter())
1267                }
1268            };
1269            doc.insert("attestation-identities", value(attestation_identities));
1270        }
1271        if self.packages.is_empty() {
1272            // `packages` is a required key in PEP 751, even when empty.
1273            doc.insert("packages", value(Array::new()));
1274        } else {
1275            let mut packages = ArrayOfTables::new();
1276            for dist in &self.packages {
1277                packages.push(dist.to_toml()?);
1278            }
1279            doc.insert("packages", Item::ArrayOfTables(packages));
1280        }
1281
1282        Ok(doc.to_string())
1283    }
1284
1285    /// Convert the [`PylockToml`] to a [`Resolution`].
1286    pub fn to_resolution(
1287        self,
1288        install_path: &Path,
1289        markers: &MarkerEnvironment,
1290        extras: &[ExtraName],
1291        groups: &[GroupName],
1292        tags: &Tags,
1293        build_options: &BuildOptions,
1294    ) -> Result<Resolution, PylockTomlError> {
1295        // Convert the extras and dependency groups specifications to a concrete environment.
1296        let mut graph =
1297            petgraph::graph::DiGraph::with_capacity(self.packages.len(), self.packages.len());
1298
1299        // Add the root node.
1300        let root = graph.add_node(Node::Root);
1301        let mut active_packages = HashSet::new();
1302
1303        for package in self.packages {
1304            // Omit packages that aren't relevant to the current environment.
1305            if !package.marker.evaluate_pep751(markers, extras, groups) {
1306                continue;
1307            }
1308            if !active_packages.insert(package.name.clone()) {
1309                return Err(
1310                    PylockTomlErrorKind::DuplicateActivePackage(package.name.clone()).into(),
1311                );
1312            }
1313
1314            if let Some(requires_python) = package.requires_python.as_ref()
1315                && !requires_python.contains(&markers.python_full_version().version)
1316            {
1317                return Err(PylockTomlErrorKind::IncompatibleRequiresPython(
1318                    package.name.clone(),
1319                    markers.python_full_version().version.clone(),
1320                    requires_python.clone(),
1321                )
1322                .into());
1323            }
1324
1325            match (
1326                package.wheels.is_some(),
1327                package.sdist.is_some(),
1328                package.directory.is_some(),
1329                package.vcs.is_some(),
1330                package.archive.is_some(),
1331            ) {
1332                // `packages.wheels` is mutually exclusive with `packages.directory`, `packages.vcs`, and `packages.archive`.
1333                (true, _, true, _, _) => {
1334                    return Err(
1335                        PylockTomlErrorKind::WheelWithDirectory(package.name.clone()).into(),
1336                    );
1337                }
1338                (true, _, _, true, _) => {
1339                    return Err(PylockTomlErrorKind::WheelWithVcs(package.name.clone()).into());
1340                }
1341                (true, _, _, _, true) => {
1342                    return Err(PylockTomlErrorKind::WheelWithArchive(package.name.clone()).into());
1343                }
1344                // `packages.sdist` is mutually exclusive with `packages.directory`, `packages.vcs`, and `packages.archive`.
1345                (_, true, true, _, _) => {
1346                    return Err(
1347                        PylockTomlErrorKind::SdistWithDirectory(package.name.clone()).into(),
1348                    );
1349                }
1350                (_, true, _, true, _) => {
1351                    return Err(PylockTomlErrorKind::SdistWithVcs(package.name.clone()).into());
1352                }
1353                (_, true, _, _, true) => {
1354                    return Err(PylockTomlErrorKind::SdistWithArchive(package.name.clone()).into());
1355                }
1356                // `packages.directory` is mutually exclusive with `packages.vcs`, and `packages.archive`.
1357                (_, _, true, true, _) => {
1358                    return Err(PylockTomlErrorKind::DirectoryWithVcs(package.name.clone()).into());
1359                }
1360                (_, _, true, _, true) => {
1361                    return Err(
1362                        PylockTomlErrorKind::DirectoryWithArchive(package.name.clone()).into(),
1363                    );
1364                }
1365                // `packages.vcs` is mutually exclusive with `packages.archive`.
1366                (_, _, _, true, true) => {
1367                    return Err(PylockTomlErrorKind::VcsWithArchive(package.name.clone()).into());
1368                }
1369                (false, false, false, false, false) => {
1370                    return Err(PylockTomlErrorKind::MissingSource(package.name.clone()).into());
1371                }
1372                _ => {}
1373            }
1374
1375            let no_binary = build_options.no_binary_package(&package.name);
1376            let no_build = build_options.no_build_package(&package.name);
1377            let is_wheel = package
1378                .archive
1379                .as_ref()
1380                .map(|archive| archive.is_wheel(&package.name))
1381                .transpose()?
1382                .unwrap_or_default();
1383
1384            // Search for a matching wheel.
1385            let dist = if let Some(best_wheel) =
1386                package.find_best_wheel(tags).filter(|_| !no_binary)
1387            {
1388                let hashes = HashDigests::from(best_wheel.hashes.clone());
1389                let built_dist = Dist::Built(BuiltDist::Registry(RegistryBuiltDist {
1390                    wheels: vec![best_wheel.to_registry_wheel(
1391                        install_path,
1392                        &package.name,
1393                        package.index.as_ref(),
1394                    )?],
1395                    best_wheel_index: 0,
1396                    sdist: None,
1397                }));
1398                let dist = ResolvedDist::Installable {
1399                    dist: Arc::new(built_dist),
1400                    version: package.version,
1401                };
1402                Node::Dist {
1403                    dist,
1404                    hashes,
1405                    install: true,
1406                }
1407            } else if let Some(sdist) = package.sdist.as_ref().filter(|_| !no_build) {
1408                let hashes = HashDigests::from(sdist.hashes.clone());
1409                let sdist = Dist::Source(SourceDist::Registry(sdist.to_sdist(
1410                    install_path,
1411                    &package.name,
1412                    package.version.as_ref(),
1413                    package.index.as_ref(),
1414                )?));
1415                let dist = ResolvedDist::Installable {
1416                    dist: Arc::new(sdist),
1417                    version: package.version,
1418                };
1419                Node::Dist {
1420                    dist,
1421                    hashes,
1422                    install: true,
1423                }
1424            } else if let Some(sdist) = package.directory.as_ref().filter(|_| !no_build) {
1425                let hashes = HashDigests::empty();
1426                let sdist = Dist::Source(SourceDist::Directory(
1427                    sdist.to_sdist(install_path, &package.name)?,
1428                ));
1429                let dist = ResolvedDist::Installable {
1430                    dist: Arc::new(sdist),
1431                    version: package.version,
1432                };
1433                Node::Dist {
1434                    dist,
1435                    hashes,
1436                    install: true,
1437                }
1438            } else if let Some(sdist) = package.vcs.as_ref().filter(|_| !no_build) {
1439                let hashes = HashDigests::empty();
1440                let sdist = Dist::Source(SourceDist::GitDirectory(
1441                    sdist.to_sdist(install_path, &package.name)?,
1442                ));
1443                let dist = ResolvedDist::Installable {
1444                    dist: Arc::new(sdist),
1445                    version: package.version,
1446                };
1447                Node::Dist {
1448                    dist,
1449                    hashes,
1450                    install: true,
1451                }
1452            } else if let Some(dist) = package
1453                .archive
1454                .as_ref()
1455                .filter(|_| if is_wheel { !no_binary } else { !no_build })
1456            {
1457                let hashes = HashDigests::from(dist.hashes.clone());
1458                let dist = dist.to_dist(install_path, &package.name, package.version.as_ref())?;
1459                let dist = ResolvedDist::Installable {
1460                    dist: Arc::new(dist),
1461                    version: package.version,
1462                };
1463                Node::Dist {
1464                    dist,
1465                    hashes,
1466                    install: true,
1467                }
1468            } else {
1469                return match (no_binary, no_build) {
1470                    (true, true) => {
1471                        Err(PylockTomlErrorKind::NoBinaryNoBuild(package.name.clone()).into())
1472                    }
1473                    (true, false) if is_wheel => {
1474                        Err(PylockTomlErrorKind::NoBinaryWheelOnly(package.name.clone()).into())
1475                    }
1476                    (true, false) => {
1477                        Err(PylockTomlErrorKind::NoBinary(package.name.clone()).into())
1478                    }
1479                    (false, true) => Err(PylockTomlErrorKind::NoBuild(package.name.clone()).into()),
1480                    (false, false) if is_wheel => Err(PylockTomlError {
1481                        kind: Box::new(PylockTomlErrorKind::IncompatibleWheelOnly(
1482                            package.name.clone(),
1483                        )),
1484                        hint: package.tag_hint(tags, markers),
1485                    }),
1486                    (false, false) => Err(PylockTomlError {
1487                        kind: Box::new(PylockTomlErrorKind::NeitherSourceDistNorWheel(
1488                            package.name.clone(),
1489                        )),
1490                        hint: package.tag_hint(tags, markers),
1491                    }),
1492                };
1493            };
1494
1495            let index = graph.add_node(dist);
1496            graph.add_edge(root, index, Edge::Prod);
1497        }
1498
1499        Ok(Resolution::new(graph))
1500    }
1501}
1502
1503impl PylockTomlPackage {
1504    /// Convert the [`PylockTomlPackage`] to a TOML [`Table`].
1505    fn to_toml(&self) -> Result<Table, toml_edit::ser::Error> {
1506        let mut table = Table::new();
1507        table.insert("name", value(self.name.to_string()));
1508        if let Some(ref version) = self.version {
1509            table.insert("version", value(version.to_string()));
1510        }
1511        if let Some(marker) = self.marker.try_to_string() {
1512            table.insert("marker", value(marker));
1513        }
1514        if let Some(ref requires_python) = self.requires_python {
1515            table.insert("requires-python", value(requires_python.to_string()));
1516        }
1517        if !self.dependencies.is_empty() {
1518            let dependencies = self
1519                .dependencies
1520                .iter()
1521                .map(|dependency| {
1522                    serde::Serialize::serialize(&dependency, toml_edit::ser::ValueSerializer::new())
1523                })
1524                .collect::<Result<Vec<_>, _>>()?;
1525            let dependencies = match dependencies.as_slice() {
1526                [] => Array::new(),
1527                [dependency] => Array::from_iter([dependency]),
1528                dependencies => each_element_on_its_line_array(dependencies.iter()),
1529            };
1530            table.insert("dependencies", value(dependencies));
1531        }
1532        if let Some(ref index) = self.index {
1533            table.insert("index", value(index.to_string()));
1534        }
1535        if let Some(ref vcs) = self.vcs {
1536            table.insert(
1537                "vcs",
1538                value(serde::Serialize::serialize(
1539                    &vcs,
1540                    toml_edit::ser::ValueSerializer::new(),
1541                )?),
1542            );
1543        }
1544        if let Some(ref directory) = self.directory {
1545            table.insert(
1546                "directory",
1547                value(serde::Serialize::serialize(
1548                    &directory,
1549                    toml_edit::ser::ValueSerializer::new(),
1550                )?),
1551            );
1552        }
1553        if let Some(ref archive) = self.archive {
1554            table.insert(
1555                "archive",
1556                value(serde::Serialize::serialize(
1557                    &archive,
1558                    toml_edit::ser::ValueSerializer::new(),
1559                )?),
1560            );
1561        }
1562        if let Some(ref sdist) = self.sdist {
1563            table.insert(
1564                "sdist",
1565                value(serde::Serialize::serialize(
1566                    &sdist,
1567                    toml_edit::ser::ValueSerializer::new(),
1568                )?),
1569            );
1570        }
1571        if let Some(wheels) = self.wheels.as_ref().filter(|wheels| !wheels.is_empty()) {
1572            let wheels = wheels
1573                .iter()
1574                .map(|wheel| {
1575                    serde::Serialize::serialize(wheel, toml_edit::ser::ValueSerializer::new())
1576                })
1577                .collect::<Result<Vec<_>, _>>()?;
1578            let wheels = match wheels.as_slice() {
1579                [] => Array::new(),
1580                [wheel] => Array::from_iter([wheel]),
1581                wheels => each_element_on_its_line_array(wheels.iter()),
1582            };
1583            table.insert("wheels", value(wheels));
1584        }
1585
1586        Ok(table)
1587    }
1588
1589    /// Return the index of the best wheel for the given tags.
1590    fn find_best_wheel(&self, tags: &Tags) -> Option<&PylockTomlWheel> {
1591        type WheelPriority = (TagPriority, Option<BuildTag>);
1592
1593        let mut best: Option<(WheelPriority, &PylockTomlWheel)> = None;
1594        for wheel in self.wheels.iter().flatten() {
1595            let Ok(filename) = wheel.filename(&self.name) else {
1596                continue;
1597            };
1598            let TagCompatibility::Compatible(tag_priority) = filename.compatibility(tags) else {
1599                continue;
1600            };
1601            let build_tag = filename.build_tag().cloned();
1602            let wheel_priority = (tag_priority, build_tag);
1603            match &best {
1604                None => {
1605                    best = Some((wheel_priority, wheel));
1606                }
1607                Some((best_priority, _)) => {
1608                    if wheel_priority > *best_priority {
1609                        best = Some((wheel_priority, wheel));
1610                    }
1611                }
1612            }
1613        }
1614
1615        best.map(|(_, i)| i)
1616    }
1617
1618    /// Generate a [`WheelTagHint`] based on wheel-tag incompatibilities.
1619    fn tag_hint(&self, tags: &Tags, markers: &MarkerEnvironment) -> Option<WheelTagHint> {
1620        let filenames = self
1621            .wheels
1622            .iter()
1623            .flatten()
1624            .filter_map(|wheel| wheel.filename(&self.name).ok())
1625            .collect::<Vec<_>>();
1626        let filenames = filenames.iter().map(Cow::as_ref).collect::<Vec<_>>();
1627        WheelTagHint::from_wheels(&self.name, self.version.as_ref(), &filenames, tags, markers)
1628    }
1629
1630    /// Returns the [`ResolvedRepositoryReference`] for the package, if it is a Git source.
1631    pub fn as_git_ref(&self) -> Result<Option<ResolvedRepositoryReference>, GitUrlParseError> {
1632        let Some(vcs) = self.vcs.as_ref() else {
1633            return Ok(None);
1634        };
1635        let Some(url) = vcs.url.as_ref() else {
1636            return Ok(None);
1637        };
1638        let reference = match vcs.requested_revision.as_ref() {
1639            Some(rev) => GitReference::from_rev(rev.clone()),
1640            None => GitReference::DefaultBranch,
1641        };
1642        let git = GitUrl::from_commit(url.clone(), reference, vcs.commit_id, GitLfs::Disabled)?;
1643        Ok(Some(ResolvedRepositoryReference {
1644            reference: RepositoryReference::from(&git),
1645            sha: vcs.commit_id,
1646        }))
1647    }
1648}
1649
1650impl PylockTomlWheel {
1651    /// Return the [`WheelFilename`] for this wheel.
1652    fn filename(&self, name: &PackageName) -> Result<Cow<'_, WheelFilename>, PylockTomlErrorKind> {
1653        if let Some(name) = self.name.as_ref() {
1654            Ok(Cow::Borrowed(name))
1655        } else if let Some(path) = self.path.as_ref() {
1656            let Some(filename) = path.as_ref().file_name().and_then(OsStr::to_str) else {
1657                return Err(PylockTomlErrorKind::PathMissingFilename(Box::<Path>::from(
1658                    path.clone(),
1659                )));
1660            };
1661            let filename = WheelFilename::from_str(filename).map(Cow::Owned)?;
1662            Ok(filename)
1663        } else if let Some(url) = self.url.as_ref() {
1664            let Some(filename) = url.filename().ok() else {
1665                return Err(PylockTomlErrorKind::UrlMissingFilename(url.clone()));
1666            };
1667            let filename = WheelFilename::from_str(&filename).map(Cow::Owned)?;
1668            Ok(filename)
1669        } else {
1670            Err(PylockTomlErrorKind::WheelMissingPathUrl(name.clone()))
1671        }
1672    }
1673
1674    /// Convert the wheel to a [`RegistryBuiltWheel`].
1675    fn to_registry_wheel(
1676        &self,
1677        install_path: &Path,
1678        name: &PackageName,
1679        index: Option<&DisplaySafeUrl>,
1680    ) -> Result<RegistryBuiltWheel, PylockTomlErrorKind> {
1681        let filename = self.filename(name)?.into_owned();
1682
1683        let file_url = if let Some(path) = self.path.as_ref() {
1684            let path = install_path.join(path);
1685            validate_path_size(&path, self.size)?;
1686            let url = DisplaySafeUrl::from_file_path(path)
1687                .map_err(|()| PylockTomlErrorKind::PathToUrl)?;
1688            UrlString::from(url)
1689        } else if let Some(url) = self.url.as_ref() {
1690            UrlString::from(url)
1691        } else {
1692            return Err(PylockTomlErrorKind::WheelMissingPathUrl(name.clone()));
1693        };
1694
1695        let index = if let Some(index) = index {
1696            IndexUrl::from(VerbatimUrl::from_url(index.clone()))
1697        } else {
1698            // Including the index is only a SHOULD in PEP 751. If it's omitted, we treat the
1699            // URL (less the filename) as the index. This isn't correct, but it's the best we can
1700            // do. In practice, the only effect here should be that we cache the wheel under a hash
1701            // of this URL (since we cache under the hash of the index).
1702            let mut index = file_url.to_url().map_err(PylockTomlErrorKind::ToUrl)?;
1703            index
1704                .path_segments_mut()
1705                .map_err(|()| PylockTomlErrorKind::InvalidArtifactUrl(file_url.clone()))?
1706                .pop();
1707            IndexUrl::from(VerbatimUrl::from_url(index))
1708        };
1709
1710        let file = Box::new(uv_distribution_types::File {
1711            dist_info_metadata: false,
1712            filename: SmallString::from(filename.to_string()),
1713            hashes: HashDigests::from(self.hashes.clone()),
1714            requires_python: None,
1715            size: self.size,
1716            upload_time_utc_ms: self.upload_time.map(Timestamp::as_millisecond),
1717            url: FileLocation::AbsoluteUrl(file_url),
1718            yanked: None,
1719            zstd: None,
1720        });
1721
1722        Ok(RegistryBuiltWheel {
1723            filename,
1724            file,
1725            index,
1726            size_is_authoritative: true,
1727        })
1728    }
1729}
1730
1731impl PylockTomlDirectory {
1732    /// Convert the sdist to a [`DirectorySourceDist`].
1733    fn to_sdist(
1734        &self,
1735        install_path: &Path,
1736        name: &PackageName,
1737    ) -> Result<DirectorySourceDist, PylockTomlErrorKind> {
1738        let path = if let Some(subdirectory) = self.subdirectory.as_ref() {
1739            install_path.join(&self.path).join(subdirectory)
1740        } else {
1741            install_path.join(&self.path)
1742        };
1743        let path = normalize_path(path);
1744        let url =
1745            VerbatimUrl::from_normalized_path(&path).map_err(|_| PylockTomlErrorKind::PathToUrl)?;
1746        Ok(DirectorySourceDist {
1747            name: name.clone(),
1748            install_path: path.into_owned().into_boxed_path(),
1749            editable: self.editable,
1750            r#virtual: Some(false),
1751            first_party: FirstParty::No,
1752            url,
1753        })
1754    }
1755}
1756
1757impl PylockTomlVcs {
1758    /// Convert the sdist to a [`GitDirectorySourceDist`].
1759    fn to_sdist(
1760        &self,
1761        install_path: &Path,
1762        name: &PackageName,
1763    ) -> Result<GitDirectorySourceDist, PylockTomlErrorKind> {
1764        let subdirectory = self.subdirectory.clone().map(Box::<Path>::from);
1765
1766        // Reconstruct the `GitUrl` from the individual fields.
1767        let git_url = {
1768            let mut url = if let Some(url) = self.url.as_ref() {
1769                url.clone()
1770            } else if let Some(path) = self.path.as_ref() {
1771                DisplaySafeUrl::from_url(
1772                    Url::from_directory_path(install_path.join(path))
1773                        .map_err(|()| PylockTomlErrorKind::PathToUrl)?,
1774                )
1775            } else {
1776                return Err(PylockTomlErrorKind::VcsMissingPathUrl(name.clone()));
1777            };
1778            url.set_fragment(None);
1779            url.set_query(None);
1780
1781            let reference = self
1782                .requested_revision
1783                .clone()
1784                .map(GitReference::from_rev)
1785                .unwrap_or_else(|| GitReference::BranchOrTagOrCommit(self.commit_id.to_string()));
1786            let precise = self.commit_id;
1787
1788            // TODO(samypr100): GitLfs::from_env() as pylock.toml spec doesn't specify how to label LFS support
1789            GitUrl::from_commit(url, reference, precise, GitLfs::from_env())?
1790        };
1791
1792        // Reconstruct the PEP 508-compatible URL from the `GitSource`.
1793        let url = DisplaySafeUrl::from(ParsedGitDirectoryUrl {
1794            url: git_url.clone(),
1795            subdirectory: subdirectory.clone(),
1796        });
1797
1798        Ok(GitDirectorySourceDist {
1799            name: name.clone(),
1800            git: Box::new(git_url),
1801            subdirectory: self.subdirectory.clone().map(Box::<Path>::from),
1802            url: VerbatimUrl::from_url(url),
1803        })
1804    }
1805}
1806
1807impl PylockTomlSdist {
1808    /// Return the filename for this sdist.
1809    fn filename(&self, name: &PackageName) -> Result<Cow<'_, SmallString>, PylockTomlErrorKind> {
1810        if let Some(name) = self.name.as_ref() {
1811            Ok(Cow::Borrowed(name))
1812        } else if let Some(path) = self.path.as_ref() {
1813            let Some(filename) = path.as_ref().file_name().and_then(OsStr::to_str) else {
1814                return Err(PylockTomlErrorKind::PathMissingFilename(Box::<Path>::from(
1815                    path.clone(),
1816                )));
1817            };
1818            Ok(Cow::Owned(SmallString::from(filename)))
1819        } else if let Some(url) = self.url.as_ref() {
1820            let Some(filename) = url.filename().ok() else {
1821                return Err(PylockTomlErrorKind::UrlMissingFilename(url.clone()));
1822            };
1823            Ok(Cow::Owned(SmallString::from(filename)))
1824        } else {
1825            Err(PylockTomlErrorKind::SdistMissingPathUrl(name.clone()))
1826        }
1827    }
1828
1829    /// Convert the sdist to a [`RegistrySourceDist`].
1830    fn to_sdist(
1831        &self,
1832        install_path: &Path,
1833        name: &PackageName,
1834        version: Option<&Version>,
1835        index: Option<&DisplaySafeUrl>,
1836    ) -> Result<RegistrySourceDist, PylockTomlErrorKind> {
1837        let filename = self.filename(name)?.into_owned();
1838        let ext = SourceDistExtension::from_path(filename.as_ref())?;
1839
1840        let version = if let Some(version) = version {
1841            Cow::Borrowed(version)
1842        } else {
1843            let filename = SourceDistFilename::parse(&filename, ext, name)?;
1844            Cow::Owned(filename.version)
1845        };
1846
1847        let file_url = if let Some(path) = self.path.as_ref() {
1848            let path = install_path.join(path);
1849            validate_path_size(&path, self.size)?;
1850            let url = DisplaySafeUrl::from_file_path(path)
1851                .map_err(|()| PylockTomlErrorKind::PathToUrl)?;
1852            UrlString::from(url)
1853        } else if let Some(url) = self.url.as_ref() {
1854            UrlString::from(url)
1855        } else {
1856            return Err(PylockTomlErrorKind::SdistMissingPathUrl(name.clone()));
1857        };
1858
1859        let index = if let Some(index) = index {
1860            IndexUrl::from(VerbatimUrl::from_url(index.clone()))
1861        } else {
1862            // Including the index is only a SHOULD in PEP 751. If it's omitted, we treat the
1863            // URL (less the filename) as the index. This isn't correct, but it's the best we can
1864            // do. In practice, the only effect here should be that we cache the sdist under a hash
1865            // of this URL (since we cache under the hash of the index).
1866            let mut index = file_url.to_url().map_err(PylockTomlErrorKind::ToUrl)?;
1867            index
1868                .path_segments_mut()
1869                .map_err(|()| PylockTomlErrorKind::InvalidArtifactUrl(file_url.clone()))?
1870                .pop();
1871            IndexUrl::from(VerbatimUrl::from_url(index))
1872        };
1873
1874        let file = Box::new(uv_distribution_types::File {
1875            dist_info_metadata: false,
1876            filename,
1877            hashes: HashDigests::from(self.hashes.clone()),
1878            requires_python: None,
1879            size: self.size,
1880            upload_time_utc_ms: self.upload_time.map(Timestamp::as_millisecond),
1881            url: FileLocation::AbsoluteUrl(file_url),
1882            yanked: None,
1883            zstd: None,
1884        });
1885
1886        Ok(RegistrySourceDist {
1887            name: name.clone(),
1888            version: version.into_owned(),
1889            file,
1890            ext,
1891            index,
1892            wheels: vec![],
1893            size_is_authoritative: true,
1894        })
1895    }
1896}
1897
1898impl PylockTomlArchive {
1899    fn to_dist(
1900        &self,
1901        install_path: &Path,
1902        name: &PackageName,
1903        version: Option<&Version>,
1904    ) -> Result<Dist, PylockTomlErrorKind> {
1905        if let Some(path) = self.path.as_ref() {
1906            let filename = path
1907                .as_ref()
1908                .file_name()
1909                .and_then(OsStr::to_str)
1910                .ok_or_else(|| {
1911                    PylockTomlErrorKind::PathMissingFilename(Box::<Path>::from(path.clone()))
1912                })?;
1913
1914            let ext = DistExtension::from_path(filename)?;
1915            match ext {
1916                DistExtension::Wheel => {
1917                    let filename = WheelFilename::from_str(filename)?;
1918                    let install_path = install_path.join(path);
1919                    validate_path_size(&install_path, self.size)?;
1920                    let url = VerbatimUrl::from_absolute_path(&install_path)
1921                        .map_err(|_| PylockTomlErrorKind::PathToUrl)?;
1922                    Ok(Dist::Built(BuiltDist::Path(PathBuiltDist {
1923                        filename,
1924                        install_path: install_path.into_boxed_path(),
1925                        url,
1926                    })))
1927                }
1928                DistExtension::Source(ext) => {
1929                    let install_path = install_path.join(path);
1930                    validate_path_size(&install_path, self.size)?;
1931                    let url = VerbatimUrl::from_absolute_path(&install_path)
1932                        .map_err(|_| PylockTomlErrorKind::PathToUrl)?;
1933                    Ok(Dist::Source(SourceDist::Path(PathSourceDist {
1934                        name: name.clone(),
1935                        version: version.cloned(),
1936                        install_path: install_path.into_boxed_path(),
1937                        ext,
1938                        url,
1939                    })))
1940                }
1941            }
1942        } else if let Some(url) = self.url.as_ref() {
1943            let filename = url
1944                .filename()
1945                .map_err(|_| PylockTomlErrorKind::UrlMissingFilename(url.clone()))?;
1946
1947            let ext = DistExtension::from_path(filename.as_ref())?;
1948            match ext {
1949                DistExtension::Wheel => {
1950                    let filename = WheelFilename::from_str(&filename)?;
1951                    Ok(Dist::Built(BuiltDist::DirectUrl(DirectUrlBuiltDist {
1952                        filename,
1953                        location: Box::new(url.clone()),
1954                        url: VerbatimUrl::from_url(url.clone()),
1955                        size: self.size,
1956                    })))
1957                }
1958                DistExtension::Source(ext) => {
1959                    Ok(Dist::Source(SourceDist::DirectUrl(DirectUrlSourceDist {
1960                        name: name.clone(),
1961                        location: Box::new(url.clone()),
1962                        subdirectory: self.subdirectory.clone().map(Box::<Path>::from),
1963                        ext,
1964                        url: VerbatimUrl::from_url(url.clone()),
1965                        size: self.size,
1966                    })))
1967                }
1968            }
1969        } else {
1970            Err(PylockTomlErrorKind::ArchiveMissingPathUrl(name.clone()))
1971        }
1972    }
1973
1974    /// Returns `true` if the [`PylockTomlArchive`] is a wheel.
1975    fn is_wheel(&self, name: &PackageName) -> Result<bool, PylockTomlErrorKind> {
1976        if let Some(path) = self.path.as_ref() {
1977            let filename = path
1978                .as_ref()
1979                .file_name()
1980                .and_then(OsStr::to_str)
1981                .ok_or_else(|| {
1982                    PylockTomlErrorKind::PathMissingFilename(Box::<Path>::from(path.clone()))
1983                })?;
1984
1985            let ext = DistExtension::from_path(filename)?;
1986            Ok(matches!(ext, DistExtension::Wheel))
1987        } else if let Some(url) = self.url.as_ref() {
1988            let filename = url
1989                .filename()
1990                .map_err(|_| PylockTomlErrorKind::UrlMissingFilename(url.clone()))?;
1991
1992            let ext = DistExtension::from_path(filename.as_ref())?;
1993            Ok(matches!(ext, DistExtension::Wheel))
1994        } else {
1995            Err(PylockTomlErrorKind::ArchiveMissingPathUrl(name.clone()))
1996        }
1997    }
1998}
1999
2000/// Convert a Jiff timestamp to a TOML datetime.
2001#[expect(clippy::ref_option)]
2002fn timestamp_to_toml_datetime<S>(
2003    timestamp: &Option<Timestamp>,
2004    serializer: S,
2005) -> Result<S::Ok, S::Error>
2006where
2007    S: serde::Serializer,
2008{
2009    let Some(timestamp) = timestamp else {
2010        return serializer.serialize_none();
2011    };
2012    let timestamp = timestamp.to_zoned(TimeZone::UTC);
2013    let timestamp = toml_edit::Datetime {
2014        date: Some(toml_edit::Date {
2015            year: u16::try_from(timestamp.year()).map_err(serde::ser::Error::custom)?,
2016            month: u8::try_from(timestamp.month()).map_err(serde::ser::Error::custom)?,
2017            day: u8::try_from(timestamp.day()).map_err(serde::ser::Error::custom)?,
2018        }),
2019        time: Some(toml_edit::Time {
2020            hour: u8::try_from(timestamp.hour()).map_err(serde::ser::Error::custom)?,
2021            minute: u8::try_from(timestamp.minute()).map_err(serde::ser::Error::custom)?,
2022            second: Some(u8::try_from(timestamp.second()).map_err(serde::ser::Error::custom)?),
2023            nanosecond: {
2024                let nanos =
2025                    u32::try_from(timestamp.nanosecond()).map_err(serde::ser::Error::custom)?;
2026                if nanos == 0 { None } else { Some(nanos) }
2027            },
2028        }),
2029        offset: Some(toml_edit::Offset::Z),
2030    };
2031    serializer.serialize_some(&timestamp)
2032}
2033
2034/// Convert a TOML datetime to a Jiff timestamp.
2035fn timestamp_from_toml_datetime<'de, D>(deserializer: D) -> Result<Option<Timestamp>, D::Error>
2036where
2037    D: serde::Deserializer<'de>,
2038{
2039    let Some(datetime) = Option::<toml_edit::Datetime>::deserialize(deserializer)? else {
2040        return Ok(None);
2041    };
2042
2043    // If the date is omitted, we can't parse the datetime.
2044    let Some(date) = datetime.date else {
2045        return Err(serde::de::Error::custom("missing date"));
2046    };
2047
2048    let year = i16::try_from(date.year).map_err(serde::de::Error::custom)?;
2049    let month = i8::try_from(date.month).map_err(serde::de::Error::custom)?;
2050    let day = i8::try_from(date.day).map_err(serde::de::Error::custom)?;
2051    let date = Date::new(year, month, day).map_err(serde::de::Error::custom)?;
2052
2053    // If the timezone is omitted, assume UTC.
2054    let tz = if let Some(offset) = datetime.offset {
2055        match offset {
2056            toml_edit::Offset::Z => TimeZone::UTC,
2057            toml_edit::Offset::Custom { minutes } => {
2058                let hours = i8::try_from(minutes / 60).map_err(serde::de::Error::custom)?;
2059                TimeZone::fixed(Offset::constant(hours))
2060            }
2061        }
2062    } else {
2063        TimeZone::UTC
2064    };
2065
2066    // If the time is omitted, assume midnight.
2067    let time = if let Some(time) = datetime.time {
2068        let hour = i8::try_from(time.hour).map_err(serde::de::Error::custom)?;
2069        let minute = i8::try_from(time.minute).map_err(serde::de::Error::custom)?;
2070        let second = time
2071            .second
2072            .map(i8::try_from)
2073            .transpose()
2074            .map_err(serde::de::Error::custom)?
2075            .unwrap_or_default();
2076        let nanosecond = time
2077            .nanosecond
2078            .map(i32::try_from)
2079            .transpose()
2080            .map_err(serde::de::Error::custom)?
2081            .unwrap_or_default();
2082        Time::new(hour, minute, second, nanosecond).map_err(serde::de::Error::custom)?
2083    } else {
2084        Time::midnight()
2085    };
2086
2087    let timestamp = tz
2088        .to_timestamp(DateTime::from_parts(date, time))
2089        .map_err(serde::de::Error::custom)?;
2090    Ok(Some(timestamp))
2091}