diff --git a/Cargo.lock b/Cargo.lock index f9e51c47a..64788d878 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5739,6 +5739,7 @@ dependencies = [ "same-file", "schemars", "serde", + "serde_json", "smallvec", "textwrap", "thiserror 2.0.12", diff --git a/crates/uv-configuration/src/export_format.rs b/crates/uv-configuration/src/export_format.rs index c38218dc4..d58729734 100644 --- a/crates/uv-configuration/src/export_format.rs +++ b/crates/uv-configuration/src/export_format.rs @@ -15,4 +15,8 @@ pub enum ExportFormat { #[serde(rename = "pylock.toml", alias = "pylock-toml")] #[cfg_attr(feature = "clap", clap(name = "pylock.toml", alias = "pylock-toml"))] PylockToml, + /// Export in `pex.lock` format. + #[serde(rename = "pex.lock", alias = "pex-lock")] + #[cfg_attr(feature = "clap", clap(name = "pex.lock", alias = "pex-lock"))] + PexLock, } diff --git a/crates/uv-resolver/Cargo.toml b/crates/uv-resolver/Cargo.toml index 715dacab8..3222567db 100644 --- a/crates/uv-resolver/Cargo.toml +++ b/crates/uv-resolver/Cargo.toml @@ -59,6 +59,7 @@ rustc-hash = { workspace = true } same-file = { workspace = true } schemars = { workspace = true, optional = true } serde = { workspace = true } +serde_json = { workspace = true } smallvec = { workspace = true } textwrap = { workspace = true } thiserror = { workspace = true } diff --git a/crates/uv-resolver/src/lib.rs b/crates/uv-resolver/src/lib.rs index e91df3a7e..4df058d36 100644 --- a/crates/uv-resolver/src/lib.rs +++ b/crates/uv-resolver/src/lib.rs @@ -5,7 +5,7 @@ pub use exclusions::Exclusions; pub use flat_index::{FlatDistributions, FlatIndex}; pub use fork_strategy::ForkStrategy; pub use lock::{ - Installable, Lock, LockError, LockVersion, Package, PackageMap, PylockToml, + Installable, Lock, LockError, LockVersion, Package, PackageMap, PexLock, PylockToml, PylockTomlErrorKind, RequirementsTxtExport, ResolverManifest, SatisfiesResult, TreeDisplay, VERSION, }; diff --git a/crates/uv-resolver/src/lock/export/mod.rs b/crates/uv-resolver/src/lock/export/mod.rs index 5b69329a7..b7dc49a52 100644 --- a/crates/uv-resolver/src/lock/export/mod.rs +++ b/crates/uv-resolver/src/lock/export/mod.rs @@ -19,11 +19,13 @@ use crate::graph_ops::{Reachable, marker_reachability}; pub(crate) use crate::lock::export::pylock_toml::PylockTomlPackage; pub use crate::lock::export::pylock_toml::{PylockToml, PylockTomlErrorKind}; pub use crate::lock::export::requirements_txt::RequirementsTxtExport; +pub use crate::lock::export::pex_lock::PexLock; use crate::universal_marker::resolve_conflicts; use crate::{Installable, Package}; mod pylock_toml; mod requirements_txt; +mod pex_lock; /// A flat requirement, with its associated marker. #[derive(Debug, Clone, PartialEq, Eq)] diff --git a/crates/uv-resolver/src/lock/export/pex_lock.rs b/crates/uv-resolver/src/lock/export/pex_lock.rs new file mode 100644 index 000000000..978b2f131 --- /dev/null +++ b/crates/uv-resolver/src/lock/export/pex_lock.rs @@ -0,0 +1,254 @@ +//! PEX lock file format support. +//! +//! This module provides functionality to export UV lock files to the PEX lock format, +//! which is used by the PEX packaging tool and Pantsbuild for reproducible Python builds. +//! +//! The PEX lock format is a JSON-based format that includes: +//! - Package metadata and version constraints +//! - Platform-specific resolves with 3-component platform tags +//! - Artifact information with separate algorithm and hash fields +//! - Build and resolution configuration + +use std::fmt; + +use serde::{Deserialize, Serialize}; + +use crate::lock::{Lock, LockError, WheelWireSource}; + +/// A PEX lock file representation. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PexLock { + /// The PEX version used to generate this lock file. + pub pex_version: String, + /// Whether to allow building from source. + pub allow_builds: bool, + /// Whether to allow prereleases. + pub allow_prereleases: bool, + /// Whether to allow wheels. + pub allow_wheels: bool, + /// Whether to use build isolation. + pub build_isolation: bool, + /// Whether to prefer older binary versions. + pub prefer_older_binary: bool, + /// Whether to use PEP517 build backend. + pub use_pep517: Option, + /// The resolver version used. + pub resolver_version: String, + /// The style of resolution. + pub style: String, + /// Whether to include transitive dependencies. + pub transitive: bool, + /// Python version requirements. + pub requires_python: Vec, + /// Direct requirements. + pub requirements: Vec, + /// Constraints applied during resolution. + pub constraints: Vec, + /// Locked resolved dependencies. + pub locked_resolves: Vec, +} + +/// A locked resolve entry in a PEX lock file. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PexLockedResolve { + /// The platform tag this resolve applies to (3 components: [interpreter, abi, platform]). + pub platform_tag: Vec, + /// The locked requirements for this platform. + pub locked_requirements: Vec, +} + +/// A locked requirement in a PEX lock file. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PexLockedRequirement { + /// The project name. + pub project_name: String, + /// The version. + pub version: String, + /// The requirement specifier. + pub requirement: String, + /// Artifacts (wheels/sdists) for this requirement. + pub artifacts: Vec, +} + +/// An artifact in a PEX lock file. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PexArtifact { + /// The artifact URL. + pub url: String, + /// The filename. + pub filename: String, + /// Hash algorithm (e.g., "sha256"). + pub algorithm: String, + /// Hash value. + pub hash: String, + /// Whether this is a wheel. + pub is_wheel: bool, +} + +impl PexLock { + /// Default PEX version for generated lock files. + const DEFAULT_PEX_VERSION: &'static str = "2.44.0"; + + /// Default hash algorithm when none is specified. + const DEFAULT_HASH_ALGORITHM: &'static str = "sha256"; + + /// Universal platform tag components: [interpreter, abi, platform]. + const UNIVERSAL_PLATFORM_TAG: [&'static str; 3] = ["py", "none", "any"]; + + /// Extract algorithm and hash from a hash string. + fn parse_hash(hash_str: &str) -> (String, String) { + if let Some(colon_pos) = hash_str.find(':') { + let algorithm = hash_str[..colon_pos].to_string(); + let hash_value = hash_str[colon_pos + 1..].to_string(); + (algorithm, hash_value) + } else { + (Self::DEFAULT_HASH_ALGORITHM.to_string(), hash_str.to_string()) + } + } + + /// Create a new PEX lock from a UV lock file. + pub fn from_lock(lock: &Lock) -> Result { + let mut requirements = Vec::new(); + let mut locked_requirements = Vec::new(); + + // Collect root requirements + if let Some(root) = lock.root() { + for dep in &root.dependencies { + if let Some(version) = lock.packages().iter() + .find(|pkg| pkg.id.name == dep.package_id.name) + .and_then(|pkg| pkg.id.version.as_ref()) + { + requirements.push(format!("{}=={}", dep.package_id.name, version)); + } + } + } + + // Process all packages for locked requirements + for package in lock.packages() { + + // Create locked requirement + let mut artifacts = Vec::new(); + + // Add wheels + for wheel in &package.wheels { + let wheel_url = match &wheel.url { + WheelWireSource::Url { url } => url.to_string(), + WheelWireSource::Path { path } => format!("file://{}", path.to_string_lossy()), + WheelWireSource::Filename { filename } => filename.to_string(), + }; + + let (algorithm, hash) = if let Some(h) = wheel.hash.as_ref() { + Self::parse_hash(&h.to_string()) + } else { + continue; + }; + + artifacts.push(PexArtifact { + url: wheel_url, + filename: wheel.filename.to_string(), + algorithm, + hash, + is_wheel: true, + }); + } + + // Add source distributions + if let Some(sdist) = &package.sdist { + let Some(sdist_url) = sdist.url().map(|u| u.to_string()) else { + continue; + }; + let Some(sdist_filename) = sdist.filename().map(|f| f.to_string()) else { + continue; + }; + + let (algorithm, hash) = if let Some(h) = sdist.hash() { + Self::parse_hash(&h.to_string()) + } else { + continue; + }; + + artifacts.push(PexArtifact { + url: sdist_url, + filename: sdist_filename, + algorithm, + hash, + is_wheel: false, + }); + } + + if let Some(version) = &package.id.version { + locked_requirements.push(PexLockedRequirement { + project_name: package.id.name.to_string(), + version: version.to_string(), + requirement: format!("{}=={}", package.id.name, version), + artifacts, + }); + } + } + + let locked_resolves = vec![PexLockedResolve { + platform_tag: Self::UNIVERSAL_PLATFORM_TAG.iter().map(|s| s.to_string()).collect(), + locked_requirements, + }]; + + Ok(PexLock { + pex_version: Self::DEFAULT_PEX_VERSION.to_string(), + allow_builds: true, + allow_prereleases: false, + allow_wheels: true, + build_isolation: true, + prefer_older_binary: false, + use_pep517: None, + resolver_version: Self::DEFAULT_PEX_VERSION.to_string(), + style: "universal".to_string(), + transitive: true, + requires_python: vec![lock.requires_python().to_string()], + requirements, + constraints: Vec::new(), + locked_resolves, + }) + } + + /// Serialize the PEX lock to JSON. + pub fn to_json(&self) -> Result { + serde_json::to_string_pretty(self) + } +} + +impl fmt::Display for PexLock { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self.to_json() { + Ok(json) => write!(f, "{}", json), + Err(err) => write!(f, "Error serializing PEX lock: {}", err), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_pex_lock_serialization() { + let pex_lock = PexLock { + pex_version: PexLock::DEFAULT_PEX_VERSION.to_string(), + allow_builds: true, + allow_prereleases: false, + allow_wheels: true, + build_isolation: true, + prefer_older_binary: false, + use_pep517: None, + resolver_version: PexLock::DEFAULT_PEX_VERSION.to_string(), + style: "universal".to_string(), + transitive: true, + requires_python: vec![">=3.8".to_string()], + requirements: vec!["requests==2.31.0".to_string()], + constraints: vec![], + locked_resolves: vec![], + }; + + let json = pex_lock.to_json().unwrap(); + assert!(json.contains("\"pex_version\": \"2.44.0\"")); + assert!(json.contains("\"allow_builds\": true")); + } +} \ No newline at end of file diff --git a/crates/uv-resolver/src/lock/mod.rs b/crates/uv-resolver/src/lock/mod.rs index beeadc912..f13d43e4b 100644 --- a/crates/uv-resolver/src/lock/mod.rs +++ b/crates/uv-resolver/src/lock/mod.rs @@ -53,7 +53,7 @@ use uv_workspace::WorkspaceMember; use crate::fork_strategy::ForkStrategy; pub(crate) use crate::lock::export::PylockTomlPackage; pub use crate::lock::export::RequirementsTxtExport; -pub use crate::lock::export::{PylockToml, PylockTomlErrorKind}; +pub use crate::lock::export::{PexLock, PylockToml, PylockTomlErrorKind}; pub use crate::lock::installable::Installable; pub use crate::lock::map::PackageMap; pub use crate::lock::tree::TreeDisplay; @@ -63,7 +63,7 @@ use crate::{ ExcludeNewer, InMemoryIndex, MetadataResponse, PrereleaseMode, ResolutionMode, ResolverOutput, }; -mod export; +pub(crate) mod export; mod installable; mod map; mod tree; diff --git a/crates/uv/src/commands/pip/compile.rs b/crates/uv/src/commands/pip/compile.rs index a1846d418..28db2943a 100644 --- a/crates/uv/src/commands/pip/compile.rs +++ b/crates/uv/src/commands/pip/compile.rs @@ -422,6 +422,10 @@ pub(crate) async fn pip_compile( ExportFormat::PylockToml => { read_pylock_toml_requirements(output_file, &upgrade).await? } + ExportFormat::PexLock => { + // PEX lock files are not supported for reading locked requirements + LockedRequirements::default() + } } } else { LockedRequirements::default() @@ -692,6 +696,9 @@ pub(crate) async fn pip_compile( let export = PylockToml::from_resolution(&resolution, &no_emit_packages, install_path)?; write!(writer, "{}", export.to_toml()?)?; } + ExportFormat::PexLock => { + return Err(anyhow::anyhow!("PEX lock format is not supported in pip compile")); + } } // If any "unsafe" packages were excluded, notify the user. diff --git a/crates/uv/src/commands/project/export.rs b/crates/uv/src/commands/project/export.rs index c14bfd904..48972bc51 100644 --- a/crates/uv/src/commands/project/export.rs +++ b/crates/uv/src/commands/project/export.rs @@ -14,7 +14,7 @@ use uv_configuration::{ use uv_normalize::{DefaultExtras, DefaultGroups, PackageName}; use uv_python::{PythonDownloads, PythonPreference, PythonRequest}; use uv_requirements::is_pylock_toml; -use uv_resolver::{PylockToml, RequirementsTxtExport}; +use uv_resolver::{PexLock, PylockToml, RequirementsTxtExport, Installable}; use uv_scripts::{Pep723ItemRef, Pep723Script}; use uv_settings::PythonInstallMirrors; use uv_workspace::{DiscoveryOptions, MemberDiscovery, VirtualProject, Workspace, WorkspaceCache}; @@ -283,6 +283,13 @@ pub(crate) async fn export( .is_some_and(is_pylock_toml) { ExportFormat::PylockToml + } else if output_file + .as_deref() + .and_then(Path::file_name) + .and_then(OsStr::to_str) + .is_some_and(|name| name.ends_with(".pex.lock") || name.ends_with(".pex.json")) + { + ExportFormat::PexLock } else { ExportFormat::RequirementsTxt } @@ -348,6 +355,19 @@ pub(crate) async fn export( } write!(writer, "{}", export.to_toml()?)?; } + ExportFormat::PexLock => { + let export = PexLock::from_lock(target.lock())?; + + if include_header { + writeln!( + writer, + "{}", + "// This file was autogenerated by uv via the following command:".green() + )?; + writeln!(writer, "{}", format!("// {}", cmd()).green())?; + } + write!(writer, "{}", export.to_json()?)?; + } } writer.commit().await?; diff --git a/crates/uv/tests/it/export.rs b/crates/uv/tests/it/export.rs index b48536f2e..5dd0b1bb7 100644 --- a/crates/uv/tests/it/export.rs +++ b/crates/uv/tests/it/export.rs @@ -4433,3 +4433,4 @@ fn pep_751_https_credentials() -> Result<()> { Ok(()) } +