Skip to main content

uv_resolver/lock/export/
pylock_toml.rs

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