use {
anyhow::{anyhow, Context, Result},
once_cell::sync::Lazy,
sha2::Digest,
simple_file_manifest::{FileEntry, FileManifest},
std::{
cmp::Ordering,
io::{Seek, Write},
path::{Path, PathBuf},
},
};
static RE_FILENAME_ESCAPE: Lazy<regex::Regex> =
Lazy::new(|| regex::Regex::new(r"[^\w\d.]+").unwrap());
fn base64_engine() -> impl base64::engine::Engine {
base64::engine::fast_portable::FastPortable::from(
&base64::alphabet::URL_SAFE,
base64::engine::fast_portable::FastPortableConfig::new().with_encode_padding(false),
)
}
pub struct WheelBuilder {
distribution: String,
version: String,
build_tag: Option<String>,
python_tag: String,
abi_tag: String,
platform_tag: String,
generator: String,
root_is_purelib: bool,
manifest: FileManifest,
modified_time: time::OffsetDateTime,
}
impl WheelBuilder {
pub fn new(distribution: impl ToString, version: impl ToString) -> Self {
Self {
distribution: distribution.to_string(),
version: version.to_string(),
build_tag: None,
python_tag: "py3".to_string(),
abi_tag: "none".to_string(),
platform_tag: "any".to_string(),
generator: "rust-python-packaging".to_string(),
root_is_purelib: false,
manifest: FileManifest::default(),
modified_time: time::OffsetDateTime::now_utc(),
}
}
pub fn build_tag(&self) -> Option<&str> {
self.build_tag.as_deref()
}
pub fn set_build_tag(&mut self, v: impl ToString) {
self.build_tag = Some(v.to_string());
}
pub fn tag(&self) -> String {
format!("{}-{}-{}", self.python_tag, self.abi_tag, self.platform_tag)
}
pub fn set_tag(&mut self, tag: impl ToString) -> Result<()> {
let tag = tag.to_string();
let mut parts = tag.splitn(3, '-');
let python = parts
.next()
.ok_or_else(|| anyhow!("could not parse Python tag"))?;
let abi = parts
.next()
.ok_or_else(|| anyhow!("could not parse ABI tag"))?;
let platform = parts
.next()
.ok_or_else(|| anyhow!("could not parse Platform tag"))?;
self.set_python_tag(python);
self.set_abi_tag(abi);
self.set_platform_tag(platform);
Ok(())
}
pub fn python_tag(&self) -> &str {
&self.python_tag
}
pub fn set_python_tag(&mut self, v: impl ToString) {
self.python_tag = v.to_string();
}
pub fn abi_tag(&self) -> &str {
&self.abi_tag
}
pub fn set_abi_tag(&mut self, v: impl ToString) {
self.abi_tag = v.to_string();
}
pub fn platform_tag(&self) -> &str {
&self.platform_tag
}
pub fn set_platform_tag(&mut self, v: impl ToString) {
self.platform_tag = v.to_string();
}
pub fn generator(&self) -> &str {
&self.generator
}
pub fn set_generator(&mut self, v: impl ToString) {
self.generator = v.to_string();
}
pub fn root_is_purelib(&self) -> bool {
self.root_is_purelib
}
pub fn set_root_is_purelib(&mut self, v: bool) {
self.root_is_purelib = v;
}
pub fn modified_time(&self) -> time::OffsetDateTime {
self.modified_time
}
pub fn set_modified_time(&mut self, v: time::OffsetDateTime) {
self.modified_time = v;
}
fn normalized_distribution(&self) -> String {
self.distribution.to_lowercase().replace('-', "_")
}
fn dist_info_path(&self) -> PathBuf {
PathBuf::from(format!(
"{}-{}.dist-info",
self.normalized_distribution(),
self.version
))
}
pub fn add_file(&mut self, path: impl AsRef<Path>, file: impl Into<FileEntry>) -> Result<()> {
self.manifest.add_file_entry(path, file)?;
Ok(())
}
pub fn add_file_dist_info(
&mut self,
path: impl AsRef<Path>,
file: impl Into<FileEntry>,
) -> Result<()> {
self.manifest
.add_file_entry(self.dist_info_path().join(path), file)?;
Ok(())
}
pub fn add_file_data(
&mut self,
destination: impl ToString,
path: impl AsRef<Path>,
file: impl Into<FileEntry>,
) -> Result<()> {
self.manifest.add_file_entry(
PathBuf::from(format!(
"{}-{}.data",
self.normalized_distribution(),
self.version
))
.join(destination.to_string())
.join(path),
file,
)?;
Ok(())
}
fn derive_wheel_file(&self) -> String {
format!(
"Wheel-Version: 1.0\nGenerator: {}\nRoot-Is-Purelib: {}\nTag: {}\n",
self.generator,
self.root_is_purelib,
self.tag()
)
}
fn derive_metadata_file(&self) -> String {
format!(
"Metadata-Version: 2.1\nName: {}\nVersion: {}\n",
self.distribution, self.version
)
}
pub fn derive_record_file(&self, manifest: &FileManifest) -> Result<String> {
let mut lines = manifest
.iter_entries()
.map(|(path, entry)| {
let content = entry
.resolve_content()
.with_context(|| format!("resolving content for {}", path.display()))?;
let mut digest = sha2::Sha256::new();
digest.update(&content);
Ok(format!(
"{},sha256={},{}",
path.display(),
base64::encode_engine(digest.finalize().as_slice(), &base64_engine()),
content.len()
))
})
.collect::<Result<Vec<_>>>()?;
lines.push(format!("{}/RECORD,,\n", self.dist_info_path().display()));
Ok(lines.join("\n"))
}
pub fn wheel_file_name(&self) -> String {
let mut parts = vec![self.normalized_distribution(), self.version.clone()];
if let Some(v) = &self.build_tag {
parts.push(v.clone());
}
parts.push(self.python_tag.clone());
parts.push(self.abi_tag.clone());
parts.push(self.platform_tag.clone());
let s = parts
.iter()
.map(|x| RE_FILENAME_ESCAPE.replace_all(x, "_"))
.collect::<Vec<_>>()
.join("-");
format!("{}.whl", s)
}
pub fn build_file_manifest(&self) -> Result<FileManifest> {
let mut m = self.manifest.clone();
if !m.has_path(self.dist_info_path().join("WHEEL")) {
m.add_file_entry(
self.dist_info_path().join("WHEEL"),
self.derive_wheel_file().as_bytes(),
)?;
}
if !m.has_path(self.dist_info_path().join("METADATA")) {
m.add_file_entry(
self.dist_info_path().join("METADATA"),
self.derive_metadata_file().as_bytes(),
)?;
}
m.remove(self.dist_info_path().join("RECORD"));
m.add_file_entry(
self.dist_info_path().join("RECORD"),
self.derive_record_file(&m)
.context("deriving RECORD file")?
.as_bytes(),
)?;
Ok(m)
}
pub fn write_wheel_data(&self, writer: &mut (impl Write + Seek)) -> Result<()> {
let m = self
.build_file_manifest()
.context("building wheel file manifest")?;
let mut files = m.iter_files().collect::<Vec<_>>();
let dist_info_path = self.dist_info_path();
files.sort_by(|a, b| {
if a.path().starts_with(&dist_info_path) && !b.path().starts_with(&dist_info_path) {
Ordering::Greater
} else if b.path().starts_with(&dist_info_path)
&& !a.path().starts_with(&dist_info_path)
{
Ordering::Less
} else {
a.path().cmp(b.path())
}
});
let mut zf = zip::ZipWriter::new(writer);
for file in files.into_iter() {
let options = zip::write::FileOptions::default()
.unix_permissions(if file.entry().is_executable() {
0o0755
} else {
0o0644
})
.last_modified_time(
zip::DateTime::from_date_and_time(
self.modified_time.year() as u16,
self.modified_time.month() as u8,
self.modified_time.day(),
self.modified_time.hour(),
self.modified_time.minute(),
self.modified_time.second(),
)
.map_err(|_| anyhow!("could not convert time to zip::DateTime"))?,
);
zf.start_file(format!("{}", file.path().display()), options)?;
zf.write_all(
&file
.entry()
.resolve_content()
.with_context(|| format!("resolving content of {}", file.path().display()))?,
)
.with_context(|| format!("writing zip member {}", file.path().display()))?;
}
zf.finish().context("finishing zip file")?;
Ok(())
}
pub fn write_wheel_into_directory(&self, directory: impl AsRef<Path>) -> Result<PathBuf> {
let path = directory.as_ref().join(self.wheel_file_name());
let mut cursor = std::io::Cursor::new(Vec::<u8>::new());
self.write_wheel_data(&mut cursor)
.context("creating wheel zip data")?;
std::fs::write(&path, cursor.into_inner())
.with_context(|| format!("writing wheel data to {}", path.display()))?;
Ok(path)
}
}
#[cfg(test)]
mod test {
use super::*;
#[test]
fn empty() -> Result<()> {
let builder = WheelBuilder::new("my-package", "0.1");
let mut dest = std::io::Cursor::new(Vec::<u8>::new());
builder.write_wheel_data(&mut dest)?;
let m = builder.build_file_manifest()?;
assert_eq!(m.iter_entries().count(), 3);
assert_eq!(m.get("my_package-0.1.dist-info/WHEEL"),
Some(&b"Wheel-Version: 1.0\nGenerator: rust-python-packaging\nRoot-Is-Purelib: false\nTag: py3-none-any\n".as_ref().into()));
assert_eq!(
m.get("my_package-0.1.dist-info/METADATA"),
Some(
&b"Metadata-Version: 2.1\nName: my-package\nVersion: 0.1\n"
.as_ref()
.into()
)
);
assert_eq!(
m.get("my_package-0.1.dist-info/RECORD"),
Some(&b"my_package-0.1.dist-info/METADATA,sha256=sXUNNYpfVReu7VHhVzSbKiT5ciO4Fwcwm7icBNiYn3Y,52\nmy_package-0.1.dist-info/WHEEL,sha256=76DhAzqMvlOgtCOiUNpWcD643b1CXd507uRH1hq6fQw,93\nmy_package-0.1.dist-info/RECORD,,\n".as_ref().into())
);
Ok(())
}
#[test]
fn wheel_file_name() -> Result<()> {
let mut builder = WheelBuilder::new("my-package", "0.1");
assert_eq!(builder.wheel_file_name(), "my_package-0.1-py3-none-any.whl");
builder.set_python_tag("py39");
assert_eq!(
builder.wheel_file_name(),
"my_package-0.1-py39-none-any.whl"
);
builder.set_abi_tag("abi");
assert_eq!(builder.wheel_file_name(), "my_package-0.1-py39-abi-any.whl");
builder.set_platform_tag("platform");
assert_eq!(
builder.wheel_file_name(),
"my_package-0.1-py39-abi-platform.whl"
);
builder.set_tag("py3-none-any")?;
assert_eq!(builder.wheel_file_name(), "my_package-0.1-py3-none-any.whl");
builder.set_build_tag("build");
assert_eq!(
builder.wheel_file_name(),
"my_package-0.1-build-py3-none-any.whl"
);
Ok(())
}
#[test]
fn custom_wheel_file() -> Result<()> {
let mut builder = WheelBuilder::new("my-package", "0.1");
builder.add_file_dist_info("WHEEL", vec![42])?;
let m = builder.build_file_manifest()?;
assert_eq!(
m.get("my_package-0.1.dist-info/WHEEL"),
Some(&vec![42].into())
);
Ok(())
}
#[test]
fn custom_metadata_file() -> Result<()> {
let mut builder = WheelBuilder::new("my-package", "0.1");
builder.add_file_dist_info("METADATA", vec![42])?;
let m = builder.build_file_manifest()?;
assert_eq!(
m.get("my_package-0.1.dist-info/METADATA"),
Some(&vec![42].into())
);
Ok(())
}
#[test]
fn add_file_data() -> Result<()> {
let mut builder = WheelBuilder::new("my-package", "0.1");
builder.add_file_data("purelib", "__init__.py", vec![42])?;
let m = builder.build_file_manifest()?;
assert_eq!(
m.get("my_package-0.1.data/purelib/__init__.py"),
Some(&vec![42].into())
);
Ok(())
}
}