Remove xz support entirely (#19143)

Signed-off-by: William Woodruff <william@astral.sh>
This commit is contained in:
William Woodruff
2026-06-01 09:21:15 -04:00
committed by William Woodruff
parent 6abcbf3bb9
commit 73c9b21128
24 changed files with 475 additions and 213 deletions
Generated
-24
View File
@@ -370,7 +370,6 @@ dependencies = [
"memchr",
"pin-project-lite",
"tokio",
"xz2",
"zstd",
"zstd-safe",
]
@@ -2622,17 +2621,6 @@ version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
[[package]]
name = "lzma-sys"
version = "0.1.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5fda04ab3764e6cde78b9974eec4f779acaba7c4e84b36eca3cf77c581b85d27"
dependencies = [
"cc",
"libc",
"pkg-config",
]
[[package]]
name = "mailparse"
version = "0.16.1"
@@ -6364,7 +6352,6 @@ dependencies = [
"uv-python",
"uv-redacted",
"uv-types",
"uv-warnings",
"uv-workspace",
"walkdir",
]
@@ -6467,8 +6454,6 @@ dependencies = [
"uv-distribution-filename",
"uv-pypi-types",
"uv-static",
"uv-warnings",
"xz2",
]
[[package]]
@@ -8118,15 +8103,6 @@ version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9"
[[package]]
name = "xz2"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "388c44dc09d76f1536602ead6d325eb532f5c122f17782bd57fb47baeeb767e2"
dependencies = [
"lzma-sys",
]
[[package]]
name = "yansi"
version = "1.0.1"
-2
View File
@@ -104,7 +104,6 @@ async-channel = { version = "2.3.1" }
async-compression = { version = "0.4.12", features = [
"bzip2",
"gzip",
"xz",
"zstd",
] }
async-trait = { version = "0.1.82" }
@@ -312,7 +311,6 @@ windows = { version = "0.61.0", features = [
windows-registry = { version = "0.5.0" }
windows-version = { version = "0.1.6" }
wiremock = { version = "0.6.4" }
xz2 = { version = "0.1.7", features = ["static"] }
zeroize = { version = "1.8.1" }
# dev-dependencies
+2 -2
View File
@@ -56,5 +56,5 @@ tempfile = { workspace = true }
tokio = { workspace = true }
tokio-util = { workspace = true }
[features]
static = ["uv-extract/static"]
[package.metadata.cargo-shear]
ignored = ["uv-extract"]
+2 -1
View File
@@ -21,6 +21,7 @@ use tokio::io::{AsyncRead, ReadBuf};
use tokio_util::compat::FuturesAsyncReadCompatExt;
use url::Url;
use uv_client::retryable_on_request_failure;
use uv_distribution_filename::LegacySourceDistExtension;
use uv_distribution_filename::SourceDistExtension;
use uv_static::{astral_mirror_base_url, astral_mirror_url_from_env, custom_astral_mirror_url};
@@ -230,7 +231,7 @@ impl ArchiveFormat {
impl From<ArchiveFormat> for SourceDistExtension {
fn from(val: ArchiveFormat) -> Self {
match val {
ArchiveFormat::Zip => Self::Zip,
ArchiveFormat::Zip => Self::Legacy(LegacySourceDistExtension::Zip),
ArchiveFormat::TarGz => Self::TarGz,
}
}
+1 -1
View File
@@ -79,7 +79,7 @@ name = "uv-dev"
required-features = ["dev"]
[features]
default = ["performance", "uv-extract/static"]
default = ["performance"]
# Actually build the dev CLI.
dev = []
performance = ["performance-memory-allocator"]
@@ -27,9 +27,29 @@ pub enum DistExtension {
)]
#[rkyv(derive(Debug))]
pub enum SourceDistExtension {
TarGz,
Legacy(LegacySourceDistExtension),
}
#[derive(
Clone,
Copy,
Debug,
PartialEq,
Eq,
PartialOrd,
Ord,
Hash,
Serialize,
Deserialize,
rkyv::Archive,
rkyv::Deserialize,
rkyv::Serialize,
)]
#[rkyv(derive(Debug))]
pub enum LegacySourceDistExtension {
Tar,
TarBz2,
TarGz,
TarLz,
TarLzma,
TarXz,
@@ -83,20 +103,17 @@ impl SourceDistExtension {
match extension {
"gz" if is_tar(path.as_ref()) => Ok(Self::TarGz),
// TODO: Remove these other extensions in the future.
// NOTE: These get parsed from network sources like PyPI, so we don't
// necessarily want to hard-fail, just skip in the future.
"zip" => Ok(Self::Zip),
"tar" => Ok(Self::Tar),
"tgz" => Ok(Self::Tgz),
"tbz" => Ok(Self::Tbz),
"txz" => Ok(Self::Txz),
"tlz" => Ok(Self::Tlz),
"bz2" if is_tar(path.as_ref()) => Ok(Self::TarBz2),
"xz" if is_tar(path.as_ref()) => Ok(Self::TarXz),
"lz" if is_tar(path.as_ref()) => Ok(Self::TarLz),
"lzma" if is_tar(path.as_ref()) => Ok(Self::TarLzma),
"zst" if is_tar(path.as_ref()) => Ok(Self::TarZst),
"zip" => Ok(Self::Legacy(LegacySourceDistExtension::Zip)),
"tar" => Ok(Self::Legacy(LegacySourceDistExtension::Tar)),
"tgz" => Ok(Self::Legacy(LegacySourceDistExtension::Tgz)),
"tbz" => Ok(Self::Legacy(LegacySourceDistExtension::Tbz)),
"txz" => Ok(Self::Legacy(LegacySourceDistExtension::Txz)),
"tlz" => Ok(Self::Legacy(LegacySourceDistExtension::Tlz)),
"bz2" if is_tar(path.as_ref()) => Ok(Self::Legacy(LegacySourceDistExtension::TarBz2)),
"xz" if is_tar(path.as_ref()) => Ok(Self::Legacy(LegacySourceDistExtension::TarXz)),
"lz" if is_tar(path.as_ref()) => Ok(Self::Legacy(LegacySourceDistExtension::TarLz)),
"lzma" if is_tar(path.as_ref()) => Ok(Self::Legacy(LegacySourceDistExtension::TarLzma)),
"zst" if is_tar(path.as_ref()) => Ok(Self::Legacy(LegacySourceDistExtension::TarZst)),
_ => Err(ExtensionError::SourceDist),
}
}
@@ -104,20 +121,31 @@ impl SourceDistExtension {
/// Return the name for the extension.
pub(crate) fn name(self) -> &'static str {
match self {
Self::Tar => "tar",
Self::TarBz2 => "tar.bz2",
Self::TarGz => "tar.gz",
Self::TarLz => "tar.lz",
Self::TarLzma => "tar.lzma",
Self::TarXz => "tar.xz",
Self::TarZst => "tar.zst",
Self::Tbz => "tbz",
Self::Tgz => "tgz",
Self::Tlz => "tlz",
Self::Txz => "txz",
Self::Zip => "zip",
Self::Legacy(LegacySourceDistExtension::Tar) => "tar",
Self::Legacy(LegacySourceDistExtension::TarBz2) => "tar.bz2",
Self::Legacy(LegacySourceDistExtension::TarLz) => "tar.lz",
Self::Legacy(LegacySourceDistExtension::TarLzma) => "tar.lzma",
Self::Legacy(LegacySourceDistExtension::TarXz) => "tar.xz",
Self::Legacy(LegacySourceDistExtension::TarZst) => "tar.zst",
Self::Legacy(LegacySourceDistExtension::Tbz) => "tbz",
Self::Legacy(LegacySourceDistExtension::Tgz) => "tgz",
Self::Legacy(LegacySourceDistExtension::Tlz) => "tlz",
Self::Legacy(LegacySourceDistExtension::Txz) => "txz",
Self::Legacy(LegacySourceDistExtension::Zip) => "zip",
}
}
/// Returns `true` if the extension conforms to [PEP 625](https://peps.python.org/pep-0625/)'s
/// naming requirements.
///
/// PEP 625 mandates `.tar.gz`; `.zip` is also accepted for backwards compatibility.
pub fn is_pep625_compliant(&self) -> bool {
matches!(
self,
Self::TarGz | Self::Legacy(LegacySourceDistExtension::Zip)
)
}
}
impl Display for SourceDistExtension {
+3 -1
View File
@@ -7,7 +7,9 @@ use uv_pep440::Version;
pub use build_tag::{BuildTag, BuildTagError};
pub use egg::{EggInfoFilename, EggInfoFilenameError};
pub use expanded_tags::{ExpandedTagError, ExpandedTags};
pub use extension::{DistExtension, ExtensionError, SourceDistExtension};
pub use extension::{
DistExtension, ExtensionError, LegacySourceDistExtension, SourceDistExtension,
};
pub use source_dist::{SourceDistFilename, SourceDistFilenameError};
pub use wheel::{WheelFilename, WheelFilenameError};
@@ -199,7 +199,7 @@ mod tests {
use uv_normalize::PackageName;
use crate::{SourceDistExtension, SourceDistFilename};
use crate::{SourceDistExtension, SourceDistFilename, extension::LegacySourceDistExtension};
/// Only test already normalized names since the parsing is lossy
///
@@ -281,7 +281,7 @@ mod tests {
assert!(
SourceDistFilename::parse(
"foo.zip",
SourceDistExtension::Zip,
SourceDistExtension::Legacy(LegacySourceDistExtension::Zip),
&PackageName::from_str("foo-lib").unwrap()
)
.is_err()
@@ -24,4 +24,9 @@ pub enum Error {
#[error("Requested package name `{0}` does not match `{1}` in the distribution filename: {2}")]
PackageNameMismatch(PackageName, PackageName, String),
#[error(
"Source distribution `{0}` has a non-PEP 625-compliant filename; only `.tar.gz` and `.zip` archives are accepted"
)]
NotPep625Filename(String),
}
+7
View File
@@ -409,6 +409,9 @@ impl Dist {
})))
}
DistExtension::Source(ext) => {
if !ext.is_pep625_compliant() {
return Err(Error::NotPep625Filename(url.verbatim().to_string()));
}
Ok(Self::Source(SourceDist::DirectUrl(DirectUrlSourceDist {
name,
location: Box::new(location),
@@ -461,6 +464,10 @@ impl Dist {
})))
}
DistExtension::Source(ext) => {
if !ext.is_pep625_compliant() {
return Err(Error::NotPep625Filename(url.verbatim().to_string()));
}
// If there is a version in the filename, record it.
let version = url
.filename()
@@ -144,6 +144,7 @@ impl IncompatibleDist {
IncompatibleSource::RequiresPython(..) => {
format!("requires {self}")
}
IncompatibleSource::NotPep625Filename => format!("has {self}"),
},
Self::Unavailable => format!("has {self}"),
}
@@ -172,6 +173,7 @@ impl IncompatibleDist {
IncompatibleSource::RequiresPython(..) => {
format!("require {self}")
}
IncompatibleSource::NotPep625Filename => format!("have {self}"),
},
Self::Unavailable => format!("have {self}"),
}
@@ -277,6 +279,9 @@ impl Display for IncompatibleDist {
IncompatibleSource::RequiresPython(python, _) => {
write!(f, "Python {python}")
}
IncompatibleSource::NotPep625Filename => {
f.write_str("a non-PEP 625-compliant source distribution filename")
}
},
Self::Unavailable => f.write_str("no available distributions"),
}
@@ -327,6 +332,8 @@ pub enum IncompatibleSource {
RequiresPython(VersionSpecifiers, PythonRequirementKind),
Yanked(Yanked),
NoBuild,
/// The source distribution's filename does not confirm to PEP 625.
NotPep625Filename,
}
#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord)]
@@ -737,21 +744,26 @@ impl IncompatibleSource {
Self::ExcludeNewer(timestamp_self) => match other {
// Smaller timestamps are closer to the cut-off time
Self::ExcludeNewer(timestamp_other) => timestamp_other < timestamp_self,
Self::NoBuild | Self::RequiresPython(_, _) | Self::Yanked(_) => true,
Self::NoBuild
| Self::RequiresPython(_, _)
| Self::Yanked(_)
| Self::NotPep625Filename => true,
},
Self::RequiresPython(_, _) => match other {
Self::ExcludeNewer(_) => false,
// Version specifiers cannot be reasonably compared
Self::RequiresPython(_, _) => false,
Self::NoBuild | Self::Yanked(_) => true,
Self::NoBuild | Self::Yanked(_) | Self::NotPep625Filename => true,
},
Self::Yanked(_) => match other {
Self::ExcludeNewer(_) | Self::RequiresPython(_, _) => false,
Self::ExcludeNewer(_) | Self::RequiresPython(_, _) | Self::NotPep625Filename => {
false
}
// Yanks with a reason are more helpful for errors
Self::Yanked(yanked_other) => matches!(yanked_other, Yanked::Reason(_)),
Self::NoBuild => true,
},
Self::NoBuild => false,
Self::NoBuild | Self::NotPep625Filename => false,
}
}
}
-2
View File
@@ -40,7 +40,6 @@ uv-pypi-types = { workspace = true }
uv-python = { workspace = true }
uv-redacted = { workspace = true }
uv-types = { workspace = true }
uv-warnings = { workspace = true }
uv-workspace = { workspace = true }
anyhow = { workspace = true }
@@ -68,4 +67,3 @@ insta = { workspace = true }
[features]
default = []
static = ["uv-extract/static"]
@@ -17,7 +17,7 @@ use uv_cache_info::{CacheInfo, Timestamp};
use uv_client::{
CacheControl, CachedClientError, Connectivity, DataWithCachePolicy, RegistryClient,
};
use uv_distribution_filename::{SourceDistExtension, WheelFilename};
use uv_distribution_filename::WheelFilename;
use uv_distribution_types::{
BuildInfo, BuildableSource, BuiltDist, Dist, DistRef, File, HashPolicy, Hashed, IndexUrl,
InstalledDist, Name, SourceDist, ToUrlError,
@@ -31,7 +31,6 @@ use uv_pypi_types::{HashDigest, HashDigests, PyProjectToml};
use uv_python::PythonVariant;
use uv_redacted::DisplaySafeUrl;
use uv_types::{BuildContext, BuildStack};
use uv_warnings::warn_user_once;
use crate::archive::Archive;
use crate::error::PythonVersion;
@@ -437,34 +436,6 @@ impl<'a, Context: BuildContext> DistributionDatabase<'a, Context> {
tags: &Tags,
hashes: HashPolicy<'_>,
) -> Result<LocalWheel, Error> {
// Warn if the source distribution isn't PEP 625 compliant.
// We do this here instead of in `SourceDistExtension::from_path` to minimize log volume:
// a non-compliant distribution isn't a huge problem if it's not actually being
// materialized into a wheel. Observe that we also allow no extension, since we expect that
// for directory and Git installs.
// NOTE: Observe that we also allow `.zip` sdists here, which are not PEP 625 compliant.
// This is because they were allowed on PyPI until relatively recently (2020).
if let Some(extension) = dist.extension()
&& !matches!(
extension,
SourceDistExtension::TarGz | SourceDistExtension::Zip
)
{
if matches!(dist, SourceDist::Registry(_)) {
// Observe that we display a slightly different warning when the sdist comes
// from a registry, since that suggests that the user has inadvertently
// (rather than explicitly) depended on a non-compliant sdist.
warn_user_once!(
"{dist} uses a legacy source distribution format ('.{extension}') that is not compliant with PEP 625. A future version of uv will reject this source distribution. Consider upgrading to a newer version of {package}",
package = dist.name(),
);
} else {
warn_user_once!(
"{dist} is not a standards-compliant source distribution: expected '.tar.gz' but found '.{extension}'. A future version of uv will reject source distributions that do not meet the requirements specified in PEP 625",
);
}
}
let built_wheel = self
.builder
.download_and_build(&BuildableSource::Dist(dist), tags, hashes, &self.client)
+2 -5
View File
@@ -22,7 +22,7 @@ uv-pypi-types = { workspace = true }
uv-static = { workspace = true }
astral-tokio-tar = { workspace = true }
async-compression = { workspace = true, features = ["bzip2", "gzip", "zstd", "xz"] }
async-compression = { workspace = true, features = ["bzip2", "gzip", "zstd"] }
async_zip = { workspace = true }
blake2 = { workspace = true }
flate2 = { workspace = true }
@@ -38,15 +38,12 @@ thiserror = { workspace = true }
tokio = { workspace = true }
tokio-util = { workspace = true, features = ["compat"] }
tracing = { workspace = true }
xz2 = { workspace = true }
[features]
default = []
# Avoid a liblzma.so dependency
static = ["xz2/static"]
[package.metadata.cargo-shear]
# NOTE: `async-compression` owns the actual Deflate codec, but we need to
# explicitly declare `flate2` here so the workspace's `zlib-rs` backend stays
# selected after removing the synchronous `zip` dependency.
ignored = ["flate2", "xz2"]
ignored = ["flate2"]
+13 -36
View File
@@ -8,7 +8,7 @@ use rustc_hash::{FxHashMap, FxHashSet};
use tokio_util::compat::{FuturesAsyncReadCompatExt, TokioAsyncReadCompatExt};
use tracing::{debug, warn};
use uv_distribution_filename::SourceDistExtension;
use uv_distribution_filename::{LegacySourceDistExtension, SourceDistExtension};
use crate::{Error, insecure_no_validate, validate_archive_member_name};
@@ -713,30 +713,6 @@ pub async fn untar_zst<R: tokio::io::AsyncRead + Unpin>(
.map_err(Error::io_or_compression)
}
/// Unpack a `.tar.xz` archive into the target directory, without requiring `Seek`.
///
/// This is useful for unpacking files as they're being downloaded.
///
/// Returns the list of unpacked files and their sizes.
async fn untar_xz<R: tokio::io::AsyncRead + Unpin>(
reader: R,
target: impl AsRef<Path>,
) -> Result<Vec<(PathBuf, u64)>, Error> {
let reader = tokio::io::BufReader::with_capacity(DEFAULT_BUF_SIZE, reader);
let mut decompressed_bytes = async_compression::tokio::bufread::XzDecoder::new(reader);
let archive = tokio_tar::ArchiveBuilder::new(
&mut decompressed_bytes as &mut (dyn tokio::io::AsyncRead + Unpin),
)
.set_preserve_mtime(false)
.set_preserve_permissions(false)
.set_allow_external_symlinks(false)
.build();
untar_in(archive, target.as_ref())
.await
.map_err(Error::io_or_compression)
}
/// Unpack a `.tar` archive into the target directory, without requiring `Seek`.
///
/// This is useful for unpacking files as they're being downloaded.
@@ -759,7 +735,7 @@ async fn untar<R: tokio::io::AsyncRead + Unpin>(
.map_err(Error::io_or_compression)
}
/// Unpack a `.zip`, `.tar.gz`, `.tar.bz2`, `.tar.zst`, or `.tar.xz` archive into the target directory,
/// Unpack a `.zip`, `.tar.gz`, `.tar.bz2`, or `.tar.zst` archive into the target directory,
/// without requiring `Seek`.
///
/// Returns the list of unpacked files and their sizes.
@@ -769,15 +745,16 @@ pub async fn archive<R: tokio::io::AsyncRead + Unpin>(
target: impl AsRef<Path>,
) -> Result<Vec<(PathBuf, u64)>, Error> {
match ext {
SourceDistExtension::Zip => unzip(reader, target).await,
SourceDistExtension::Tar => untar(reader, target).await,
SourceDistExtension::Tgz | SourceDistExtension::TarGz => untar_gz(reader, target).await,
SourceDistExtension::Tbz | SourceDistExtension::TarBz2 => untar_bz2(reader, target).await,
SourceDistExtension::Txz
| SourceDistExtension::TarXz
| SourceDistExtension::Tlz
| SourceDistExtension::TarLz
| SourceDistExtension::TarLzma => untar_xz(reader, target).await,
SourceDistExtension::TarZst => untar_zst(reader, target).await,
SourceDistExtension::Legacy(LegacySourceDistExtension::Zip) => unzip(reader, target).await,
SourceDistExtension::Legacy(LegacySourceDistExtension::Tar) => untar(reader, target).await,
SourceDistExtension::Legacy(LegacySourceDistExtension::Tgz)
| SourceDistExtension::TarGz => untar_gz(reader, target).await,
SourceDistExtension::Legacy(
LegacySourceDistExtension::Tbz | LegacySourceDistExtension::TarBz2,
) => untar_bz2(reader, target).await,
SourceDistExtension::Legacy(LegacySourceDistExtension::TarZst) => {
untar_zst(reader, target).await
}
SourceDistExtension::Legacy(_) => Err(Error::UnsupportedCompression),
}
}
+56 -41
View File
@@ -150,56 +150,69 @@ impl<T: Pep508Url> Requirement<T> {
/// Returns a [`Display`] implementation that doesn't mask credentials.
pub fn displayable_with_credentials(&self) -> impl Display {
std::fmt::from_fn(|f| fmt_requirement(self, f, true))
RequirementDisplay {
requirement: self,
display_credentials: true,
}
}
}
impl<T: Pep508Url + Display> Display for Requirement<T> {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
fmt_requirement(self, f, false)
RequirementDisplay {
requirement: self,
display_credentials: false,
}
.fmt(f)
}
}
fn fmt_requirement<T: Pep508Url + Display>(
requirement: &Requirement<T>,
f: &mut Formatter<'_>,
struct RequirementDisplay<'a, T>
where
T: Pep508Url + Display,
{
requirement: &'a Requirement<T>,
display_credentials: bool,
) -> std::fmt::Result {
write!(f, "{}", requirement.name)?;
if !requirement.extras.is_empty() {
write!(
f,
"[{}]",
requirement
.extras
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join(",")
)?;
}
if let Some(version_or_url) = &requirement.version_or_url {
match version_or_url {
VersionOrUrl::VersionSpecifier(version_specifier) => {
let version_specifier: Vec<String> =
version_specifier.iter().map(ToString::to_string).collect();
write!(f, "{}", version_specifier.join(","))?;
}
VersionOrUrl::Url(url) => {
let url_string = if display_credentials {
url.displayable_with_credentials().to_string()
} else {
url.to_string()
};
// We add the space for markers later if necessary
write!(f, " @ {url_string}")?;
}
impl<T: Pep508Url + Display> Display for RequirementDisplay<'_, T> {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.requirement.name)?;
if !self.requirement.extras.is_empty() {
write!(
f,
"[{}]",
self.requirement
.extras
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join(",")
)?;
}
if let Some(version_or_url) = &self.requirement.version_or_url {
match version_or_url {
VersionOrUrl::VersionSpecifier(version_specifier) => {
let version_specifier: Vec<String> =
version_specifier.iter().map(ToString::to_string).collect();
write!(f, "{}", version_specifier.join(","))?;
}
VersionOrUrl::Url(url) => {
let url_string = if self.display_credentials {
url.displayable_with_credentials().to_string()
} else {
url.to_string()
};
// We add the space for markers later if necessary
write!(f, " @ {url_string}")?;
}
}
}
if let Some(marker) = self.requirement.marker.contents() {
write!(f, " ; {marker}")?;
}
Ok(())
}
if let Some(marker) = requirement.marker.contents() {
write!(f, " ; {marker}")?;
}
Ok(())
}
/// <https://github.com/serde-rs/serde/issues/908#issuecomment-298027413>
@@ -554,7 +567,10 @@ fn looks_like_unnamed_requirement(cursor: &mut Cursor) -> bool {
/// Returns `true` if a file looks like an archive.
///
/// See <https://github.com/pypa/pip/blob/111eed14b6e9fba7c78a5ec2b7594812d17b5d2b/src/pip/_internal/utils/filetypes.py#L8>
/// for the list of supported archive extensions.
/// for the original list of supported archive extensions.
///
/// NOTE: Unlike pip, we don't allow xz or lzma archives here.
/// Longer term, we will also probably disallow bzip2 archives.
fn looks_like_archive(file: impl AsRef<Path>) -> bool {
let file = file.as_ref();
@@ -570,8 +586,7 @@ fn looks_like_archive(file: impl AsRef<Path>) -> bool {
matches!(
(pre_extension, extension),
(_, "whl" | "tbz" | "txz" | "tlz" | "zip" | "tgz" | "tar")
| (Some("tar"), "bz2" | "xz" | "lz" | "lzma" | "gz")
(_, "whl" | "tbz" | "tlz" | "zip" | "tgz" | "tar") | (Some("tar"), "bz2" | "gz")
)
}
+8
View File
@@ -164,6 +164,14 @@ impl FlatDistributions {
return SourceDistCompatibility::Incompatible(IncompatibleSource::NoBuild);
}
// Check if the filename is PEP 625-compliant.
// TODO: Strengthen this check more; right now we allow `.zip`
// (which is not compliant) and we don't strictly
// enforce the formatting rules for the name or version.
if !filename.extension.is_pep625_compliant() {
return SourceDistCompatibility::Incompatible(IncompatibleSource::NotPep625Filename);
}
// Check if hashes line up
let hash_policy = hasher.get_package(&filename.name, &filename.version);
let hash = if hash_policy.requires_validation() {
+28
View File
@@ -3161,6 +3161,12 @@ impl Package {
else {
return Ok(None);
};
if !ext.is_pep625_compliant() {
return Err(LockErrorKind::NotPep625Filename {
id: self.id.clone(),
}
.into());
}
let install_path = absolute_path(workspace_root, path)?;
let given = path.to_str().expect("lock file paths must be UTF-8");
let path_dist = PathSourceDist {
@@ -3276,6 +3282,12 @@ impl Package {
else {
return Ok(None);
};
if !ext.is_pep625_compliant() {
return Err(LockErrorKind::NotPep625Filename {
id: self.id.clone(),
}
.into());
}
let location = url.to_url().map_err(LockErrorKind::InvalidUrl)?;
let url = DisplaySafeUrl::from(ParsedArchiveUrl {
url: location.clone(),
@@ -5957,6 +5969,12 @@ impl LockError {
LockErrorKind::NoBuild { .. } | LockErrorKind::NoBinaryNoBuild { .. }
)
}
/// Returns true if the [`LockError`] indicates that the lockfile references a
/// non-PEP 625-compliant source distribution.
pub fn is_not_pep625(&self) -> bool {
matches!(&*self.kind, LockErrorKind::NotPep625Filename { .. })
}
}
impl<E> From<E> for LockError
@@ -6375,6 +6393,16 @@ enum LockErrorKind {
/// The list of valid extensions that were expected.
err: ExtensionError,
},
/// An error that occurs when a locked source distribution has a
/// non-PEP 625-compliant filename (e.g., `.tar.bz2`).
#[error(
"Source distribution for `{id}` has a non-PEP 625-compliant filename; only `.tar.gz` and `.zip` archives are accepted",
id = id.cyan()
)]
NotPep625Filename {
/// The ID of the package whose source distribution has a non-PEP 625-compliant filename.
id: PackageId,
},
/// Failed to parse a Git source URL.
#[error("Failed to parse Git URL")]
InvalidGitSourceUrl(
+17 -7
View File
@@ -10,7 +10,7 @@ use tracing::{instrument, trace};
use uv_client::{FlatIndexEntry, OwnedArchive, SimpleDetailMetadata, VersionFiles};
use uv_configuration::BuildOptions;
use uv_distribution_filename::{DistFilename, WheelFilename};
use uv_distribution_filename::{DistFilename, SourceDistFilename, WheelFilename};
use uv_distribution_types::{
HashComparison, IncompatibleSource, IncompatibleWheel, IndexUrl, PrioritizedDist,
RegistryBuiltWheel, RegistrySourceDist, RequiresPython, SourceDistCompatibility,
@@ -521,8 +521,7 @@ impl VersionMapLazy {
}
DistFilename::SourceDistFilename(filename) => {
let compatibility = self.source_dist_compatibility(
&filename.name,
&filename.version,
&filename,
hashes.as_slice(),
yanked,
excluded,
@@ -551,8 +550,7 @@ impl VersionMapLazy {
fn source_dist_compatibility(
&self,
name: &PackageName,
version: &Version,
filename: &SourceDistFilename,
hashes: &[HashDigest],
yanked: Option<&Yanked>,
excluded: bool,
@@ -572,15 +570,27 @@ impl VersionMapLazy {
// Check if yanked
if let Some(yanked) = yanked {
if yanked.is_yanked() && !self.allowed_yanks.contains(name, version) {
if yanked.is_yanked()
&& !self
.allowed_yanks
.contains(&filename.name, &filename.version)
{
return SourceDistCompatibility::Incompatible(IncompatibleSource::Yanked(
yanked.clone(),
));
}
}
// Check if the filename is PEP 625-compliant.
// TODO: Strengthen this check more; right now we allow `.zip`
// (which is not compliant) and we don't strictly
// enforce the formatting rules for the name or version.
if !filename.extension.is_pep625_compliant() {
return SourceDistCompatibility::Incompatible(IncompatibleSource::NotPep625Filename);
}
// Check if hashes line up. If hashes aren't required, they're considered matching.
let hash_policy = self.hasher.get_package(name, version);
let hash_policy = self.hasher.get_package(&filename.name, &filename.version);
let required_hashes = hash_policy.digests();
let hash = if required_hashes.is_empty() {
HashComparison::Matched
+1 -1
View File
@@ -157,7 +157,7 @@ nix = { workspace = true }
uv-unix = { workspace = true }
[features]
default = ["performance", "uv-distribution/static", "test-defaults"]
default = ["performance", "test-defaults"]
native-auth = []
# Use better memory allocators, etc.
performance = ["performance-memory-allocator"]
+5
View File
@@ -856,6 +856,11 @@ async fn do_lock(
// metadata that cannot be obtained under `--no-build`.
return Err(ProjectError::Lock(err));
}
Err(ProjectError::Lock(err)) if err.is_not_pep625() => {
// A non-PEP 625-compliant sdist in the lockfile will also be rejected by a fresh
// resolve, so short-circuit rather than doing the extra work.
return Err(ProjectError::Lock(err));
}
Err(err) => {
warn_user!("Failed to validate existing lockfile: {err}");
None
+7 -10
View File
@@ -860,9 +860,10 @@ fn install_sdist_url() -> Result<()> {
Ok(())
}
/// Install a package with source archive format `.tar.bz2`.
/// Attempt to install a direct URL source distribution with a non-PEP 625-compliant
/// archive format (e.g., `.tar.bz2`). This should hard-error.
#[test]
fn install_sdist_archive_type_bz2() -> Result<()> {
fn reject_sdist_archive_type_bz2() -> Result<()> {
let context = uv_test::test_context!("3.9");
let requirements_txt = context.temp_dir.child("requirements.txt");
@@ -876,17 +877,13 @@ fn install_sdist_archive_type_bz2() -> Result<()> {
uv_snapshot!(context.filters(), context.pip_sync()
.arg("requirements.txt")
.arg("--strict"), @"
success: true
exit_code: 0
.arg("--strict"), @r"
success: false
exit_code: 2
----- stdout -----
----- stderr -----
Resolved 1 package in [TIME]
warning: bz2 @ file://[WORKSPACE]/test/links/bz2-1.0.0.tar.bz2 is not a standards-compliant source distribution: expected '.tar.gz' but found '.tar.bz2'. A future version of uv will reject source distributions that do not meet the requirements specified in PEP 625
Prepared 1 package in [TIME]
Installed 1 package in [TIME]
+ bz2==1.0.0 (from file://[WORKSPACE]/test/links/bz2-1.0.0.tar.bz2)
error: Source distribution `[WORKSPACE]/test/links/bz2-1.0.0.tar.bz2` has a non-PEP 625-compliant filename; only `.tar.gz` and `.zip` archives are accepted
"
);
+241
View File
@@ -16229,6 +16229,247 @@ async fn sync_zstd_wheel() -> Result<()> {
Ok(())
}
/// Sync with an index whose only source distribution has a non-PEP 625-compliant
/// extension (e.g., `.tar.bz2`). The resolver should reject it as incompatible.
#[tokio::test]
async fn sync_non_pep625_sdist() -> Result<()> {
use serde_json::json;
use wiremock::{
Mock, MockServer, ResponseTemplate,
matchers::{method, path},
};
let context = uv_test::test_context!("3.13");
let server = MockServer::start().await;
let sdist_url = format!("{}/files/basic_package-0.1.0.tar.bz2", server.uri());
let simple_index = json!({
"meta": {
"api-version": "1.1"
},
"name": "basic-package",
"files": [{
"filename": "basic_package-0.1.0.tar.bz2",
"url": sdist_url,
"hashes": {
"sha256": "0000000000000000000000000000000000000000000000000000000000000000"
}
}]
});
Mock::given(method("GET"))
.and(path("/simple/basic-package/"))
.respond_with(ResponseTemplate::new(200).set_body_raw(
simple_index.to_string().into_bytes(),
"application/vnd.pyx.simple.v1+json",
))
.mount(&server)
.await;
let pyproject_toml = context.temp_dir.child("pyproject.toml");
pyproject_toml.write_str(&formatdoc! { r#"
[project]
name = "project"
version = "0.1.0"
requires-python = ">=3.13"
dependencies = ["basic-package"]
[tool.uv.sources]
basic-package = {{ index = "test-registry" }}
[[tool.uv.index]]
name = "test-registry"
url = "{}/simple"
"#,
server.uri()
})?;
uv_snapshot!(context.filters(), context.sync().env_remove(EnvVars::UV_EXCLUDE_NEWER), @"
success: false
exit_code: 1
----- stdout -----
----- stderr -----
× No solution found when resolving dependencies:
Because only basic-package==0.1.0 is available and basic-package==0.1.0 has a non-PEP 625-compliant source distribution filename, we can conclude that all versions of basic-package cannot be used.
And because your project depends on basic-package, we can conclude that your project's requirements are unsatisfiable.
hint: `basic-package` was found on http://[LOCALHOST]/simple, but not at the requested version (all of:
basic-package<0.1.0
basic-package>0.1.0
). A compatible version may be available on a subsequent index (e.g., https://pypi.org/simple). By default, uv will only consider versions that are published on the first index that contains a given package, to avoid dependency confusion attacks. If all indexes are equally trusted, use `--index-strategy unsafe-best-match` to consider all versions from all indexes, regardless of the order in which they were defined.
");
Ok(())
}
/// Sync with an index that serves both a non-PEP 625-compliant sdist and a
/// compatible wheel. The resolver should use the wheel and skip the sdist
/// without surfacing an incompatibility error.
#[tokio::test]
async fn sync_non_pep625_sdist_with_compatible_wheel() -> Result<()> {
use serde_json::json;
use wiremock::{
Mock, MockServer, ResponseTemplate,
matchers::{method, path},
};
let context = uv_test::test_context!("3.13");
let server = MockServer::start().await;
let wheel_path = context
.temp_dir
.child("basic_package-0.1.0-py3-none-any.whl");
fs_err::copy(
context
.workspace_root
.join("test/links/basic_package-0.1.0-py3-none-any.whl"),
&wheel_path,
)?;
let wheel_url = format!(
"{}/files/basic_package-0.1.0-py3-none-any.whl",
server.uri()
);
let sdist_url = format!("{}/files/basic_package-0.1.0.tar.bz2", server.uri());
Mock::given(method("GET"))
.and(path("/files/basic_package-0.1.0-py3-none-any.whl"))
.respond_with(ResponseTemplate::new(200).set_body_bytes(fs_err::read(&wheel_path)?))
.mount(&server)
.await;
let simple_index = json!({
"meta": {
"api-version": "1.1"
},
"name": "basic-package",
"files": [
{
"filename": "basic_package-0.1.0.tar.bz2",
"url": sdist_url,
"hashes": {
"sha256": "0000000000000000000000000000000000000000000000000000000000000000"
}
},
{
"filename": "basic_package-0.1.0-py3-none-any.whl",
"url": wheel_url,
"hashes": {
"sha256": "7b6229db79b5800e4e98a351b5628c1c8a944533a2d428aeeaa7275a30d4ea82"
}
}
]
});
Mock::given(method("GET"))
.and(path("/simple/basic-package/"))
.respond_with(ResponseTemplate::new(200).set_body_raw(
simple_index.to_string().into_bytes(),
"application/vnd.pyx.simple.v1+json",
))
.mount(&server)
.await;
let pyproject_toml = context.temp_dir.child("pyproject.toml");
pyproject_toml.write_str(&formatdoc! { r#"
[project]
name = "project"
version = "0.1.0"
requires-python = ">=3.13"
dependencies = ["basic-package"]
[tool.uv.sources]
basic-package = {{ index = "test-registry" }}
[[tool.uv.index]]
name = "test-registry"
url = "{}/simple"
"#,
server.uri()
})?;
uv_snapshot!(context.filters(), context.sync().env_remove(EnvVars::UV_EXCLUDE_NEWER), @"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
WARN Range requests not supported for basic_package-0.1.0-py3-none-any.whl; streaming wheel
Resolved 2 packages in [TIME]
Prepared 1 package in [TIME]
Installed 1 package in [TIME]
+ basic-package==0.1.0
");
Ok(())
}
/// A pre-existing lockfile may refer to a non-PEP 625-compliant direct URL sdist (e.g.
/// one that was locked by an older uv). Refreshing it via `uv sync` should hard-error
/// rather than silently install.
#[test]
fn sync_non_pep625_sdist_from_lockfile() -> Result<()> {
let context = uv_test::test_context!("3.12");
// Stage the `.tar.bz2` sdist alongside the project.
let archive = context.temp_dir.child("bz2-1.0.0.tar.bz2");
fs_err::copy(
context.workspace_root.join("test/links/bz2-1.0.0.tar.bz2"),
&archive,
)?;
let pyproject_toml = context.temp_dir.child("pyproject.toml");
pyproject_toml.write_str(indoc! {r#"
[project]
name = "project"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = ["bz2"]
[tool.uv.sources]
bz2 = { path = "bz2-1.0.0.tar.bz2" }
"#})?;
// Pre-write a lockfile as if a previous uv had resolved the `.tar.bz2` direct URL.
context.temp_dir.child("uv.lock").write_str(indoc! {r#"
version = 1
requires-python = ">=3.12"
[options]
exclude-newer = "2024-03-25T00:00:00Z"
[[package]]
name = "bz2"
version = "1.0.0"
source = { path = "bz2-1.0.0.tar.bz2" }
sdist = { hash = "sha256:f792d237bf8d8f1fc0b0ea16848371cbbb06a6b8a43b0da2f50d30bfba834f7b" }
[[package]]
name = "project"
version = "0.1.0"
source = { virtual = "." }
dependencies = [
{ name = "bz2" },
]
[package.metadata]
requires-dist = [{ name = "bz2", path = "bz2-1.0.0.tar.bz2" }]
"#})?;
uv_snapshot!(context.filters(), context.sync(), @r"
success: false
exit_code: 2
----- stdout -----
----- stderr -----
error: Source distribution for `bz2==1.0.0 @ path+bz2-1.0.0.tar.bz2` has a non-PEP 625-compliant filename; only `.tar.gz` and `.zip` archives are accepted
");
Ok(())
}
#[test]
#[cfg(not(windows))]
fn toggle_workspace_editable() -> Result<()> {
+4 -18
View File
@@ -806,27 +806,13 @@ exclude-newer-package = { setuptools = "30 days" }
[PEP 625](https://peps.python.org/pep-0625/) specifies that packages must distribute source
distributions as gzip tarball (`.tar.gz`) archives. Prior to this specification, other archive
formats, which need to be supported for backward compatibility, were also allowed. uv supports
reading and extracting archives in the following formats:
- gzip tarball (`.tar.gz`, `.tgz`)
- bzip2 tarball (`.tar.bz2`, `.tbz`)
- xz tarball (`.tar.xz`, `.txz`)
- zstd tarball (`.tar.zst`)
- lzip tarball (`.tar.lz`)
- lzma tarball (`.tar.lzma`)
- zip (`.zip`)
formats, which need to be supported for backward compatibility, were also allowed.
!!! important
Using source distribution extensions other than `.tar.gz` is strongly
discouraged, as these extensions are not widely or consistently
supported across the Python packaging ecosystem.
!!! warning "Deprecated"
Support for source distribution extensions other than `.tar.gz` is
deprecated and will be removed in a future release of uv.
As of 0.12, uv rejects source distributions that do not confirm to
[PEP 625]'s extension requirements with the exception of `.zip` archives,
which are still accepted for backward compatibility.
## Lockfile versioning