1use std::io::{Read, Write};
7use std::path::{Path, PathBuf};
8
9use sha2::{Digest, Sha256};
10use zip::write::SimpleFileOptions;
11use zip::ZipWriter;
12
13use crate::path_dep::{load_path_dependencies, PathDependency};
14use crate::pep::PyProject;
15use crate::{Error, Result};
16
17pub struct Builder {
19 project_root: PathBuf,
21 include_local_deps: bool,
23}
24
25#[derive(Debug)]
27pub struct BuildResult {
28 pub path: PathBuf,
30 pub size: u64,
32}
33
34impl Builder {
35 pub fn new(project_root: impl Into<PathBuf>) -> Self {
37 Self {
38 project_root: project_root.into(),
39 include_local_deps: true, }
41 }
42
43 pub fn with_include_local_deps(mut self, include: bool) -> Self {
45 self.include_local_deps = include;
46 self
47 }
48
49 pub fn build_wheel(&self, output_dir: &Path) -> Result<BuildResult> {
51 let pyproject = PyProject::load(&self.project_root)?;
52 let project = pyproject
53 .project
54 .as_ref()
55 .ok_or(Error::MissingProjectMetadata)?;
56
57 let name = &project.name;
58 let version = project.version.as_ref().ok_or(Error::MissingVersion)?;
59
60 let normalized_name = normalize_name(name);
62
63 std::fs::create_dir_all(output_dir).map_err(Error::Io)?;
65
66 let wheel_name = format!("{}-{}-py3-none-any.whl", normalized_name, version);
68 let wheel_path = output_dir.join(&wheel_name);
69
70 let file = std::fs::File::create(&wheel_path).map_err(Error::Io)?;
72 let mut zip = ZipWriter::new(file);
73 let options =
74 SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated);
75
76 let mut records: Vec<(String, String, u64)> = Vec::new();
78
79 let src_dir = self.project_root.join("src");
81 let package_dir = if src_dir.exists() {
82 let pkg_dir = src_dir.join(name.replace('-', "_"));
84 if pkg_dir.exists() {
85 Some(pkg_dir)
86 } else {
87 find_package_in_dir(&src_dir)
89 }
90 } else {
91 let pkg_dir = self.project_root.join(name.replace('-', "_"));
93 if pkg_dir.exists() {
94 Some(pkg_dir)
95 } else {
96 None
97 }
98 };
99
100 if let Some(ref pkg_dir) = package_dir {
101 let pkg_name = pkg_dir.file_name().unwrap().to_string_lossy().to_string();
102 add_directory_to_zip(&mut zip, pkg_dir, &pkg_name, options, &mut records)?;
103 }
104
105 if self.include_local_deps {
107 let path_deps = load_path_dependencies(&self.project_root).unwrap_or_default();
108 for (dep_name, dep) in path_deps {
109 if !dep.editable {
111 if let Ok(local_pkg_dir) = find_local_package_dir(&dep, &self.project_root) {
112 let local_pkg_name = local_pkg_dir
113 .file_name()
114 .unwrap_or_default()
115 .to_string_lossy()
116 .to_string();
117 tracing::info!("Including local dependency '{}' in wheel", dep_name);
118 add_directory_to_zip(
119 &mut zip,
120 &local_pkg_dir,
121 &local_pkg_name,
122 options,
123 &mut records,
124 )?;
125 }
126 }
127 }
128 }
129
130 let dist_info = format!("{}-{}.dist-info", normalized_name, version);
132
133 let metadata = generate_metadata(&pyproject)?;
135 let metadata_path = format!("{}/METADATA", dist_info);
136 add_file_to_zip(
137 &mut zip,
138 &metadata_path,
139 metadata.as_bytes(),
140 options,
141 &mut records,
142 )?;
143
144 let wheel_content = generate_wheel_file();
146 let wheel_file_path = format!("{}/WHEEL", dist_info);
147 add_file_to_zip(
148 &mut zip,
149 &wheel_file_path,
150 wheel_content.as_bytes(),
151 options,
152 &mut records,
153 )?;
154
155 if !project.scripts.is_empty() || !project.gui_scripts.is_empty() {
157 let entry_points = generate_entry_points(project);
158 let ep_path = format!("{}/entry_points.txt", dist_info);
159 add_file_to_zip(
160 &mut zip,
161 &ep_path,
162 entry_points.as_bytes(),
163 options,
164 &mut records,
165 )?;
166 }
167
168 if let Some(ref pkg_dir) = package_dir {
170 let top_level = pkg_dir.file_name().unwrap().to_string_lossy().to_string();
171 let tl_path = format!("{}/top_level.txt", dist_info);
172 add_file_to_zip(
173 &mut zip,
174 &tl_path,
175 format!("{}\n", top_level).as_bytes(),
176 options,
177 &mut records,
178 )?;
179 }
180
181 let record_path = format!("{}/RECORD", dist_info);
183 let mut record_content = String::new();
184 for (path, hash, size) in &records {
185 record_content.push_str(&format!("{},sha256={},{}\n", path, hash, size));
186 }
187 record_content.push_str(&format!("{},,\n", record_path));
189
190 zip.start_file(&record_path, options)
191 .map_err(|e| Error::Zip(e.to_string()))?;
192 zip.write_all(record_content.as_bytes())
193 .map_err(Error::Io)?;
194
195 zip.finish().map_err(|e| Error::Zip(e.to_string()))?;
196
197 let size = std::fs::metadata(&wheel_path).map_err(Error::Io)?.len();
198
199 Ok(BuildResult {
200 path: wheel_path,
201 size,
202 })
203 }
204
205 pub fn build_sdist(&self, output_dir: &Path) -> Result<BuildResult> {
207 let pyproject = PyProject::load(&self.project_root)?;
208 let project = pyproject
209 .project
210 .as_ref()
211 .ok_or(Error::MissingProjectMetadata)?;
212
213 let name = &project.name;
214 let version = project.version.as_ref().ok_or(Error::MissingVersion)?;
215
216 std::fs::create_dir_all(output_dir).map_err(Error::Io)?;
218
219 let sdist_name = format!("{}-{}.tar.gz", name, version);
221 let sdist_path = output_dir.join(&sdist_name);
222
223 let file = std::fs::File::create(&sdist_path).map_err(Error::Io)?;
225 let encoder = flate2::write::GzEncoder::new(file, flate2::Compression::default());
226 let mut tar = tar::Builder::new(encoder);
227
228 let base_dir = format!("{}-{}", name, version);
230
231 let pyproject_content =
233 std::fs::read_to_string(self.project_root.join("pyproject.toml")).map_err(Error::Io)?;
234 add_to_tar(
235 &mut tar,
236 &format!("{}/pyproject.toml", base_dir),
237 pyproject_content.as_bytes(),
238 )?;
239
240 let pkg_info = generate_pkg_info(&pyproject)?;
242 add_to_tar(
243 &mut tar,
244 &format!("{}/PKG-INFO", base_dir),
245 pkg_info.as_bytes(),
246 )?;
247
248 for readme in &["README.md", "README.rst", "README.txt", "README"] {
250 let readme_path = self.project_root.join(readme);
251 if readme_path.exists() {
252 let content = std::fs::read_to_string(&readme_path).map_err(Error::Io)?;
253 add_to_tar(
254 &mut tar,
255 &format!("{}/{}", base_dir, readme),
256 content.as_bytes(),
257 )?;
258 break;
259 }
260 }
261
262 for license in &["LICENSE", "LICENSE.txt", "LICENSE.md", "COPYING"] {
264 let license_path = self.project_root.join(license);
265 if license_path.exists() {
266 let content = std::fs::read_to_string(&license_path).map_err(Error::Io)?;
267 add_to_tar(
268 &mut tar,
269 &format!("{}/{}", base_dir, license),
270 content.as_bytes(),
271 )?;
272 break;
273 }
274 }
275
276 let src_dir = self.project_root.join("src");
278 if src_dir.exists() {
279 add_directory_to_tar(&mut tar, &src_dir, &format!("{}/src", base_dir))?;
280 } else {
281 let pkg_dir = self.project_root.join(name.replace('-', "_"));
283 if pkg_dir.exists() {
284 let pkg_name = pkg_dir.file_name().unwrap().to_string_lossy().to_string();
285 add_directory_to_tar(&mut tar, &pkg_dir, &format!("{}/{}", base_dir, pkg_name))?;
286 }
287 }
288
289 let tests_dir = self.project_root.join("tests");
291 if tests_dir.exists() {
292 add_directory_to_tar(&mut tar, &tests_dir, &format!("{}/tests", base_dir))?;
293 }
294
295 tar.finish().map_err(|e| Error::Tar(e.to_string()))?;
296
297 let size = std::fs::metadata(&sdist_path).map_err(Error::Io)?.len();
298
299 Ok(BuildResult {
300 path: sdist_path,
301 size,
302 })
303 }
304
305 pub fn project_root(&self) -> &Path {
307 &self.project_root
308 }
309}
310
311fn normalize_name(name: &str) -> String {
313 name.replace(['-', '.'], "_")
314}
315
316fn find_package_in_dir(dir: &Path) -> Option<PathBuf> {
318 if let Ok(entries) = std::fs::read_dir(dir) {
319 for entry in entries.flatten() {
320 let path = entry.path();
321 if path.is_dir() && path.join("__init__.py").exists() {
322 return Some(path);
323 }
324 }
325 }
326 None
327}
328
329fn find_local_package_dir(dep: &PathDependency, base_dir: &Path) -> Result<PathBuf> {
331 let resolved_path = dep.resolve_path(base_dir);
332 let normalized_name = dep.name.replace('-', "_");
333
334 let src_layout = resolved_path.join("src").join(&normalized_name);
336 if src_layout.exists() && src_layout.join("__init__.py").exists() {
337 return Ok(src_layout);
338 }
339
340 let flat_layout = resolved_path.join(&normalized_name);
342 if flat_layout.exists() && flat_layout.join("__init__.py").exists() {
343 return Ok(flat_layout);
344 }
345
346 let src_dir = resolved_path.join("src");
348 if src_dir.exists() {
349 if let Some(pkg) = find_package_in_dir(&src_dir) {
350 return Ok(pkg);
351 }
352 }
353
354 if let Some(pkg) = find_package_in_dir(&resolved_path) {
356 return Ok(pkg);
357 }
358
359 Err(Error::Config(format!(
360 "Could not find Python package for local dependency '{}' in {}",
361 dep.name,
362 resolved_path.display()
363 )))
364}
365
366fn add_file_to_zip<W: Write + std::io::Seek>(
368 zip: &mut ZipWriter<W>,
369 path: &str,
370 content: &[u8],
371 options: SimpleFileOptions,
372 records: &mut Vec<(String, String, u64)>,
373) -> Result<()> {
374 zip.start_file(path, options)
375 .map_err(|e| Error::Zip(e.to_string()))?;
376 zip.write_all(content).map_err(Error::Io)?;
377
378 let hash = base64_urlsafe_nopad(&Sha256::digest(content));
379 records.push((path.to_string(), hash, content.len() as u64));
380
381 Ok(())
382}
383
384fn add_directory_to_zip<W: Write + std::io::Seek>(
386 zip: &mut ZipWriter<W>,
387 dir: &Path,
388 prefix: &str,
389 options: SimpleFileOptions,
390 records: &mut Vec<(String, String, u64)>,
391) -> Result<()> {
392 for entry in walkdir::WalkDir::new(dir)
393 .into_iter()
394 .filter_map(|e| e.ok())
395 {
396 let path = entry.path();
397
398 if path.to_string_lossy().contains("__pycache__") {
400 continue;
401 }
402 if let Some(ext) = path.extension() {
403 if ext == "pyc" || ext == "pyo" {
404 continue;
405 }
406 }
407
408 if path.is_file() {
409 let relative = path.strip_prefix(dir).unwrap();
410 let archive_path = format!(
411 "{}/{}",
412 prefix,
413 relative.to_string_lossy().replace('\\', "/")
414 );
415
416 let mut content = Vec::new();
417 std::fs::File::open(path)
418 .map_err(Error::Io)?
419 .read_to_end(&mut content)
420 .map_err(Error::Io)?;
421
422 add_file_to_zip(zip, &archive_path, &content, options, records)?;
423 }
424 }
425
426 Ok(())
427}
428
429fn generate_metadata(pyproject: &PyProject) -> Result<String> {
431 let project = pyproject
432 .project
433 .as_ref()
434 .ok_or(Error::MissingProjectMetadata)?;
435
436 let mut metadata = String::new();
437 metadata.push_str("Metadata-Version: 2.1\n");
438 metadata.push_str(&format!("Name: {}\n", project.name));
439
440 if let Some(ref version) = project.version {
441 metadata.push_str(&format!("Version: {}\n", version));
442 }
443
444 if let Some(ref description) = project.description {
445 metadata.push_str(&format!("Summary: {}\n", description));
446 }
447
448 if let Some(ref requires_python) = project.requires_python {
449 metadata.push_str(&format!("Requires-Python: {}\n", requires_python));
450 }
451
452 for author in &project.authors {
454 if let Some(ref name) = author.name {
455 if let Some(ref email) = author.email {
456 metadata.push_str(&format!("Author-email: {} <{}>\n", name, email));
457 } else {
458 metadata.push_str(&format!("Author: {}\n", name));
459 }
460 }
461 }
462
463 if let Some(ref license) = project.license {
465 match license {
466 crate::pep::License::Text { text } => {
467 metadata.push_str(&format!("License: {}\n", text));
468 }
469 crate::pep::License::File { .. } => {
470 }
472 }
473 }
474
475 for classifier in &project.classifiers {
477 metadata.push_str(&format!("Classifier: {}\n", classifier));
478 }
479
480 if !project.keywords.is_empty() {
482 metadata.push_str(&format!("Keywords: {}\n", project.keywords.join(",")));
483 }
484
485 for (label, url) in &project.urls {
487 metadata.push_str(&format!("Project-URL: {}, {}\n", label, url));
488 }
489
490 for dep in &project.dependencies {
492 metadata.push_str(&format!("Requires-Dist: {}\n", dep));
493 }
494
495 for (extra, deps) in &project.optional_dependencies {
497 for dep in deps {
498 metadata.push_str(&format!(
499 "Requires-Dist: {} ; extra == \"{}\"\n",
500 dep, extra
501 ));
502 }
503 metadata.push_str(&format!("Provides-Extra: {}\n", extra));
504 }
505
506 if let Some(ref readme) = project.readme {
508 match readme {
509 crate::pep::Readme::Path(path) => {
510 let readme_path = pyproject
511 .project
512 .as_ref()
513 .map(|_| Path::new(path))
514 .unwrap_or(Path::new(path));
515
516 if let Ok(content) = std::fs::read_to_string(readme_path) {
517 let content_type = if path.ends_with(".md") {
518 "text/markdown"
519 } else if path.ends_with(".rst") {
520 "text/x-rst"
521 } else {
522 "text/plain"
523 };
524 metadata.push_str(&format!("Description-Content-Type: {}\n", content_type));
525 metadata.push('\n');
526 metadata.push_str(&content);
527 }
528 }
529 crate::pep::Readme::Inline {
530 text, content_type, ..
531 } => {
532 if let Some(ref ct) = content_type {
533 metadata.push_str(&format!("Description-Content-Type: {}\n", ct));
534 }
535 if let Some(ref t) = text {
536 metadata.push('\n');
537 metadata.push_str(t);
538 }
539 }
540 }
541 }
542
543 Ok(metadata)
544}
545
546fn generate_wheel_file() -> String {
548 let mut wheel = String::new();
549 wheel.push_str("Wheel-Version: 1.0\n");
550 wheel.push_str("Generator: rx (Pro)\n");
551 wheel.push_str("Root-Is-Purelib: true\n");
552 wheel.push_str("Tag: py3-none-any\n");
553 wheel
554}
555
556fn generate_entry_points(project: &crate::pep::ProjectMetadata) -> String {
558 let mut content = String::new();
559
560 if !project.scripts.is_empty() {
561 content.push_str("[console_scripts]\n");
562 for (name, entry) in &project.scripts {
563 content.push_str(&format!("{} = {}\n", name, entry));
564 }
565 }
566
567 if !project.gui_scripts.is_empty() {
568 if !content.is_empty() {
569 content.push('\n');
570 }
571 content.push_str("[gui_scripts]\n");
572 for (name, entry) in &project.gui_scripts {
573 content.push_str(&format!("{} = {}\n", name, entry));
574 }
575 }
576
577 for (group, entries) in &project.entry_points {
578 if !content.is_empty() {
579 content.push('\n');
580 }
581 content.push_str(&format!("[{}]\n", group));
582 for (name, entry) in entries {
583 content.push_str(&format!("{} = {}\n", name, entry));
584 }
585 }
586
587 content
588}
589
590fn generate_pkg_info(pyproject: &PyProject) -> Result<String> {
592 generate_metadata(pyproject)
594}
595
596fn add_to_tar<W: Write>(tar: &mut tar::Builder<W>, path: &str, content: &[u8]) -> Result<()> {
598 let mut header = tar::Header::new_gnu();
599 header
600 .set_path(path)
601 .map_err(|e| Error::Tar(e.to_string()))?;
602 header.set_size(content.len() as u64);
603 header.set_mode(0o644);
604 header.set_mtime(0);
605 header.set_cksum();
606
607 tar.append(&header, content)
608 .map_err(|e| Error::Tar(e.to_string()))?;
609
610 Ok(())
611}
612
613fn add_directory_to_tar<W: Write>(
615 tar: &mut tar::Builder<W>,
616 dir: &Path,
617 prefix: &str,
618) -> Result<()> {
619 for entry in walkdir::WalkDir::new(dir)
620 .into_iter()
621 .filter_map(|e| e.ok())
622 {
623 let path = entry.path();
624
625 if path.to_string_lossy().contains("__pycache__") {
627 continue;
628 }
629 if let Some(ext) = path.extension() {
630 if ext == "pyc" || ext == "pyo" {
631 continue;
632 }
633 }
634
635 if path.is_file() {
636 let relative = path.strip_prefix(dir).unwrap();
637 let archive_path = format!(
638 "{}/{}",
639 prefix,
640 relative.to_string_lossy().replace('\\', "/")
641 );
642
643 let content = std::fs::read(path).map_err(Error::Io)?;
644 add_to_tar(tar, &archive_path, &content)?;
645 }
646 }
647
648 Ok(())
649}
650
651fn base64_urlsafe_nopad(data: &[u8]) -> String {
653 use base64::Engine;
654 base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(data)
655}
656
657#[cfg(test)]
658mod tests {
659 use super::*;
660
661 #[test]
662 fn test_normalize_name() {
663 assert_eq!(normalize_name("my-package"), "my_package");
664 assert_eq!(normalize_name("my.package"), "my_package");
665 assert_eq!(normalize_name("mypackage"), "mypackage");
666 }
667}