Skip to main content

uv_resolver/lock/export/
pylock_toml.rs

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