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