Skip to main content

pro_core/builder/
mod.rs

1//! Native Rust build backend for Python packages (PEP 517)
2//!
3//! Supports bundling local path dependencies (non-editable) into wheels
4//! for monorepo deployments.
5
6use 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
17/// Build backend for creating wheels and sdists
18pub struct Builder {
19    /// Project root directory
20    project_root: PathBuf,
21    /// Whether to include local path dependencies in the wheel
22    include_local_deps: bool,
23}
24
25/// Result of a build operation
26#[derive(Debug)]
27pub struct BuildResult {
28    /// Path to the built artifact
29    pub path: PathBuf,
30    /// Size of the artifact in bytes
31    pub size: u64,
32}
33
34impl Builder {
35    /// Create a new builder for the given project
36    pub fn new(project_root: impl Into<PathBuf>) -> Self {
37        Self {
38            project_root: project_root.into(),
39            include_local_deps: true, // Default to including local deps
40        }
41    }
42
43    /// Set whether to include local path dependencies in the wheel
44    pub fn with_include_local_deps(mut self, include: bool) -> Self {
45        self.include_local_deps = include;
46        self
47    }
48
49    /// Build a wheel (PEP 427)
50    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        // Normalize name for wheel filename (PEP 427)
61        let normalized_name = normalize_name(name);
62
63        // Create output directory
64        std::fs::create_dir_all(output_dir).map_err(Error::Io)?;
65
66        // Wheel filename: {distribution}-{version}-{python}-{abi}-{platform}.whl
67        let wheel_name = format!("{}-{}-py3-none-any.whl", normalized_name, version);
68        let wheel_path = output_dir.join(&wheel_name);
69
70        // Create the wheel zip
71        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        // Track files for RECORD
77        let mut records: Vec<(String, String, u64)> = Vec::new();
78
79        // Find and add package source files
80        let src_dir = self.project_root.join("src");
81        let package_dir = if src_dir.exists() {
82            // src layout: src/package_name/
83            let pkg_dir = src_dir.join(name.replace('-', "_"));
84            if pkg_dir.exists() {
85                Some(pkg_dir)
86            } else {
87                // Try finding any package in src/
88                find_package_in_dir(&src_dir)
89            }
90        } else {
91            // Flat layout: package_name/ at project root
92            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        // Include local path dependencies (non-editable only)
106        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                // Only include non-editable dependencies
110                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        // Create dist-info directory
131        let dist_info = format!("{}-{}.dist-info", normalized_name, version);
132
133        // METADATA (PEP 566)
134        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        // WHEEL file
145        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        // entry_points.txt (if scripts defined)
156        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        // top_level.txt
169        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        // RECORD (must be last, contains all file hashes)
182        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 itself has no hash
188        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    /// Build a source distribution (PEP 517)
206    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        // Create output directory
217        std::fs::create_dir_all(output_dir).map_err(Error::Io)?;
218
219        // Sdist filename: {name}-{version}.tar.gz
220        let sdist_name = format!("{}-{}.tar.gz", name, version);
221        let sdist_path = output_dir.join(&sdist_name);
222
223        // Create tar.gz
224        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        // Base directory in the archive
229        let base_dir = format!("{}-{}", name, version);
230
231        // Add pyproject.toml
232        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        // Add PKG-INFO
241        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        // Add README if exists
249        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        // Add LICENSE if exists
263        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        // Add source files
277        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            // Flat layout - add package directory
282            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        // Add tests if they exist
290        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    /// Get the project root
306    pub fn project_root(&self) -> &Path {
307        &self.project_root
308    }
309}
310
311/// Normalize package name for wheel filename (PEP 427)
312fn normalize_name(name: &str) -> String {
313    name.replace(['-', '.'], "_")
314}
315
316/// Find a Python package directory in the given directory
317fn 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
329/// Find the package directory for a local path dependency
330fn 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    // Try src layout first: src/<name>/
335    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    // Try flat layout: <name>/
341    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    // Try to find any package in src/
347    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    // Try to find any package at root
355    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
366/// Add a file to the zip archive and record its hash
367fn 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
384/// Add a directory recursively to the zip archive
385fn 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        // Skip __pycache__ and .pyc files
399        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
429/// Generate METADATA file content (PEP 566)
430fn 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    // Authors
453    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    // License
464    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                // License file is included separately
471            }
472        }
473    }
474
475    // Classifiers
476    for classifier in &project.classifiers {
477        metadata.push_str(&format!("Classifier: {}\n", classifier));
478    }
479
480    // Keywords
481    if !project.keywords.is_empty() {
482        metadata.push_str(&format!("Keywords: {}\n", project.keywords.join(",")));
483    }
484
485    // URLs
486    for (label, url) in &project.urls {
487        metadata.push_str(&format!("Project-URL: {}, {}\n", label, url));
488    }
489
490    // Dependencies
491    for dep in &project.dependencies {
492        metadata.push_str(&format!("Requires-Dist: {}\n", dep));
493    }
494
495    // Optional dependencies (extras)
496    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    // Long description (from README)
507    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
546/// Generate WHEEL file content
547fn 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
556/// Generate entry_points.txt content
557fn 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
590/// Generate PKG-INFO for sdist
591fn generate_pkg_info(pyproject: &PyProject) -> Result<String> {
592    // PKG-INFO is essentially the same as METADATA
593    generate_metadata(pyproject)
594}
595
596/// Add content to tar archive
597fn 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
613/// Add directory recursively to tar archive
614fn 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        // Skip __pycache__ and .pyc files
626        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
651/// Base64 URL-safe encoding without padding (for RECORD hashes)
652fn 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}