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