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