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