Skip to main content

uv_resolver/lock/export/
pylock_toml.rs

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