From e089c42e4300eab169d9cd48e79eba1072a6359d Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Mon, 21 Apr 2025 18:10:30 -0400 Subject: [PATCH] Add `pylock.toml` to `uv pip install` and `uv pip sync` (#12992) ## Summary We accept `pylock.toml` as a requirements file (e.g., `uv sync pylock.toml` or `uv pip install -r pylock.toml`). When you provide a `pylock.toml` file, we don't allow you to provide other requirements, or constraints, etc. And you can only provide one `pylock.toml` file, not multiple. We might want to remove this from `uv pip install` for now, since `pip` may end up with a different interface (whereas `uv pip sync` is already specific to uv), and most of the arguments aren't applicable (like `--resolution`, etc.). Regardless, it's behind `--preview` for both commands. --- .../uv-distribution-types/src/resolution.rs | 13 +- crates/uv-requirements/src/sources.rs | 62 +- crates/uv-requirements/src/specification.rs | 85 ++- .../src/lock/export/pylock_toml.rs | 587 +++++++++++++++- crates/uv-resolver/src/lock/mod.rs | 4 +- crates/uv/src/commands/pip/compile.rs | 8 + crates/uv/src/commands/pip/install.rs | 123 ++-- crates/uv/src/commands/pip/sync.rs | 121 ++-- crates/uv/tests/it/pip_install.rs | 628 +++++++++++++++++- crates/uv/tests/it/pip_sync.rs | 89 +++ 10 files changed, 1572 insertions(+), 148 deletions(-) diff --git a/crates/uv-distribution-types/src/resolution.rs b/crates/uv-distribution-types/src/resolution.rs index ea02c64f4..50195c261 100644 --- a/crates/uv-distribution-types/src/resolution.rs +++ b/crates/uv-distribution-types/src/resolution.rs @@ -19,7 +19,7 @@ pub struct Resolution { } impl Resolution { - /// Create a new resolution from the given pinned packages. + /// Create a [`Resolution`] from the given pinned packages. pub fn new(graph: petgraph::graph::DiGraph) -> Self { Self { graph, @@ -208,17 +208,6 @@ pub enum Edge { Dev(GroupName, MarkerTree), } -impl Edge { - /// Return the [`MarkerTree`] for this edge. - pub fn marker(&self) -> &MarkerTree { - match self { - Self::Prod(marker) => marker, - Self::Optional(_, marker) => marker, - Self::Dev(_, marker) => marker, - } - } -} - impl From<&ResolvedDist> for RequirementSource { fn from(resolved_dist: &ResolvedDist) -> Self { match resolved_dist { diff --git a/crates/uv-requirements/src/sources.rs b/crates/uv-requirements/src/sources.rs index 5af2a606b..5ee6d1426 100644 --- a/crates/uv-requirements/src/sources.rs +++ b/crates/uv-requirements/src/sources.rs @@ -13,6 +13,8 @@ pub enum RequirementsSource { Package(RequirementsTxtRequirement), /// An editable path was provided on the command line (e.g., `pip install -e ../flask`). Editable(RequirementsTxtRequirement), + /// Dependencies were provided via a `pylock.toml` file. + PylockToml(PathBuf), /// Dependencies were provided via a `requirements.txt` file (e.g., `pip install -r requirements.txt`). RequirementsTxt(PathBuf), /// Dependencies were provided via a `pyproject.toml` file (e.g., `pip-compile pyproject.toml`). @@ -39,6 +41,11 @@ impl RequirementsSource { Self::SetupCfg(path) } else if path.ends_with("environment.yml") { Self::EnvironmentYml(path) + } else if path + .file_name() + .is_some_and(|file_name| file_name.to_str().is_some_and(is_pylock_toml)) + { + Self::PylockToml(path) } else { Self::RequirementsTxt(path) } @@ -46,12 +53,20 @@ impl RequirementsSource { /// Parse a [`RequirementsSource`] from a `requirements.txt` file. pub fn from_requirements_txt(path: PathBuf) -> Self { - for filename in ["pyproject.toml", "setup.py", "setup.cfg"] { - if path.ends_with(filename) { + for file_name in ["pyproject.toml", "setup.py", "setup.cfg"] { + if path.ends_with(file_name) { warn_user!( "The file `{}` appears to be a `{}` file, but requirements must be specified in `requirements.txt` format.", path.user_display(), - filename + file_name + ); + } + } + if let Some(file_name) = path.file_name() { + if file_name.to_str().is_some_and(is_pylock_toml) { + warn_user!( + "The file `{}` appears to be a `pylock.toml` file, but requirements must be specified in `requirements.txt` format.", + path.user_display(), ); } } @@ -60,12 +75,20 @@ impl RequirementsSource { /// Parse a [`RequirementsSource`] from a `constraints.txt` file. pub fn from_constraints_txt(path: PathBuf) -> Self { - for filename in ["pyproject.toml", "setup.py", "setup.cfg"] { - if path.ends_with(filename) { + for file_name in ["pyproject.toml", "setup.py", "setup.cfg"] { + if path.ends_with(file_name) { warn_user!( "The file `{}` appears to be a `{}` file, but constraints must be specified in `requirements.txt` format.", path.user_display(), - filename + file_name + ); + } + } + if let Some(file_name) = path.file_name() { + if file_name.to_str().is_some_and(is_pylock_toml) { + warn_user!( + "The file `{}` appears to be a `pylock.toml` file, but constraints must be specified in `requirements.txt` format.", + path.user_display(), ); } } @@ -74,12 +97,20 @@ impl RequirementsSource { /// Parse a [`RequirementsSource`] from an `overrides.txt` file. pub fn from_overrides_txt(path: PathBuf) -> Self { - for filename in ["pyproject.toml", "setup.py", "setup.cfg"] { - if path.ends_with(filename) { + for file_name in ["pyproject.toml", "setup.py", "setup.cfg"] { + if path.ends_with(file_name) { warn_user!( "The file `{}` appears to be a `{}` file, but overrides must be specified in `requirements.txt` format.", path.user_display(), - filename + file_name + ); + } + } + if let Some(file_name) = path.file_name() { + if file_name.to_str().is_some_and(is_pylock_toml) { + warn_user!( + "The file `{}` appears to be a `pylock.toml` file, but overrides must be specified in `requirements.txt` format.", + path.user_display(), ); } } @@ -110,7 +141,10 @@ impl RequirementsSource { // Similarly, if the user provided a `pyproject.toml` file without `-r` (as in // `uv pip install pyproject.toml`), prompt them to correct it. - if (name == "pyproject.toml" || name == "setup.py" || name == "setup.cfg") + if (name == "pyproject.toml" + || name == "setup.py" + || name == "setup.cfg" + || is_pylock_toml(name)) && Path::new(&name).is_file() { let term = Term::stderr(); @@ -155,7 +189,10 @@ impl RequirementsSource { // Similarly, if the user provided a `pyproject.toml` file without `--with-requirements` (as in // `uvx --with pyproject.toml ruff`), prompt them to correct it. - if (name == "pyproject.toml" || name == "setup.py" || name == "setup.cfg") + if (name == "pyproject.toml" + || name == "setup.py" + || name == "setup.cfg" + || is_pylock_toml(name)) && Path::new(&name).is_file() { let term = Term::stderr(); @@ -217,7 +254,8 @@ impl std::fmt::Display for RequirementsSource { match self { Self::Package(package) => write!(f, "{package:?}"), Self::Editable(path) => write!(f, "-e {path:?}"), - Self::RequirementsTxt(path) + Self::PylockToml(path) + | Self::RequirementsTxt(path) | Self::PyprojectToml(path) | Self::SetupPy(path) | Self::SetupCfg(path) diff --git a/crates/uv-requirements/src/specification.rs b/crates/uv-requirements/src/specification.rs index 0f16ada10..f14aa635b 100644 --- a/crates/uv-requirements/src/specification.rs +++ b/crates/uv-requirements/src/specification.rs @@ -61,6 +61,8 @@ pub struct RequirementsSpecification { pub constraints: Vec, /// The overrides for the project. pub overrides: Vec, + /// The `pylock.toml` file from which to extract the resolution. + pub pylock: Option, /// The source trees from which to extract requirements. pub source_trees: Vec, /// The groups to use for `source_trees` @@ -190,6 +192,16 @@ impl RequirementsSpecification { ..Self::default() } } + RequirementsSource::PylockToml(path) => { + if !path.is_file() { + return Err(anyhow::anyhow!("File not found: `{}`", path.user_display())); + } + + Self { + pylock: Some(path.clone()), + ..Self::default() + } + } RequirementsSource::SourceTree(path) => { if !path.is_dir() { return Err(anyhow::anyhow!( @@ -231,7 +243,66 @@ impl RequirementsSpecification { ) -> Result { let mut spec = Self::default(); - // Resolve sources into specifications so we know their `source_tree`s∂ + // Disallow `pylock.toml` files as constraints. + if let Some(pylock_toml) = constraints.iter().find_map(|source| { + if let RequirementsSource::PylockToml(path) = source { + Some(path) + } else { + None + } + }) { + return Err(anyhow::anyhow!( + "Cannot use `{}` as a constraint file", + pylock_toml.user_display() + )); + } + + // Disallow `pylock.toml` files as overrides. + if let Some(pylock_toml) = overrides.iter().find_map(|source| { + if let RequirementsSource::PylockToml(path) = source { + Some(path) + } else { + None + } + }) { + return Err(anyhow::anyhow!( + "Cannot use `{}` as an override file", + pylock_toml.user_display() + )); + } + + // If we have a `pylock.toml`, don't allow additional requirements, constraints, or + // overrides. + if requirements + .iter() + .any(|source| matches!(source, RequirementsSource::PylockToml(..))) + { + if requirements + .iter() + .any(|source| !matches!(source, RequirementsSource::PylockToml(..))) + { + return Err(anyhow::anyhow!( + "Cannot specify additional requirements alongside a `pylock.toml` file", + )); + } + if !constraints.is_empty() { + return Err(anyhow::anyhow!( + "Cannot specify additional requirements with a `pylock.toml` file" + )); + } + if !overrides.is_empty() { + return Err(anyhow::anyhow!( + "Cannot specify constraints with a `pylock.toml` file" + )); + } + if !groups.is_empty() { + return Err(anyhow::anyhow!( + "Cannot specify groups with a `pylock.toml` file" + )); + } + } + + // Resolve sources into specifications so we know their `source_tree`. let mut requirement_sources = Vec::new(); for source in requirements { let source = Self::from_source(source, client_builder).await?; @@ -301,6 +372,18 @@ impl RequirementsSpecification { spec.extras.extend(source.extras); spec.source_trees.extend(source.source_trees); + // Allow at most one `pylock.toml`. + if let Some(pylock) = source.pylock { + if let Some(existing) = spec.pylock { + return Err(anyhow::anyhow!( + "Multiple `pylock.toml` files specified: `{}` vs. `{}`", + existing.user_display(), + pylock.user_display() + )); + } + spec.pylock = Some(pylock); + } + // Use the first project name discovered. if spec.project.is_none() { spec.project = source.project; diff --git a/crates/uv-resolver/src/lock/export/pylock_toml.rs b/crates/uv-resolver/src/lock/export/pylock_toml.rs index 5e6022932..a27440c2c 100644 --- a/crates/uv-resolver/src/lock/export/pylock_toml.rs +++ b/crates/uv-resolver/src/lock/export/pylock_toml.rs @@ -1,22 +1,76 @@ -use jiff::tz::TimeZone; -use jiff::Timestamp; +use std::borrow::Cow; +use std::ffi::OsStr; +use std::path::Path; +use std::str::FromStr; +use std::sync::Arc; + +use jiff::civil::{DateTime, Time}; +use jiff::tz::{Offset, TimeZone}; +use jiff::{civil, Timestamp}; +use serde::Deserialize; use toml_edit::{value, Array, ArrayOfTables, Item, Table}; use url::Url; use uv_configuration::{DependencyGroupsWithDefaults, ExtrasSpecification, InstallOptions}; -use uv_distribution_types::{IndexUrl, RegistryBuiltWheel, RemoteSource, SourceDist}; +use uv_distribution_filename::{ + BuildTag, DistExtension, ExtensionError, SourceDistExtension, SourceDistFilename, + SourceDistFilenameError, WheelFilename, WheelFilenameError, +}; +use uv_distribution_types::{ + BuiltDist, DirectUrlBuiltDist, DirectUrlSourceDist, DirectorySourceDist, Dist, Edge, + FileLocation, GitSourceDist, IndexUrl, Node, PathBuiltDist, PathSourceDist, RegistryBuiltDist, + RegistryBuiltWheel, RegistrySourceDist, RemoteSource, Resolution, ResolvedDist, SourceDist, + ToUrlError, UrlString, +}; use uv_fs::{relative_to, PortablePathBuf}; -use uv_git_types::GitOid; +use uv_git_types::{GitOid, GitReference, GitUrl, GitUrlParseError}; use uv_normalize::{ExtraName, GroupName, PackageName}; use uv_pep440::Version; -use uv_pep508::MarkerTree; -use uv_pypi_types::{Hashes, VcsKind}; +use uv_pep508::{MarkerEnvironment, MarkerTree, VerbatimUrl}; +use uv_platform_tags::{TagCompatibility, TagPriority, Tags}; +use uv_pypi_types::{HashDigests, Hashes, ParsedGitUrl, VcsKind}; use uv_small_str::SmallString; use crate::lock::export::ExportableRequirements; -use crate::lock::{each_element_on_its_line_array, LockErrorKind, Source}; +use crate::lock::{each_element_on_its_line_array, Source}; use crate::{Installable, LockError, RequiresPython}; +#[derive(Debug, thiserror::Error)] +pub enum PylockTomlError { + #[error("`packages` entry for `{0}` must contain one of: `wheels`, `directory`, `archive`, `sdist`, or `vcs`")] + MissingSource(PackageName), + #[error("`packages.wheel` entry for `{0}` must have a `path` or `url`")] + WheelMissingPathUrl(PackageName), + #[error("`packages.sdist` entry for `{0}` must have a `path` or `url`")] + SdistMissingPathUrl(PackageName), + #[error("`packages.archive` entry for `{0}` must have a `path` or `url`")] + ArchiveMissingPathUrl(PackageName), + #[error("`packages.vcs` entry for `{0}` must have a `url` or `path`")] + VcsMissingPathUrl(PackageName), + #[error("URL must end in a valid wheel filename: `{0}`")] + UrlMissingFilename(Url), + #[error("Path must end in a valid wheel filename: `{0}`")] + PathMissingFilename(Box), + #[error("Failed to convert path to URL")] + PathToUrl, + #[error("Failed to convert URL to path")] + UrlToPath, + #[error(transparent)] + WheelFilename(#[from] WheelFilenameError), + #[error(transparent)] + SourceDistFilename(#[from] SourceDistFilenameError), + #[error(transparent)] + ToUrl(#[from] ToUrlError), + #[error(transparent)] + GitUrlParse(#[from] GitUrlParseError), + #[error(transparent)] + LockError(#[from] LockError), + #[error(transparent)] + Extension(#[from] ExtensionError), + #[error(transparent)] + Jiff(#[from] jiff::Error), +} + #[derive(Debug, serde::Serialize, serde::Deserialize)] #[serde(rename_all = "kebab-case")] pub struct PylockToml { @@ -106,7 +160,9 @@ struct PylockTomlArchive { size: Option, #[serde( skip_serializing_if = "Option::is_none", - serialize_with = "timestamp_to_toml_datetime" + serialize_with = "timestamp_to_toml_datetime", + deserialize_with = "timestamp_from_toml_datetime", + default )] upload_time: Option, #[serde(skip_serializing_if = "Option::is_none")] @@ -125,7 +181,9 @@ struct PylockTomlSdist { path: Option, #[serde( skip_serializing_if = "Option::is_none", - serialize_with = "timestamp_to_toml_datetime" + serialize_with = "timestamp_to_toml_datetime", + deserialize_with = "timestamp_from_toml_datetime", + default )] upload_time: Option, #[serde(skip_serializing_if = "Option::is_none")] @@ -137,14 +195,16 @@ struct PylockTomlSdist { #[serde(rename_all = "kebab-case")] struct PylockTomlWheel { #[serde(skip_serializing_if = "Option::is_none")] - name: Option, + name: Option, #[serde(skip_serializing_if = "Option::is_none")] url: Option, #[serde(skip_serializing_if = "Option::is_none")] path: Option, #[serde( skip_serializing_if = "Option::is_none", - serialize_with = "timestamp_to_toml_datetime" + serialize_with = "timestamp_to_toml_datetime", + deserialize_with = "timestamp_from_toml_datetime", + default )] upload_time: Option, #[serde(skip_serializing_if = "Option::is_none")] @@ -167,7 +227,7 @@ impl<'lock> PylockToml { dev: &DependencyGroupsWithDefaults, annotate: bool, install_options: &'lock InstallOptions, - ) -> Result { + ) -> Result { // Extract the packages from the lock file. let ExportableRequirements(mut nodes) = ExportableRequirements::from_lock( target, @@ -216,14 +276,14 @@ impl<'lock> PylockToml { let wheels = package .wheels .iter() - .map(|wheel| wheel.to_registry_dist(source, target.install_path())) + .map(|wheel| wheel.to_registry_wheel(source, target.install_path())) .collect::, LockError>>()?; Some( wheels .into_iter() .map(|wheel| { let url = - wheel.file.url.to_url().map_err(LockErrorKind::InvalidUrl)?; + wheel.file.url.to_url().map_err(PylockTomlError::ToUrl)?; Ok(PylockTomlWheel { // Optional "when the last component of path/ url would be the same value". name: if url @@ -232,21 +292,20 @@ impl<'lock> PylockToml { { None } else { - Some(wheel.file.filename.clone()) + Some(wheel.filename.clone()) }, upload_time: wheel .file .upload_time_utc_ms .map(Timestamp::from_millisecond) - .transpose() - .map_err(LockErrorKind::InvalidTimestamp)?, + .transpose()?, url: Some(url), path: None, size: wheel.file.size, hashes: Hashes::from(wheel.file.hashes), }) }) - .collect::, LockError>>()?, + .collect::, PylockTomlError>>()?, ) } Source::Path(..) => None, @@ -360,7 +419,7 @@ impl<'lock> PylockToml { // Extract the `packages.sdist` field. let sdist = match &sdist { Some(SourceDist::Registry(sdist)) => { - let url = sdist.file.url.to_url().map_err(LockErrorKind::InvalidUrl)?; + let url = sdist.file.url.to_url().map_err(PylockTomlError::ToUrl)?; Some(PylockTomlSdist { // Optional "when the last component of path/ url would be the same value". name: if url @@ -375,8 +434,7 @@ impl<'lock> PylockToml { .file .upload_time_utc_ms .map(Timestamp::from_millisecond) - .transpose() - .map_err(LockErrorKind::InvalidTimestamp)?, + .transpose()?, url: Some(url), path: None, size, @@ -485,9 +543,116 @@ impl<'lock> PylockToml { Ok(doc.to_string()) } + + /// Convert the [`PylockToml`] to a [`Resolution`]. + pub fn to_resolution( + self, + install_path: &Path, + markers: &MarkerEnvironment, + tags: &Tags, + ) -> Result { + let mut graph = + petgraph::graph::DiGraph::with_capacity(self.packages.len(), self.packages.len()); + + // Add the root node. + let root = graph.add_node(Node::Root); + + for package in self.packages { + // Omit packages that aren't relevant to the current environment. + let install = package.marker.evaluate(markers, &[]); + + // Search for a matching wheel. + let dist = if let Some(best_wheel) = package.find_best_wheel(tags) { + let hashes = HashDigests::from(best_wheel.hashes.clone()); + let built_dist = Dist::Built(BuiltDist::Registry(RegistryBuiltDist { + wheels: vec![best_wheel.to_registry_wheel( + install_path, + &package.name, + package.index.as_ref(), + )?], + best_wheel_index: 0, + sdist: None, + })); + let dist = ResolvedDist::Installable { + dist: Arc::new(built_dist), + version: package.version, + }; + Node::Dist { + dist, + hashes, + install, + } + } else if let Some(sdist) = package.sdist.as_ref() { + let hashes = HashDigests::from(sdist.hashes.clone()); + let sdist = Dist::Source(SourceDist::Registry(sdist.to_sdist( + install_path, + &package.name, + package.version.as_ref(), + package.index.as_ref(), + )?)); + let dist = ResolvedDist::Installable { + dist: Arc::new(sdist), + version: package.version, + }; + Node::Dist { + dist, + hashes, + install, + } + } else if let Some(sdist) = package.directory.as_ref() { + let hashes = HashDigests::empty(); + let sdist = Dist::Source(SourceDist::Directory( + sdist.to_sdist(install_path, &package.name)?, + )); + let dist = ResolvedDist::Installable { + dist: Arc::new(sdist), + version: package.version, + }; + Node::Dist { + dist, + hashes, + install, + } + } else if let Some(sdist) = package.vcs.as_ref() { + let hashes = HashDigests::empty(); + let sdist = Dist::Source(SourceDist::Git( + sdist.to_sdist(install_path, &package.name)?, + )); + let dist = ResolvedDist::Installable { + dist: Arc::new(sdist), + version: package.version, + }; + Node::Dist { + dist, + hashes, + install, + } + } else if let Some(dist) = package.archive.as_ref() { + let hashes = HashDigests::from(dist.hashes.clone()); + let dist = dist.to_dist(install_path, &package.name, package.version.as_ref())?; + let dist = ResolvedDist::Installable { + dist: Arc::new(dist), + version: package.version, + }; + Node::Dist { + dist, + hashes, + install, + } + } else { + return Err(PylockTomlError::MissingSource(package.name.clone())); + }; + + let index = graph.add_node(dist); + graph.add_edge(root, index, Edge::Prod(package.marker)); + } + + Ok(Resolution::new(graph)) + } } impl PylockTomlPackage { + /// Convert the [`PylockTomlPackage`] to a TOML [`Table`]. fn to_toml(&self) -> Result { let mut table = Table::new(); table.insert("name", value(self.name.to_string())); @@ -571,6 +736,338 @@ impl PylockTomlPackage { Ok(table) } + + /// Return the index of the best wheel for the given tags. + fn find_best_wheel(&self, tags: &Tags) -> Option<&PylockTomlWheel> { + type WheelPriority = (TagPriority, Option); + + let mut best: Option<(WheelPriority, &PylockTomlWheel)> = None; + for wheel in self.wheels.iter().flatten() { + let Ok(filename) = wheel.filename(&self.name) else { + continue; + }; + let TagCompatibility::Compatible(tag_priority) = filename.compatibility(tags) else { + continue; + }; + let build_tag = filename.build_tag().cloned(); + let wheel_priority = (tag_priority, build_tag); + match &best { + None => { + best = Some((wheel_priority, wheel)); + } + Some((best_priority, _)) => { + if wheel_priority > *best_priority { + best = Some((wheel_priority, wheel)); + } + } + } + } + + best.map(|(_, i)| i) + } +} + +impl PylockTomlWheel { + /// Return the [`WheelFilename`] for this wheel. + fn filename(&self, name: &PackageName) -> Result, PylockTomlError> { + if let Some(name) = self.name.as_ref() { + Ok(Cow::Borrowed(name)) + } else if let Some(path) = self.path.as_ref() { + let Some(filename) = path.as_ref().file_name().and_then(OsStr::to_str) else { + return Err(PylockTomlError::PathMissingFilename(Box::::from( + path.clone(), + ))); + }; + let filename = WheelFilename::from_str(filename).map(Cow::Owned)?; + Ok(filename) + } else if let Some(url) = self.url.as_ref() { + let Some(filename) = url.filename().ok() else { + return Err(PylockTomlError::UrlMissingFilename(url.clone())); + }; + let filename = WheelFilename::from_str(&filename).map(Cow::Owned)?; + Ok(filename) + } else { + Err(PylockTomlError::WheelMissingPathUrl(name.clone())) + } + } + + /// Convert the wheel to a [`RegistryBuiltWheel`]. + fn to_registry_wheel( + &self, + install_path: &Path, + name: &PackageName, + index: Option<&Url>, + ) -> Result { + let filename = self.filename(name)?.into_owned(); + + let file_url = if let Some(url) = self.url.as_ref() { + UrlString::from(url) + } else if let Some(path) = self.path.as_ref() { + let path = install_path.join(path); + let url = Url::from_file_path(path).map_err(|()| PylockTomlError::PathToUrl)?; + UrlString::from(url) + } else { + return Err(PylockTomlError::WheelMissingPathUrl(name.clone())); + }; + + let index = if let Some(index) = index { + IndexUrl::from(VerbatimUrl::from_url(index.clone())) + } else { + // Including the index is only a SHOULD in PEP 751. If it's omitted, we treat the + // URL (less the filename) as the index. This isn't correct, but it's the best we can + // do. In practice, the only effect here should be that we cache the wheel under a hash + // of this URL (since we cache under the hash of the index). + let mut index = file_url.to_url().map_err(PylockTomlError::ToUrl)?; + index.path_segments_mut().unwrap().pop(); + IndexUrl::from(VerbatimUrl::from_url(index)) + }; + + let file = Box::new(uv_distribution_types::File { + dist_info_metadata: false, + filename: SmallString::from(filename.to_string()), + hashes: HashDigests::from(self.hashes.clone()), + requires_python: None, + size: self.size, + upload_time_utc_ms: self.upload_time.map(Timestamp::as_millisecond), + url: FileLocation::AbsoluteUrl(file_url), + yanked: None, + }); + + Ok(RegistryBuiltWheel { + filename, + file, + index, + }) + } +} + +impl PylockTomlDirectory { + /// Convert the sdist to a [`DirectorySourceDist`]. + fn to_sdist( + &self, + install_path: &Path, + name: &PackageName, + ) -> Result { + let path = if let Some(subdirectory) = self.subdirectory.as_ref() { + install_path.join(&self.path).join(subdirectory) + } else { + install_path.join(&self.path) + }; + let path = uv_fs::normalize_path_buf(path); + let url = + VerbatimUrl::from_normalized_path(&path).map_err(|_| PylockTomlError::PathToUrl)?; + Ok(DirectorySourceDist { + name: name.clone(), + install_path: path.into_boxed_path(), + editable: self.editable.unwrap_or(false), + r#virtual: false, + url, + }) + } +} + +impl PylockTomlVcs { + /// Convert the sdist to a [`GitSourceDist`]. + fn to_sdist( + &self, + install_path: &Path, + name: &PackageName, + ) -> Result { + let subdirectory = self.subdirectory.clone().map(Box::::from); + + // Reconstruct the `GitUrl` from the individual fields. + let git_url = { + let mut url = if let Some(url) = self.url.as_ref() { + url.clone() + } else if let Some(path) = self.path.as_ref() { + Url::from_directory_path(install_path.join(path)) + .map_err(|()| PylockTomlError::PathToUrl)? + } else { + return Err(PylockTomlError::VcsMissingPathUrl(name.clone())); + }; + url.set_fragment(None); + url.set_query(None); + + let reference = self + .requested_revision + .clone() + .map(GitReference::from_rev) + .unwrap_or_else(|| GitReference::BranchOrTagOrCommit(self.commit_id.to_string())); + let precise = self.commit_id; + + GitUrl::from_commit(url, reference, precise)? + }; + + // Reconstruct the PEP 508-compatible URL from the `GitSource`. + let url = Url::from(ParsedGitUrl { + url: git_url.clone(), + subdirectory: subdirectory.clone(), + }); + + Ok(GitSourceDist { + name: name.clone(), + git: Box::new(git_url), + subdirectory: self.subdirectory.clone().map(Box::::from), + url: VerbatimUrl::from_url(url), + }) + } +} + +impl PylockTomlSdist { + /// Return the filename for this sdist. + fn filename(&self, name: &PackageName) -> Result, PylockTomlError> { + if let Some(name) = self.name.as_ref() { + Ok(Cow::Borrowed(name)) + } else if let Some(path) = self.path.as_ref() { + let Some(filename) = path.as_ref().file_name().and_then(OsStr::to_str) else { + return Err(PylockTomlError::PathMissingFilename(Box::::from( + path.clone(), + ))); + }; + Ok(Cow::Owned(SmallString::from(filename))) + } else if let Some(url) = self.url.as_ref() { + let Some(filename) = url.filename().ok() else { + return Err(PylockTomlError::UrlMissingFilename(url.clone())); + }; + Ok(Cow::Owned(SmallString::from(filename))) + } else { + Err(PylockTomlError::SdistMissingPathUrl(name.clone())) + } + } + + /// Convert the sdist to a [`RegistrySourceDist`]. + fn to_sdist( + &self, + install_path: &Path, + name: &PackageName, + version: Option<&Version>, + index: Option<&Url>, + ) -> Result { + let filename = self.filename(name)?.into_owned(); + let ext = SourceDistExtension::from_path(filename.as_ref())?; + + let version = if let Some(version) = version { + Cow::Borrowed(version) + } else { + let filename = SourceDistFilename::parse(&filename, ext, name)?; + Cow::Owned(filename.version) + }; + + let file_url = if let Some(url) = self.url.as_ref() { + UrlString::from(url) + } else if let Some(path) = self.path.as_ref() { + let path = install_path.join(path); + let url = Url::from_file_path(path).map_err(|()| PylockTomlError::PathToUrl)?; + UrlString::from(url) + } else { + return Err(PylockTomlError::SdistMissingPathUrl(name.clone())); + }; + + let index = if let Some(index) = index { + IndexUrl::from(VerbatimUrl::from_url(index.clone())) + } else { + // Including the index is only a SHOULD in PEP 751. If it's omitted, we treat the + // URL (less the filename) as the index. This isn't correct, but it's the best we can + // do. In practice, the only effect here should be that we cache the sdist under a hash + // of this URL (since we cache under the hash of the index). + let mut index = file_url.to_url().map_err(PylockTomlError::ToUrl)?; + index.path_segments_mut().unwrap().pop(); + IndexUrl::from(VerbatimUrl::from_url(index)) + }; + + let file = Box::new(uv_distribution_types::File { + dist_info_metadata: false, + filename, + hashes: HashDigests::from(self.hashes.clone()), + requires_python: None, + size: self.size, + upload_time_utc_ms: self.upload_time.map(Timestamp::as_millisecond), + url: FileLocation::AbsoluteUrl(file_url), + yanked: None, + }); + + Ok(RegistrySourceDist { + name: name.clone(), + version: version.into_owned(), + file, + ext, + index, + wheels: vec![], + }) + } +} + +impl PylockTomlArchive { + fn to_dist( + &self, + install_path: &Path, + name: &PackageName, + version: Option<&Version>, + ) -> Result { + if let Some(url) = self.url.as_ref() { + let filename = url + .filename() + .map_err(|_| PylockTomlError::UrlMissingFilename(url.clone()))?; + + let ext = DistExtension::from_path(filename.as_ref())?; + match ext { + DistExtension::Wheel => { + let filename = WheelFilename::from_str(&filename)?; + Ok(Dist::Built(BuiltDist::DirectUrl(DirectUrlBuiltDist { + filename, + location: Box::new(url.clone()), + url: VerbatimUrl::from_url(url.clone()), + }))) + } + DistExtension::Source(ext) => { + Ok(Dist::Source(SourceDist::DirectUrl(DirectUrlSourceDist { + name: name.clone(), + location: Box::new(url.clone()), + subdirectory: self.subdirectory.clone().map(Box::::from), + ext, + url: VerbatimUrl::from_url(url.clone()), + }))) + } + } + } else if let Some(path) = self.path.as_ref() { + let filename = path + .as_ref() + .file_name() + .and_then(OsStr::to_str) + .ok_or_else(|| { + PylockTomlError::PathMissingFilename(Box::::from(path.clone())) + })?; + + let ext = DistExtension::from_path(filename)?; + match ext { + DistExtension::Wheel => { + let filename = WheelFilename::from_str(filename)?; + let install_path = install_path.join(path); + let url = VerbatimUrl::from_absolute_path(&install_path) + .map_err(|_| PylockTomlError::PathToUrl)?; + Ok(Dist::Built(BuiltDist::Path(PathBuiltDist { + filename, + install_path: install_path.into_boxed_path(), + url, + }))) + } + DistExtension::Source(ext) => { + let install_path = install_path.join(path); + let url = VerbatimUrl::from_absolute_path(&install_path) + .map_err(|_| PylockTomlError::PathToUrl)?; + Ok(Dist::Source(SourceDist::Path(PathSourceDist { + name: name.clone(), + version: version.cloned(), + install_path: install_path.into_boxed_path(), + ext, + url, + }))) + } + } + } else { + return Err(PylockTomlError::ArchiveMissingPathUrl(name.clone())); + } + } } /// Convert a Jiff timestamp to a TOML datetime. @@ -602,3 +1099,51 @@ where }; serializer.serialize_some(×tamp) } + +/// Convert a TOML datetime to a Jiff timestamp. +fn timestamp_from_toml_datetime<'de, D>(deserializer: D) -> Result, D::Error> +where + D: serde::Deserializer<'de>, +{ + let Some(datetime) = Option::::deserialize(deserializer)? else { + return Ok(None); + }; + let Some(date) = datetime.date else { + return Err(serde::de::Error::custom("missing date")); + }; + + let year = i16::try_from(date.year).map_err(serde::de::Error::custom)?; + let month = i8::try_from(date.month).map_err(serde::de::Error::custom)?; + let day = i8::try_from(date.day).map_err(serde::de::Error::custom)?; + let date = civil::date(year, month, day); + + // If the timezone is omitted, assume UTC. + let tz = if let Some(offset) = datetime.offset { + match offset { + toml_edit::Offset::Z => TimeZone::UTC, + toml_edit::Offset::Custom { minutes } => { + let hours = i8::try_from(minutes / 60).map_err(serde::de::Error::custom)?; + TimeZone::fixed(Offset::constant(hours)) + } + } + } else { + TimeZone::UTC + }; + + // If the time is omitted, assume midnight. + let time = if let Some(time) = datetime.time { + let hour = i8::try_from(time.hour).map_err(serde::de::Error::custom)?; + let minute = i8::try_from(time.minute).map_err(serde::de::Error::custom)?; + let second = i8::try_from(time.second).map_err(serde::de::Error::custom)?; + let nanosecond = i32::try_from(time.nanosecond).map_err(serde::de::Error::custom)?; + Time::constant(hour, minute, second, nanosecond) + } else { + Time::midnight() + }; + + let zoned = DateTime::from_parts(date, time) + .to_zoned(tz) + .map_err(serde::de::Error::custom)?; + let timestamp = zoned.timestamp(); + Ok(Some(timestamp)) +} diff --git a/crates/uv-resolver/src/lock/mod.rs b/crates/uv-resolver/src/lock/mod.rs index 6a6bebd1e..1710e38ed 100644 --- a/crates/uv-resolver/src/lock/mod.rs +++ b/crates/uv-resolver/src/lock/mod.rs @@ -2202,7 +2202,7 @@ impl Package { let wheels = self .wheels .iter() - .map(|wheel| wheel.to_registry_dist(source, workspace_root)) + .map(|wheel| wheel.to_registry_wheel(source, workspace_root)) .collect::>()?; let reg_built_dist = RegistryBuiltDist { wheels, @@ -4183,7 +4183,7 @@ impl Wheel { } } - pub(crate) fn to_registry_dist( + pub(crate) fn to_registry_wheel( &self, source: &RegistrySource, root: &Path, diff --git a/crates/uv/src/commands/pip/compile.rs b/crates/uv/src/commands/pip/compile.rs index e43c36894..fa6ac8855 100644 --- a/crates/uv/src/commands/pip/compile.rs +++ b/crates/uv/src/commands/pip/compile.rs @@ -171,6 +171,7 @@ pub(crate) async fn pip_compile( requirements, constraints, overrides, + pylock, source_trees, groups, extras: used_extras, @@ -189,6 +190,13 @@ pub(crate) async fn pip_compile( ) .await?; + // Reject `pylock.toml` files, which are valid outputs but not inputs. + if pylock.is_some() { + return Err(anyhow!( + "`pylock.toml` is not a supported input format for `uv pip compile`" + )); + } + let constraints = constraints .iter() .cloned() diff --git a/crates/uv/src/commands/pip/install.rs b/crates/uv/src/commands/pip/install.rs index da8ec6674..1933e901f 100644 --- a/crates/uv/src/commands/pip/install.rs +++ b/crates/uv/src/commands/pip/install.rs @@ -32,8 +32,8 @@ use uv_python::{ }; use uv_requirements::{RequirementsSource, RequirementsSpecification}; use uv_resolver::{ - DependencyMode, ExcludeNewer, FlatIndex, OptionsBuilder, PrereleaseMode, PythonRequirement, - ResolutionMode, ResolverEnvironment, + DependencyMode, ExcludeNewer, FlatIndex, OptionsBuilder, PrereleaseMode, PylockToml, + PythonRequirement, ResolutionMode, ResolverEnvironment, }; use uv_torch::{TorchMode, TorchStrategy}; use uv_types::{BuildIsolation, HashStrategy}; @@ -111,6 +111,7 @@ pub(crate) async fn pip_install( requirements, constraints, overrides, + pylock, source_trees, groups, index_url, @@ -130,6 +131,12 @@ pub(crate) async fn pip_install( ) .await?; + if pylock.is_some() { + if preview.is_disabled() { + warn_user!("The `--pylock` setting is experimental and may change without warning. Pass `--preview` to disable this warning."); + } + } + let constraints: Vec = constraints .iter() .cloned() @@ -245,6 +252,7 @@ pub(crate) async fn pip_install( if reinstall.is_none() && upgrade.is_none() && source_trees.is_empty() + && pylock.is_none() && matches!(modifications, Modifications::Sufficient) { match site_packages.satisfies_spec(&requirements, &constraints, &overrides, &marker_env)? { @@ -305,9 +313,6 @@ pub(crate) async fn pip_install( HashStrategy::None }; - // When resolving, don't take any external preferences into account. - let preferences = Vec::default(); - // Incorporate any index locations from the provided sources. let index_locations = index_locations.combine( extra_index_urls @@ -429,51 +434,69 @@ pub(crate) async fn pip_install( preview, ); - let options = OptionsBuilder::new() - .resolution_mode(resolution_mode) - .prerelease_mode(prerelease_mode) - .dependency_mode(dependency_mode) - .exclude_newer(exclude_newer) - .index_strategy(index_strategy) - .build_options(build_options.clone()) - .build(); + let (resolution, hasher) = if let Some(pylock) = pylock { + // Read the `pylock.toml` from disk, and deserialize it from TOML. + let install_path = std::path::absolute(&pylock)?; + let install_path = install_path.parent().unwrap(); + let content = fs_err::tokio::read_to_string(&pylock).await?; + let lock = toml::from_str::(&content)?; - // Resolve the requirements. - let resolution = match operations::resolve( - requirements, - constraints, - overrides, - source_trees, - project, - BTreeSet::default(), - extras, - &groups, - preferences, - site_packages.clone(), - &hasher, - &reinstall, - &upgrade, - Some(&tags), - ResolverEnvironment::specific(marker_env.clone()), - python_requirement, - Conflicts::empty(), - &client, - &flat_index, - state.index(), - &build_dispatch, - concurrency, - options, - Box::new(DefaultResolveLogger), - printer, - ) - .await - { - Ok(graph) => Resolution::from(graph), - Err(err) => { - return diagnostics::OperationDiagnostic::native_tls(network_settings.native_tls) - .report(err) - .map_or(Ok(ExitStatus::Failure), |err| Err(err.into())) - } + let resolution = lock.to_resolution(install_path, marker_env.markers(), &tags)?; + let hasher = HashStrategy::from_resolution(&resolution, HashCheckingMode::Verify)?; + + (resolution, hasher) + } else { + // When resolving, don't take any external preferences into account. + let preferences = Vec::default(); + + let options = OptionsBuilder::new() + .resolution_mode(resolution_mode) + .prerelease_mode(prerelease_mode) + .dependency_mode(dependency_mode) + .exclude_newer(exclude_newer) + .index_strategy(index_strategy) + .build_options(build_options.clone()) + .build(); + + // Resolve the requirements. + let resolution = match operations::resolve( + requirements, + constraints, + overrides, + source_trees, + project, + BTreeSet::default(), + extras, + &groups, + preferences, + site_packages.clone(), + &hasher, + &reinstall, + &upgrade, + Some(&tags), + ResolverEnvironment::specific(marker_env.clone()), + python_requirement, + Conflicts::empty(), + &client, + &flat_index, + state.index(), + &build_dispatch, + concurrency, + options, + Box::new(DefaultResolveLogger), + printer, + ) + .await + { + Ok(graph) => Resolution::from(graph), + Err(err) => { + return diagnostics::OperationDiagnostic::native_tls(network_settings.native_tls) + .report(err) + .map_or(Ok(ExitStatus::Failure), |err| Err(err.into())) + } + }; + + (resolution, hasher) }; // Sync the environment. @@ -502,7 +525,7 @@ pub(crate) async fn pip_install( ) .await { - Ok(_) => {} + Ok(..) => {} Err(err) => { return diagnostics::OperationDiagnostic::native_tls(network_settings.native_tls) .report(err) diff --git a/crates/uv/src/commands/pip/sync.rs b/crates/uv/src/commands/pip/sync.rs index d6cead51c..7ddda2692 100644 --- a/crates/uv/src/commands/pip/sync.rs +++ b/crates/uv/src/commands/pip/sync.rs @@ -27,8 +27,8 @@ use uv_python::{ }; use uv_requirements::{RequirementsSource, RequirementsSpecification}; use uv_resolver::{ - DependencyMode, ExcludeNewer, FlatIndex, OptionsBuilder, PrereleaseMode, PythonRequirement, - ResolutionMode, ResolverEnvironment, + DependencyMode, ExcludeNewer, FlatIndex, OptionsBuilder, PrereleaseMode, PylockToml, + PythonRequirement, ResolutionMode, ResolverEnvironment, }; use uv_torch::{TorchMode, TorchStrategy}; use uv_types::{BuildIsolation, HashStrategy}; @@ -103,6 +103,7 @@ pub(crate) async fn pip_sync( requirements, constraints, overrides, + pylock, source_trees, groups, index_url, @@ -122,13 +123,20 @@ pub(crate) async fn pip_sync( ) .await?; + if pylock.is_some() { + if preview.is_disabled() { + warn_user!("The `--pylock` setting is experimental and may change without warning. Pass `--preview` to disable this warning."); + } + } + // Read build constraints. let build_constraints = operations::read_constraints(build_constraints, &client_builder).await?; // Validate that the requirements are non-empty. if !allow_empty_requirements { - let num_requirements = requirements.len() + source_trees.len(); + let num_requirements = + requirements.len() + source_trees.len() + usize::from(pylock.is_some()); if num_requirements == 0 { writeln!(printer.stderr(), "No requirements found (hint: use `--allow-empty-requirements` to clear the environment)")?; return Ok(ExitStatus::Success); @@ -335,9 +343,6 @@ pub(crate) async fn pip_sync( // Initialize any shared state. let state = SharedState::default(); - // When resolving, don't take any external preferences into account. - let preferences = Vec::default(); - // Create a build dispatch. let build_dispatch = BuildDispatch::new( &client, @@ -364,50 +369,68 @@ pub(crate) async fn pip_sync( // Determine the set of installed packages. let site_packages = SitePackages::from_environment(&environment)?; - let options = OptionsBuilder::new() - .resolution_mode(resolution_mode) - .prerelease_mode(prerelease_mode) - .dependency_mode(dependency_mode) - .exclude_newer(exclude_newer) - .index_strategy(index_strategy) - .build_options(build_options.clone()) - .build(); + let (resolution, hasher) = if let Some(pylock) = pylock { + // Read the `pylock.toml` from disk, and deserialize it from TOML. + let install_path = std::path::absolute(&pylock)?; + let install_path = install_path.parent().unwrap(); + let content = fs_err::tokio::read_to_string(&pylock).await?; + let lock = toml::from_str::(&content)?; - let resolution = match operations::resolve( - requirements, - constraints, - overrides, - source_trees, - project, - BTreeSet::default(), - &extras, - &groups, - preferences, - site_packages.clone(), - &hasher, - &reinstall, - &upgrade, - Some(&tags), - ResolverEnvironment::specific(marker_env.clone()), - python_requirement, - Conflicts::empty(), - &client, - &flat_index, - state.index(), - &build_dispatch, - concurrency, - options, - Box::new(DefaultResolveLogger), - printer, - ) - .await - { - Ok(resolution) => Resolution::from(resolution), - Err(err) => { - return diagnostics::OperationDiagnostic::native_tls(network_settings.native_tls) - .report(err) - .map_or(Ok(ExitStatus::Failure), |err| Err(err.into())) - } + let resolution = lock.to_resolution(install_path, marker_env.markers(), &tags)?; + let hasher = HashStrategy::from_resolution(&resolution, HashCheckingMode::Verify)?; + + (resolution, hasher) + } else { + // When resolving, don't take any external preferences into account. + let preferences = Vec::default(); + + let options = OptionsBuilder::new() + .resolution_mode(resolution_mode) + .prerelease_mode(prerelease_mode) + .dependency_mode(dependency_mode) + .exclude_newer(exclude_newer) + .index_strategy(index_strategy) + .build_options(build_options.clone()) + .build(); + + let resolution = match operations::resolve( + requirements, + constraints, + overrides, + source_trees, + project, + BTreeSet::default(), + &extras, + &groups, + preferences, + site_packages.clone(), + &hasher, + &reinstall, + &upgrade, + Some(&tags), + ResolverEnvironment::specific(marker_env.clone()), + python_requirement, + Conflicts::empty(), + &client, + &flat_index, + state.index(), + &build_dispatch, + concurrency, + options, + Box::new(DefaultResolveLogger), + printer, + ) + .await + { + Ok(resolution) => Resolution::from(resolution), + Err(err) => { + return diagnostics::OperationDiagnostic::native_tls(network_settings.native_tls) + .report(err) + .map_or(Ok(ExitStatus::Failure), |err| Err(err.into())) + } + }; + + (resolution, hasher) }; // Sync the environment. diff --git a/crates/uv/tests/it/pip_install.rs b/crates/uv/tests/it/pip_install.rs index ce291a3da..ae2c9b96b 100644 --- a/crates/uv/tests/it/pip_install.rs +++ b/crates/uv/tests/it/pip_install.rs @@ -19,7 +19,8 @@ use wiremock::{ use crate::common::{self, decode_token}; use crate::common::{ - build_vendor_links_url, get_bin, uv_snapshot, venv_bin_path, venv_to_interpreter, TestContext, + build_vendor_links_url, download_to_disk, get_bin, uv_snapshot, venv_bin_path, + venv_to_interpreter, TestContext, }; use uv_fs::Simplified; use uv_static::EnvVars; @@ -10445,3 +10446,628 @@ fn change_layout_custom_directory() -> Result<()> { Ok(()) } + +#[test] +fn pep_751_install_registry_wheel() -> Result<()> { + let context = TestContext::new("3.12"); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str( + r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["iniconfig"] + "#, + )?; + + context + .export() + .arg("-o") + .arg("pylock.toml") + .assert() + .success(); + + uv_snapshot!(context.filters(), context.pip_install() + .arg("--preview") + .arg("-r") + .arg("pylock.toml"), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Prepared 1 package in [TIME] + Installed 1 package in [TIME] + + iniconfig==2.0.0 + " + ); + + uv_snapshot!(context.filters(), context.pip_install() + .arg("--preview") + .arg("-r") + .arg("pylock.toml"), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Audited 1 package in [TIME] + " + ); + + Ok(()) +} + +#[test] +fn pep_751_install_registry_sdist() -> Result<()> { + let context = TestContext::new("3.12").with_exclude_newer("2025-01-29T00:00:00Z"); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str( + r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["source-distribution"] + "#, + )?; + + context + .export() + .arg("-o") + .arg("pylock.toml") + .assert() + .success(); + + uv_snapshot!(context.filters(), context.pip_install() + .arg("--preview") + .arg("-r") + .arg("pylock.toml"), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Prepared 1 package in [TIME] + Installed 1 package in [TIME] + + source-distribution==0.0.3 + " + ); + + uv_snapshot!(context.filters(), context.pip_install() + .arg("--preview") + .arg("-r") + .arg("pylock.toml"), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Audited 1 package in [TIME] + " + ); + + Ok(()) +} + +#[test] +fn pep_751_install_directory() -> Result<()> { + let context = TestContext::new("3.12"); + + // Create a local dependency in a subdirectory. + let pyproject_toml = context.temp_dir.child("foo").child("pyproject.toml"); + pyproject_toml.write_str( + r#" + [project] + name = "foo" + version = "1.0.0" + dependencies = ["anyio"] + + [build-system] + requires = ["hatchling"] + build-backend = "hatchling.build" + "#, + )?; + context + .temp_dir + .child("foo") + .child("src") + .child("foo") + .child("__init__.py") + .touch()?; + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str( + r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["foo"] + + [tool.uv.sources] + foo = { path = "foo" } + "#, + )?; + + context + .export() + .arg("-o") + .arg("pylock.toml") + .assert() + .success(); + + uv_snapshot!(context.filters(), context.pip_install() + .arg("--preview") + .arg("-r") + .arg("pylock.toml"), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Prepared 4 packages in [TIME] + Installed 4 packages in [TIME] + + anyio==4.3.0 + + foo==1.0.0 (from file://[TEMP_DIR]/foo) + + idna==3.6 + + sniffio==1.3.1 + " + ); + + uv_snapshot!(context.filters(), context.pip_install() + .arg("--preview") + .arg("-r") + .arg("pylock.toml"), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Audited 4 packages in [TIME] + " + ); + + Ok(()) +} + +#[test] +#[cfg(feature = "git")] +fn pep_751_install_git() -> Result<()> { + let context = TestContext::new("3.12"); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str( + r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["uv-public-pypackage @ git+https://github.com/astral-test/uv-public-pypackage.git@0.0.1"] + "#, + )?; + + context + .export() + .arg("-o") + .arg("pylock.toml") + .assert() + .success(); + + uv_snapshot!(context.filters(), context.pip_install() + .arg("--preview") + .arg("-r") + .arg("pylock.toml"), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Prepared 1 package in [TIME] + Installed 1 package in [TIME] + + uv-public-pypackage==0.1.0 (from git+https://github.com/astral-test/uv-public-pypackage.git@0dacfd662c64cb4ceb16e6cf65a157a8b715b979) + " + ); + + uv_snapshot!(context.filters(), context.pip_install() + .arg("--preview") + .arg("-r") + .arg("pylock.toml"), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Audited 1 package in [TIME] + " + ); + + Ok(()) +} + +#[test] +fn pep_751_install_url_wheel() -> Result<()> { + let context = TestContext::new("3.12"); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str( + r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["anyio @ https://files.pythonhosted.org/packages/14/fd/2f20c40b45e4fb4324834aea24bd4afdf1143390242c0b33774da0e2e34f/anyio-4.3.0-py3-none-any.whl"] + "#, + )?; + + context + .export() + .arg("-o") + .arg("pylock.toml") + .assert() + .success(); + + uv_snapshot!(context.filters(), context.pip_install() + .arg("--preview") + .arg("-r") + .arg("pylock.toml"), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Prepared 2 packages in [TIME] + Installed 3 packages in [TIME] + + anyio==4.3.0 (from https://files.pythonhosted.org/packages/14/fd/2f20c40b45e4fb4324834aea24bd4afdf1143390242c0b33774da0e2e34f/anyio-4.3.0-py3-none-any.whl) + + idna==3.6 + + sniffio==1.3.1 + " + ); + + uv_snapshot!(context.filters(), context.pip_install() + .arg("--preview") + .arg("-r") + .arg("pylock.toml"), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Audited 3 packages in [TIME] + " + ); + + Ok(()) +} + +#[test] +fn pep_751_install_url_sdist() -> Result<()> { + let context = TestContext::new("3.12"); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str( + r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["anyio @ https://files.pythonhosted.org/packages/db/4d/3970183622f0330d3c23d9b8a5f52e365e50381fd484d08e3285104333d3/anyio-4.3.0.tar.gz"] + "#, + )?; + + context + .export() + .arg("-o") + .arg("pylock.toml") + .assert() + .success(); + + uv_snapshot!(context.filters(), context.pip_install() + .arg("--preview") + .arg("-r") + .arg("pylock.toml"), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Prepared 3 packages in [TIME] + Installed 3 packages in [TIME] + + anyio==4.3.0 (from https://files.pythonhosted.org/packages/db/4d/3970183622f0330d3c23d9b8a5f52e365e50381fd484d08e3285104333d3/anyio-4.3.0.tar.gz) + + idna==3.6 + + sniffio==1.3.1 + " + ); + + uv_snapshot!(context.filters(), context.pip_install() + .arg("--preview") + .arg("-r") + .arg("pylock.toml"), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Audited 3 packages in [TIME] + " + ); + + Ok(()) +} + +#[test] +fn pep_751_install_path_wheel() -> Result<()> { + let context = TestContext::new("3.12"); + + // Download the source. + let archive = context.temp_dir.child("iniconfig-2.0.0-py3-none-any.whl"); + download_to_disk( + "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl", + &archive, + ); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str( + r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["iniconfig"] + + [tool.uv.sources] + iniconfig = { path = "iniconfig-2.0.0-py3-none-any.whl" } + "#, + )?; + + context + .export() + .arg("-o") + .arg("pylock.toml") + .assert() + .success(); + + let lock = context.read("pylock.toml"); + + insta::with_settings!({ + filters => context.filters(), + }, { + insta::assert_snapshot!( + lock, @r##" + # This file was autogenerated by uv via the following command: + # uv export --cache-dir [CACHE_DIR] -o pylock.toml + lock-version = "1.0" + created-by = "uv" + requires-python = ">=3.12" + + [[packages]] + name = "iniconfig" + version = "2.0.0" + archive = { path = "iniconfig-2.0.0-py3-none-any.whl", hashes = { sha256 = "b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374" } } + "## + ); + }); + + uv_snapshot!(context.filters(), context.pip_install() + .arg("--preview") + .arg("-r") + .arg("pylock.toml"), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Installed 1 package in [TIME] + + iniconfig==2.0.0 (from file://[TEMP_DIR]/iniconfig-2.0.0-py3-none-any.whl) + " + ); + + uv_snapshot!(context.filters(), context.pip_install() + .arg("--preview") + .arg("-r") + .arg("pylock.toml"), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Audited 1 package in [TIME] + " + ); + + Ok(()) +} + +#[test] +fn pep_751_install_path_sdist() -> Result<()> { + let context = TestContext::new("3.12"); + + // Download the source. + let archive = context.temp_dir.child("iniconfig-2.0.0.tar.gz"); + download_to_disk( + "https://files.pythonhosted.org/packages/d7/4b/cbd8e699e64a6f16ca3a8220661b5f83792b3017d0f79807cb8708d33913/iniconfig-2.0.0.tar.gz", + &archive, + ); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str( + r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["iniconfig"] + + [tool.uv.sources] + iniconfig = { path = "iniconfig-2.0.0.tar.gz" } + "#, + )?; + + context + .export() + .arg("-o") + .arg("pylock.toml") + .assert() + .success(); + + uv_snapshot!(context.filters(), context.pip_install() + .arg("--preview") + .arg("-r") + .arg("pylock.toml"), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Prepared 1 package in [TIME] + Installed 1 package in [TIME] + + iniconfig==2.0.0 (from file://[TEMP_DIR]/iniconfig-2.0.0.tar.gz) + " + ); + + uv_snapshot!(context.filters(), context.pip_install() + .arg("--preview") + .arg("-r") + .arg("pylock.toml"), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Audited 1 package in [TIME] + " + ); + + Ok(()) +} + +#[test] +fn pep_751_hash_mismatch() -> Result<()> { + let context = TestContext::new("3.12"); + + // Download the source. + let archive = context.temp_dir.child("iniconfig-2.0.0-py3-none-any.whl"); + download_to_disk( + "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl", + &archive, + ); + + let pylock_toml = context.temp_dir.child("pylock.toml"); + pylock_toml.write_str(r#" + # This file was autogenerated by uv via the following command: + # uv export --cache-dir [CACHE_DIR] -o pylock.toml + lock-version = "1.0" + created-by = "uv" + requires-python = ">=3.12" + + [[packages]] + name = "iniconfig" + version = "2.0.0" + archive = { path = "iniconfig-2.0.0-py3-none-any.whl", hashes = { sha256 = "c5185871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374" } } + "#)?; + + uv_snapshot!(context.filters(), context.pip_install() + .arg("--preview") + .arg("-r") + .arg("pylock.toml"), @r" + success: false + exit_code: 1 + ----- stdout ----- + + ----- stderr ----- + × Failed to read `iniconfig @ file://[TEMP_DIR]/iniconfig-2.0.0-py3-none-any.whl` + ╰─▶ Hash mismatch for `iniconfig @ file://[TEMP_DIR]/iniconfig-2.0.0-py3-none-any.whl` + + Expected: + sha256:c5185871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374 + + Computed: + sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374 + " + ); + + Ok(()) +} + +#[test] +fn pep_751_mix() -> Result<()> { + let context = TestContext::new("3.12"); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str( + r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["iniconfig"] + "#, + )?; + + context + .export() + .arg("-o") + .arg("pylock.toml") + .assert() + .success(); + + context + .export() + .arg("-o") + .arg("pylock.dev.toml") + .assert() + .success(); + + context.temp_dir.child("requirements.txt").touch()?; + context.temp_dir.child("constraints.txt").touch()?; + + uv_snapshot!(context.filters(), context.pip_install() + .arg("--preview") + .arg("-r") + .arg("pylock.toml") + .arg("-r") + .arg("pylock.dev.toml"), @r" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: Multiple `pylock.toml` files specified: `pylock.toml` vs. `pylock.dev.toml` + " + ); + + uv_snapshot!(context.filters(), context.pip_install() + .arg("--preview") + .arg("-r") + .arg("pylock.toml") + .arg("-r") + .arg("requirements.txt"), @r" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: Cannot specify additional requirements alongside a `pylock.toml` file + " + ); + + uv_snapshot!(context.filters(), context.pip_install() + .arg("--preview") + .arg("-r") + .arg("pylock.toml") + .arg("-c") + .arg("constraints.txt"), @r" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: Cannot specify additional requirements with a `pylock.toml` file + " + ); + + Ok(()) +} diff --git a/crates/uv/tests/it/pip_sync.rs b/crates/uv/tests/it/pip_sync.rs index ca8326fad..905ad4ce8 100644 --- a/crates/uv/tests/it/pip_sync.rs +++ b/crates/uv/tests/it/pip_sync.rs @@ -5733,3 +5733,92 @@ fn semicolon_no_space() -> Result<()> { Ok(()) } + +#[test] +fn pep_751() -> Result<()> { + let context = TestContext::new("3.12"); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str( + r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["anyio"] + "#, + )?; + + context + .export() + .arg("-o") + .arg("pylock.toml") + .assert() + .success(); + + uv_snapshot!(context.filters(), context.pip_sync() + .arg("--preview") + .arg("pylock.toml"), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Prepared 3 packages in [TIME] + Installed 3 packages in [TIME] + + anyio==4.3.0 + + idna==3.6 + + sniffio==1.3.1 + " + ); + + uv_snapshot!(context.filters(), context.pip_sync() + .arg("--preview") + .arg("pylock.toml"), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Audited 3 packages in [TIME] + " + ); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str( + r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["iniconfig"] + "#, + )?; + + context + .export() + .arg("-o") + .arg("pylock.toml") + .assert() + .success(); + + uv_snapshot!(context.filters(), context.pip_sync() + .arg("--preview") + .arg("pylock.toml"), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Prepared 1 package in [TIME] + Uninstalled 3 packages in [TIME] + Installed 1 package in [TIME] + - anyio==4.3.0 + - idna==3.6 + + iniconfig==2.0.0 + - sniffio==1.3.1 + " + ); + + Ok(()) +}