Skip to main content

uv_resolver/lock/export/
pylock_toml.rs

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