Skip to content

Commit 107ab3d

Browse files
authored
Build basic source distributions (#8886)
Very basic source distribution support. What's included: - Include and exclude patterns (hard-coded): Currently, we have globset+walkdir in one part and glob in the other. I'll migrate everything to globset+walkset and some custom perf optimizations to avoid traversing irrelevant directories on top. I'll also pick a glob syntax (or subset), PEP 639 seems like a good candidate since it's consistent with what we already have to support. - Add the `PKG-INFO` file with metadata: Thanks to Code Metadata 2.2, this metadata is reliable and can be read statically by external tools. Example output: ``` $ tar -ztvf dist/dummy-0.1.0.tar.gz -rw-r--r-- 0/0 154 1970-01-01 01:00 dummy-0.1.0/PKG-INFO -rw-rw-r-- 0/0 509 1970-01-01 01:00 dummy-0.1.0/pyproject.toml drwxrwxr-x 0/0 0 1970-01-01 01:00 dummy-0.1.0/src/dummy drwxrwxr-x 0/0 0 1970-01-01 01:00 dummy-0.1.0/src/dummy/submodule -rw-rw-r-- 0/0 30 1970-01-01 01:00 dummy-0.1.0/src/dummy/submodule/impl.py -rw-rw-r-- 0/0 14 1970-01-01 01:00 dummy-0.1.0/src/dummy/submodule/__init__.py -rw-rw-r-- 0/0 12 1970-01-01 01:00 dummy-0.1.0/src/dummy/__init__.py ``` No tests since the source distributions don't build valid wheels yet.
1 parent 5eba64a commit 107ab3d

File tree

6 files changed

+171
-14
lines changed

6 files changed

+171
-14
lines changed

Cargo.lock

Lines changed: 14 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,7 @@ fs-err = { version = "2.11.0" }
103103
fs2 = { version = "0.4.3" }
104104
futures = { version = "0.3.30" }
105105
glob = { version = "0.3.1" }
106+
globset = { version = "0.4.15" }
106107
globwalk = { version = "0.9.1" }
107108
goblin = { version = "0.9.0", default-features = false, features = ["std", "elf32", "elf64", "endian_fd"] }
108109
hex = { version = "0.4.3" }
@@ -126,7 +127,7 @@ path-slash = { version = "0.2.1" }
126127
pathdiff = { version = "0.2.1" }
127128
petgraph = { version = "0.6.5" }
128129
platform-info = { version = "2.0.3" }
129-
procfs = { version = "0.17.0" , default-features = false, features = ["flate2"] }
130+
procfs = { version = "0.17.0", default-features = false, features = ["flate2"] }
130131
proc-macro2 = { version = "1.0.86" }
131132
pubgrub = { git = "https://github.com/astral-sh/pubgrub", rev = "95e1390399cdddee986b658be19587eb1fdb2d79" }
132133
version-ranges = { git = "https://github.com/astral-sh/pubgrub", rev = "95e1390399cdddee986b658be19587eb1fdb2d79" }
@@ -153,6 +154,7 @@ smallvec = { version = "1.13.2" }
153154
spdx = { version = "0.10.6" }
154155
syn = { version = "2.0.77" }
155156
sys-info = { version = "0.9.1" }
157+
tar = { version = "0.4.43" }
156158
target-lexicon = { version = "0.12.16" }
157159
tempfile = { version = "3.12.0" }
158160
textwrap = { version = "0.16.1" }

crates/uv-build-backend/Cargo.toml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,13 +21,16 @@ uv-pep508 = { workspace = true }
2121
uv-pypi-types = { workspace = true }
2222
uv-warnings = { workspace = true }
2323

24-
csv = { workspace = true}
24+
csv = { workspace = true }
25+
flate2 = { workspace = true }
2526
fs-err = { workspace = true }
2627
glob = { workspace = true }
28+
globset = { workspace = true }
2729
itertools = { workspace = true }
2830
serde = { workspace = true }
2931
sha2 = { workspace = true }
3032
spdx = { workspace = true }
33+
tar = { workspace = true }
3134
thiserror = { workspace = true }
3235
toml = { workspace = true }
3336
tracing = { workspace = true }

crates/uv-build-backend/src/lib.rs

Lines changed: 139 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,17 +3,21 @@ mod pep639_glob;
33

44
use crate::metadata::{PyProjectToml, ValidationError};
55
use crate::pep639_glob::Pep639GlobError;
6+
use flate2::write::GzEncoder;
7+
use flate2::Compression;
68
use fs_err::File;
79
use glob::{GlobError, PatternError};
10+
use globset::{Glob, GlobSetBuilder};
811
use itertools::Itertools;
912
use sha2::{Digest, Sha256};
1013
use std::fs::FileType;
11-
use std::io::{BufReader, Read, Write};
14+
use std::io::{BufReader, Cursor, Read, Write};
1215
use std::path::{Path, PathBuf, StripPrefixError};
1316
use std::{io, mem};
17+
use tar::{EntryType, Header};
1418
use thiserror::Error;
1519
use tracing::{debug, trace};
16-
use uv_distribution_filename::WheelFilename;
20+
use uv_distribution_filename::{SourceDistExtension, SourceDistFilename, WheelFilename};
1721
use uv_fs::Simplified;
1822
use walkdir::WalkDir;
1923
use zip::{CompressionMethod, ZipWriter};
@@ -33,6 +37,9 @@ pub enum Error {
3337
/// [`GlobError`] is a wrapped io error.
3438
#[error(transparent)]
3539
Glob(#[from] GlobError),
40+
/// [`globset::Error`] shows the glob that failed to parse.
41+
#[error(transparent)]
42+
GlobSet(#[from] globset::Error),
3643
#[error("Failed to walk source tree: `{}`", root.user_display())]
3744
WalkDir {
3845
root: PathBuf,
@@ -43,8 +50,8 @@ pub enum Error {
4350
NotUtf8Path(PathBuf),
4451
#[error("Failed to walk source tree")]
4552
StripPrefix(#[from] StripPrefixError),
46-
#[error("Unsupported file type: {0:?}")]
47-
UnsupportedFileType(FileType),
53+
#[error("Unsupported file type {1:?}: `{}`", _0.user_display())]
54+
UnsupportedFileType(PathBuf, FileType),
4855
#[error("Failed to write wheel zip archive")]
4956
Zip(#[from] zip::result::ZipError),
5057
#[error("Failed to write RECORD file")]
@@ -53,6 +60,8 @@ pub enum Error {
5360
MissingModule(PathBuf),
5461
#[error("Inconsistent metadata between prepare and build step: `{0}`")]
5562
InconsistentSteps(&'static str),
63+
#[error("Failed to write to {}", _0.user_display())]
64+
TarWrite(PathBuf, #[source] io::Error),
5665
}
5766

5867
/// Allow dispatching between writing to a directory, writing to zip and writing to a `.tar.gz`.
@@ -276,7 +285,7 @@ fn write_hashed(
276285
}
277286

278287
/// Build a wheel from the source tree and place it in the output directory.
279-
pub fn build(
288+
pub fn build_wheel(
280289
source_tree: &Path,
281290
wheel_dir: &Path,
282291
metadata_directory: Option<&Path>,
@@ -323,7 +332,10 @@ pub fn build(
323332
wheel_writer.write_file(relative_path_str, entry.path())?;
324333
} else {
325334
// TODO(konsti): We may want to support symlinks, there is support for installing them.
326-
return Err(Error::UnsupportedFileType(entry.file_type()));
335+
return Err(Error::UnsupportedFileType(
336+
entry.path().to_path_buf(),
337+
entry.file_type(),
338+
));
327339
}
328340

329341
entry.path();
@@ -342,6 +354,126 @@ pub fn build(
342354
Ok(filename)
343355
}
344356

357+
/// Build a source distribution from the source tree and place it in the output directory.
358+
pub fn build_source_dist(
359+
source_tree: &Path,
360+
source_dist_directory: &Path,
361+
uv_version: &str,
362+
) -> Result<SourceDistFilename, Error> {
363+
let contents = fs_err::read_to_string(source_tree.join("pyproject.toml"))?;
364+
let pyproject_toml = PyProjectToml::parse(&contents)?;
365+
pyproject_toml.check_build_system(uv_version);
366+
367+
let filename = SourceDistFilename {
368+
name: pyproject_toml.name().clone(),
369+
version: pyproject_toml.version().clone(),
370+
extension: SourceDistExtension::TarGz,
371+
};
372+
373+
let top_level = format!("{}-{}", pyproject_toml.name(), pyproject_toml.version());
374+
375+
let source_dist_path = source_dist_directory.join(filename.to_string());
376+
let tar_gz = File::create(&source_dist_path)?;
377+
let enc = GzEncoder::new(tar_gz, Compression::default());
378+
let mut tar = tar::Builder::new(enc);
379+
380+
let metadata = pyproject_toml
381+
.to_metadata(source_tree)?
382+
.core_metadata_format();
383+
384+
let mut header = Header::new_gnu();
385+
header.set_size(metadata.bytes().len() as u64);
386+
header.set_mode(0o644);
387+
header.set_cksum();
388+
tar.append_data(
389+
&mut header,
390+
Path::new(&top_level).join("PKG-INFO"),
391+
Cursor::new(metadata),
392+
)
393+
.map_err(|err| Error::TarWrite(source_dist_path.clone(), err))?;
394+
395+
let includes = ["src/**/*", "pyproject.toml"];
396+
let mut include_builder = GlobSetBuilder::new();
397+
for include in includes {
398+
include_builder.add(Glob::new(include)?);
399+
}
400+
let include_matcher = include_builder.build()?;
401+
402+
let excludes = ["__pycache__", "*.pyc", "*.pyo"];
403+
let mut exclude_builder = GlobSetBuilder::new();
404+
for exclude in excludes {
405+
exclude_builder.add(Glob::new(exclude)?);
406+
}
407+
let exclude_matcher = exclude_builder.build()?;
408+
409+
// TODO(konsti): Add files linked by pyproject.toml
410+
411+
for file in WalkDir::new(source_tree).into_iter().filter_entry(|dir| {
412+
let relative = dir
413+
.path()
414+
.strip_prefix(source_tree)
415+
.expect("walkdir starts with root");
416+
// TODO(konsti): Also check that we're matching at least a prefix of an include matcher.
417+
!exclude_matcher.is_match(relative)
418+
}) {
419+
let entry = file.map_err(|err| Error::WalkDir {
420+
root: source_tree.to_path_buf(),
421+
err,
422+
})?;
423+
let relative = entry
424+
.path()
425+
.strip_prefix(source_tree)
426+
.expect("walkdir starts with root");
427+
if !include_matcher.is_match(relative) {
428+
trace!("Excluding {}", relative.user_display());
429+
continue;
430+
}
431+
debug!("Including {}", relative.user_display());
432+
433+
let metadata = fs_err::metadata(entry.path())?;
434+
let mut header = Header::new_gnu();
435+
#[cfg(unix)]
436+
{
437+
header.set_mode(std::os::unix::fs::MetadataExt::mode(&metadata));
438+
}
439+
#[cfg(not(unix))]
440+
{
441+
header.set_mode(0o644);
442+
}
443+
444+
if entry.file_type().is_dir() {
445+
header.set_entry_type(EntryType::Directory);
446+
header
447+
.set_path(Path::new(&top_level).join(relative))
448+
.map_err(|err| Error::TarWrite(source_dist_path.clone(), err))?;
449+
header.set_size(0);
450+
header.set_cksum();
451+
tar.append(&header, io::empty())
452+
.map_err(|err| Error::TarWrite(source_dist_path.clone(), err))?;
453+
continue;
454+
} else if entry.file_type().is_file() {
455+
header.set_size(metadata.len());
456+
header.set_cksum();
457+
tar.append_data(
458+
&mut header,
459+
Path::new(&top_level).join(relative),
460+
BufReader::new(File::open(entry.path())?),
461+
)
462+
.map_err(|err| Error::TarWrite(source_dist_path.clone(), err))?;
463+
} else {
464+
return Err(Error::UnsupportedFileType(
465+
relative.to_path_buf(),
466+
entry.file_type(),
467+
));
468+
}
469+
}
470+
471+
tar.finish()
472+
.map_err(|err| Error::TarWrite(source_dist_path.clone(), err))?;
473+
474+
Ok(filename)
475+
}
476+
345477
/// Write the dist-info directory to the output directory without building the wheel.
346478
pub fn metadata(
347479
source_tree: &Path,
@@ -350,7 +482,7 @@ pub fn metadata(
350482
) -> Result<String, Error> {
351483
let contents = fs_err::read_to_string(source_tree.join("pyproject.toml"))?;
352484
let pyproject_toml = PyProjectToml::parse(&contents)?;
353-
pyproject_toml.check_build_system("1.0.0+test");
485+
pyproject_toml.check_build_system(uv_version);
354486

355487
let filename = WheelFilename {
356488
name: pyproject_toml.name().clone(),

crates/uv-build-backend/src/tests.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ fn test_record() {
4646
fn test_determinism() {
4747
let temp1 = TempDir::new().unwrap();
4848
let uv_backend = Path::new("../../scripts/packages/uv_backend");
49-
build(uv_backend, temp1.path(), None, "1.0.0+test").unwrap();
49+
build_wheel(uv_backend, temp1.path(), None, "1.0.0+test").unwrap();
5050

5151
// Touch the file to check that we don't serialize the last modified date.
5252
fs_err::write(
@@ -56,7 +56,7 @@ fn test_determinism() {
5656
.unwrap();
5757

5858
let temp2 = TempDir::new().unwrap();
59-
build(uv_backend, temp2.path(), None, "1.0.0+test").unwrap();
59+
build_wheel(uv_backend, temp2.path(), None, "1.0.0+test").unwrap();
6060

6161
let wheel_filename = "uv_backend-0.1.0-py3-none-any.whl";
6262
assert_eq!(

crates/uv/src/commands/build_backend.rs

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,20 @@ use anyhow::Result;
55
use std::env;
66
use std::path::Path;
77

8-
pub(crate) fn build_sdist(_sdist_directory: &Path) -> Result<ExitStatus> {
9-
todo!()
8+
pub(crate) fn build_sdist(sdist_directory: &Path) -> Result<ExitStatus> {
9+
let filename = uv_build_backend::build_source_dist(
10+
&env::current_dir()?,
11+
sdist_directory,
12+
uv_version::version(),
13+
)?;
14+
println!("{filename}");
15+
Ok(ExitStatus::Success)
1016
}
1117
pub(crate) fn build_wheel(
1218
wheel_directory: &Path,
1319
metadata_directory: Option<&Path>,
1420
) -> Result<ExitStatus> {
15-
let filename = uv_build_backend::build(
21+
let filename = uv_build_backend::build_wheel(
1622
&env::current_dir()?,
1723
wheel_directory,
1824
metadata_directory,

0 commit comments

Comments
 (0)