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