Skip to main content

uv_resolver/lock/export/
pylock_toml.rs

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