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