use crate::metadata::DEFAULT_EXCLUDES;
use crate::wheel::build_exclude_matcher;
use crate::{
BuildBackendSettings, DirectoryWriter, Error, FileList, ListWriter, PyProjectToml,
error_on_venv, find_roots, write_directory_once, write_file_with_directories,
};
use flate2::Compression;
use flate2::write::GzEncoder;
use fs_err::File;
use futures_lite::future::block_on;
use globset::{Glob, GlobSet};
use rustc_hash::FxHashSet;
use std::io;
use std::io::{BufReader, Cursor, Read, Write};
use std::path::{Component, Path, PathBuf};
use std::pin::Pin;
use std::task::{Context, Poll};
use tokio::io::{AsyncRead, AsyncWrite, ReadBuf};
use tokio_tar::{EntryType, Header};
use tracing::{debug, trace};
use uv_distribution_filename::{SourceDistExtension, SourceDistFilename};
use uv_fs::{Simplified, normalize_path};
use uv_globfilter::{GlobDirFilter, PortableGlobParser};
use uv_preview::PreviewFeature;
use uv_toml::has_toml11_features;
use uv_warnings::warn_user_once;
use walkdir::WalkDir;
pub fn build_source_dist(
source_tree: &Path,
source_dist_directory: &Path,
uv_version: &str,
show_warnings: bool,
) -> Result<SourceDistFilename, Error> {
let pyproject_toml = PyProjectToml::parse(&source_tree.join("pyproject.toml"))?;
let filename = SourceDistFilename {
name: pyproject_toml.name().clone(),
version: pyproject_toml.version().clone(),
extension: SourceDistExtension::TarGz,
};
let source_dist_path = source_dist_directory.join(filename.to_string());
if source_dist_path.exists() {
fs_err::remove_file(&source_dist_path)?;
}
let temp_file = uv_fs::tempfile_in(source_dist_directory)?;
let writer = TarGzWriter::new(temp_file.as_file(), &source_dist_path);
write_source_dist(source_tree, writer, uv_version, show_warnings)?;
temp_file
.persist(&source_dist_path)
.map_err(|err| Error::Persist(source_dist_path.clone(), err.error))?;
Ok(filename)
}
pub fn list_source_dist(
source_tree: &Path,
uv_version: &str,
show_warnings: bool,
) -> Result<(SourceDistFilename, FileList), Error> {
let pyproject_toml = PyProjectToml::parse(&source_tree.join("pyproject.toml"))?;
let filename = SourceDistFilename {
name: pyproject_toml.name().clone(),
version: pyproject_toml.version().clone(),
extension: SourceDistExtension::TarGz,
};
let mut files = FileList::new();
let writer = ListWriter::new(&mut files);
write_source_dist(source_tree, writer, uv_version, show_warnings)?;
Ok((filename, files))
}
fn source_dist_matcher(
source_tree: &Path,
pyproject_toml: &PyProjectToml,
settings: BuildBackendSettings,
show_warnings: bool,
) -> Result<(GlobDirFilter, GlobSet), Error> {
let mut include_globs = Vec::new();
let mut includes: Vec<String> = settings.source_include;
includes.push(globset::escape("pyproject.toml"));
let (src_root, modules_relative) = find_roots(
source_tree,
pyproject_toml,
&settings.module_root,
settings.module_name.as_ref(),
settings.namespace,
show_warnings,
)?;
for module_relative in modules_relative {
let path = &uv_fs::relative_to(src_root.join(module_relative), source_tree)
.expect("module root is inside source tree");
let import_path = normalize_path(path).portable_display().to_string();
includes.push(format!("{}/**", globset::escape(&import_path)));
}
for include in includes {
let glob = PortableGlobParser::Uv
.parse(&include)
.map_err(|err| Error::PortableGlob {
field: "tool.uv.build-backend.source-include".to_string(),
source: err,
})?;
include_globs.push(glob);
}
if let Some(readme) = pyproject_toml
.readme()
.as_ref()
.and_then(|readme| readme.path())
{
let readme = normalize_path(readme);
trace!("Including readme at: {}", readme.user_display());
let readme = readme.portable_display().to_string();
let glob = Glob::new(&globset::escape(&readme)).expect("escaped globset is parseable");
include_globs.push(glob);
}
for license_files in pyproject_toml.license_files_source_dist() {
trace!("Including license files at: {license_files}`");
let glob = PortableGlobParser::Pep639
.parse(license_files)
.map_err(|err| Error::PortableGlob {
field: "project.license-files".to_string(),
source: err,
})?;
include_globs.push(glob);
}
for (name, directory) in settings.data.iter() {
let directory = normalize_path(directory);
trace!("Including data ({}) at: {}", name, directory.user_display());
if directory
.components()
.next()
.is_some_and(|component| !matches!(component, Component::CurDir | Component::Normal(_)))
{
return Err(Error::InvalidDataRoot {
name: name.to_string(),
path: directory.to_path_buf(),
});
}
let directory = directory.portable_display().to_string();
let glob = PortableGlobParser::Uv
.parse(&format!("{}/**", globset::escape(&directory)))
.map_err(|err| Error::PortableGlob {
field: format!("tool.uv.build-backend.data.{name}"),
source: err,
})?;
include_globs.push(glob);
}
debug!(
"Source distribution includes: {:?}",
include_globs
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
);
let include_matcher =
GlobDirFilter::from_globs(&include_globs).map_err(|err| Error::GlobSetTooLarge {
field: "tool.uv.build-backend.source-include".to_string(),
source: err,
})?;
let mut excludes: Vec<String> = Vec::new();
if settings.default_excludes {
excludes.extend(DEFAULT_EXCLUDES.iter().map(ToString::to_string));
}
for exclude in settings.source_exclude {
if !excludes.contains(&exclude) {
excludes.push(exclude);
}
}
debug!("Source dist excludes: {:?}", excludes);
let exclude_matcher = build_exclude_matcher(excludes)?;
if exclude_matcher.is_match("pyproject.toml") {
return Err(Error::PyprojectTomlExcluded);
}
Ok((include_matcher, exclude_matcher))
}
fn write_source_dist(
source_tree: &Path,
mut writer: impl DirectoryWriter,
uv_version: &str,
show_warnings: bool,
) -> Result<SourceDistFilename, Error> {
let pyproject_toml = PyProjectToml::parse(&source_tree.join("pyproject.toml"))?;
for warning in pyproject_toml.check_build_system(uv_version) {
warn_user_once!("{warning}");
}
let settings = pyproject_toml
.settings()
.cloned()
.unwrap_or_else(BuildBackendSettings::default);
let filename = SourceDistFilename {
name: pyproject_toml.name().clone(),
version: pyproject_toml.version().clone(),
extension: SourceDistExtension::TarGz,
};
let top_level = format!(
"{}-{}",
pyproject_toml.name().as_dist_info_name(),
pyproject_toml.version()
);
let metadata = pyproject_toml.to_metadata(source_tree)?;
let metadata_email = metadata.core_metadata_format();
debug!("Adding content files to source distribution");
writer.write_bytes(
&Path::new(&top_level)
.join("PKG-INFO")
.portable_display()
.to_string(),
metadata_email.as_bytes(),
)?;
let pyproject_path = source_tree.join("pyproject.toml");
let pyproject_contents = fs_err::read_to_string(&pyproject_path)?;
let toml_backwards_compatibility =
if uv_preview::is_enabled(PreviewFeature::TomlBackwardsCompatibility) {
true
} else if has_toml11_features(&pyproject_contents) {
warn_user_once!(
"`pyproject.toml` uses TOML 1.1 features; rewriting to TOML 1.0 for \
compatibility with older build tools. Use `--preview-feature \
{feature}` to suppress this warning.",
feature = PreviewFeature::TomlBackwardsCompatibility
);
true
} else {
false
};
if toml_backwards_compatibility {
let mut pyproject_value: toml::Value = toml::from_str(&pyproject_contents)
.map_err(|err| Error::Toml(pyproject_path.clone(), err))?;
normalize_toml10_datetimes(&mut pyproject_value);
let pyproject_rewritten =
toml::to_string_pretty(&pyproject_value).map_err(Error::TomlSerialize)?;
writer.write_bytes(
&Path::new(&top_level)
.join("pyproject.toml")
.portable_display()
.to_string(),
pyproject_rewritten.as_bytes(),
)?;
writer.write_file(
&Path::new(&top_level)
.join("pyproject.toml.orig")
.portable_display()
.to_string(),
&pyproject_path,
)?;
}
let (include_matcher, exclude_matcher) =
source_dist_matcher(source_tree, &pyproject_toml, settings, show_warnings)?;
let mut files_visited = 0;
let mut written_directories = FxHashSet::<PathBuf>::default();
let top_level_directory = PathBuf::from(&top_level).join("");
write_directory_once(&mut writer, &mut written_directories, &top_level_directory)?;
for entry in WalkDir::new(source_tree)
.sort_by_file_name()
.into_iter()
.filter_entry(|entry| {
let relative = entry
.path()
.strip_prefix(source_tree)
.expect("walkdir starts with root");
include_matcher.match_directory(relative) && !exclude_matcher.is_match(relative)
})
{
let entry = entry.map_err(|err| Error::WalkDir {
root: source_tree.to_path_buf(),
err,
})?;
files_visited += 1;
if files_visited > 10000 {
warn_user_once!(
"Visited more than 10,000 files for source distribution build. \
Consider using more constrained includes or more excludes."
);
}
let relative = entry
.path()
.strip_prefix(source_tree)
.expect("walkdir starts with root");
if !include_matcher.match_path(relative) || exclude_matcher.is_match(relative) {
trace!("Excluding from sdist: {}", relative.user_display());
continue;
}
if toml_backwards_compatibility {
if relative == "pyproject.toml" {
continue;
}
if relative == "pyproject.toml.orig" {
debug!("Ignoring existing `pyproject.toml.orig`");
continue;
}
}
error_on_venv(entry.file_name(), entry.path())?;
if entry.file_type().is_dir() {
continue;
}
debug!("Adding to sdist: {}", relative.user_display());
write_file_with_directories(
&mut writer,
&mut written_directories,
Path::new(&top_level),
relative,
entry.path(),
)?;
}
debug!("Visited {files_visited} files for source dist build");
writer.close(&top_level)?;
Ok(filename)
}
pub(crate) struct SyncReader<R> {
reader: R,
}
impl<R> SyncReader<R> {
pub(crate) fn new(reader: R) -> Self {
Self { reader }
}
}
impl<R: Read + Unpin> AsyncRead for SyncReader<R> {
fn poll_read(
mut self: Pin<&mut Self>,
_context: &mut Context<'_>,
buffer: &mut ReadBuf<'_>,
) -> Poll<io::Result<()>> {
let read = self.reader.read(buffer.initialize_unfilled())?;
buffer.advance(read);
Poll::Ready(Ok(()))
}
}
struct SyncWriter<W> {
writer: W,
}
impl<W> SyncWriter<W> {
fn new(writer: W) -> Self {
Self { writer }
}
fn into_inner(self) -> W {
self.writer
}
}
impl<W: Write + Unpin> AsyncWrite for SyncWriter<W> {
fn poll_write(
mut self: Pin<&mut Self>,
_context: &mut Context<'_>,
buffer: &[u8],
) -> Poll<io::Result<usize>> {
Poll::Ready(self.writer.write(buffer))
}
fn poll_flush(self: Pin<&mut Self>, _context: &mut Context<'_>) -> Poll<io::Result<()>> {
Poll::Ready(Ok(()))
}
fn poll_shutdown(self: Pin<&mut Self>, context: &mut Context<'_>) -> Poll<io::Result<()>> {
self.poll_flush(context)
}
}
struct TarGzWriter<W: Write + Unpin + Send> {
path: PathBuf,
tar: tokio_tar::Builder<SyncWriter<GzEncoder<W>>>,
}
impl<W: Write + Unpin + Send> TarGzWriter<W> {
fn new(writer: W, path: impl Into<PathBuf>) -> Self {
let path = path.into();
let enc = GzEncoder::new(writer, Compression::default());
let tar = tokio_tar::Builder::new_non_terminated(SyncWriter::new(enc));
Self { path, tar }
}
}
fn normalize_toml10_datetimes(value: &mut toml::Value) {
match value {
toml::Value::Datetime(datetime) => {
if let Some(time) = datetime.time.as_mut()
&& time.second.is_none()
{
time.second = Some(0);
}
}
toml::Value::Array(values) => {
for value in values {
normalize_toml10_datetimes(value);
}
}
toml::Value::Table(values) => {
for (_, value) in values.iter_mut() {
normalize_toml10_datetimes(value);
}
}
toml::Value::String(_)
| toml::Value::Integer(_)
| toml::Value::Float(_)
| toml::Value::Boolean(_) => {}
}
}
impl<W: Write + Unpin + Send> DirectoryWriter for TarGzWriter<W> {
fn write_bytes(&mut self, path: &str, bytes: &[u8]) -> Result<(), Error> {
let mut header = Header::new_gnu();
header.set_entry_type(EntryType::Regular);
header.set_size(bytes.len() as u64);
header.set_mode(0o644);
block_on(
self.tar
.append_data(&mut header, path, SyncReader::new(Cursor::new(bytes))),
)
.map_err(|err| Error::TarWrite(self.path.clone(), err))?;
Ok(())
}
fn write_file(&mut self, path: &str, file: &Path) -> Result<(), Error> {
let metadata = fs_err::metadata(file)?;
let mut header = Header::new_gnu();
header.set_entry_type(EntryType::Regular);
#[cfg(unix)]
let executable_bit = {
use std::os::unix::fs::PermissionsExt;
file.metadata()?.permissions().mode() & 0o111 != 0
};
#[cfg(not(unix))]
let executable_bit = false;
if executable_bit {
header.set_mode(0o755);
} else {
header.set_mode(0o644);
}
header.set_size(metadata.len());
let reader = BufReader::new(File::open(file)?);
block_on(
self.tar
.append_data(&mut header, path, SyncReader::new(reader)),
)
.map_err(|err| Error::TarWrite(self.path.clone(), err))?;
Ok(())
}
fn write_directory(&mut self, directory: &str) -> Result<(), Error> {
let mut header = Header::new_gnu();
header.set_mode(0o755);
header.set_entry_type(EntryType::Directory);
header.set_size(0);
block_on(
self.tar
.append_data(&mut header, directory, SyncReader::new(io::empty())),
)
.map_err(|err| Error::TarWrite(self.path.clone(), err))?;
Ok(())
}
fn close(self, _dist_info_dir: &str) -> Result<(), Error> {
let path = self.path;
let writer =
block_on(self.tar.into_inner()).map_err(|err| Error::TarWrite(path.clone(), err))?;
writer
.into_inner()
.finish()
.map_err(|err| Error::TarWrite(path, err))?;
Ok(())
}
}