pyoxidizerlib/
projectmgmt.rs

1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
5//! Manage PyOxidizer projects.
6
7use {
8    crate::{
9        environment::{canonicalize_path, default_target_triple, Environment, PyOxidizerSource},
10        licensing::{licenses_from_cargo_manifest, log_licensing_info},
11        project_building::find_pyoxidizer_config_file_env,
12        project_layout::{initialize_project, write_new_pyoxidizer_config_file},
13        py_packaging::{
14            distribution::{
15                default_distribution_location, resolve_distribution,
16                resolve_python_distribution_archive, BinaryLibpythonLinkMode, DistributionCache,
17                DistributionFlavor, PythonDistribution,
18            },
19            standalone_distribution::StandaloneDistribution,
20        },
21        python_distributions::PYTHON_DISTRIBUTIONS,
22        starlark::eval::EvaluationContextBuilder,
23    },
24    anyhow::{anyhow, Context, Result},
25    python_packaging::licensing::LicenseFlavor,
26    python_packaging::{
27        filesystem_scanning::find_python_resources,
28        interpreter::{MemoryAllocatorBackend, PythonInterpreterProfile},
29        resource::PythonResource,
30        wheel::WheelArchive,
31    },
32    simple_file_manifest::{FileData, FileManifest},
33    std::{
34        collections::HashMap,
35        fs::create_dir_all,
36        io::{Cursor, Read},
37        path::{Path, PathBuf},
38    },
39};
40
41/// Attempt to resolve the default Rust target for a build.
42pub fn default_target() -> Result<String> {
43    // TODO derive these more intelligently.
44    if cfg!(target_os = "linux") {
45        if cfg!(target_arch = "aarch64") {
46            Ok("aarch64-unknown-linux-gnu".to_string())
47        } else {
48            Ok("x86_64-unknown-linux-gnu".to_string())
49        }
50    } else if cfg!(target_os = "windows") {
51        Ok("x86_64-pc-windows-msvc".to_string())
52    } else if cfg!(target_os = "macos") {
53        if cfg!(target_arch = "aarch64") {
54            Ok("aarch64-apple-darwin".to_string())
55        } else {
56            Ok("x86_64-apple-darwin".to_string())
57        }
58    } else {
59        Err(anyhow!("unable to resolve target"))
60    }
61}
62
63pub fn resolve_target(target: Option<&str>) -> Result<String> {
64    if let Some(s) = target {
65        Ok(s.to_string())
66    } else {
67        default_target()
68    }
69}
70
71pub fn list_targets(env: &Environment, project_path: &Path) -> Result<()> {
72    let config_path = find_pyoxidizer_config_file_env(project_path).ok_or_else(|| {
73        anyhow!(
74            "unable to find PyOxidizder config file at {}",
75            project_path.display()
76        )
77    })?;
78
79    let target_triple = default_target()?;
80
81    let mut context = EvaluationContextBuilder::new(env, config_path.clone(), target_triple)
82        .resolve_targets(vec![])
83        .into_context()?;
84
85    context.evaluate_file(&config_path)?;
86
87    if context.default_target()?.is_none() {
88        println!("(no targets defined)");
89        return Ok(());
90    }
91
92    for target in context.target_names()? {
93        let prefix = if Some(target.clone()) == context.default_target()? {
94            "*"
95        } else {
96            ""
97        };
98        println!("{}{}", prefix, target);
99    }
100
101    Ok(())
102}
103
104/// Build a PyOxidizer enabled project.
105///
106/// This is a glorified wrapper around `cargo build`. Our goal is to get the
107/// output from repackaging to give the user something for debugging.
108#[allow(clippy::too_many_arguments)]
109pub fn build(
110    env: &Environment,
111    project_path: &Path,
112    target_triple: Option<&str>,
113    resolve_targets: Option<Vec<String>>,
114    extra_vars: HashMap<String, Option<String>>,
115    release: bool,
116    verbose: bool,
117) -> Result<()> {
118    let config_path = find_pyoxidizer_config_file_env(project_path).ok_or_else(|| {
119        anyhow!(
120            "unable to find PyOxidizer config file at {}",
121            project_path.display()
122        )
123    })?;
124    let target_triple = resolve_target(target_triple)?;
125
126    let mut context = EvaluationContextBuilder::new(env, config_path.clone(), target_triple)
127        .extra_vars(extra_vars)
128        .release(release)
129        .verbose(verbose)
130        .resolve_targets_optional(resolve_targets)
131        .into_context()?;
132
133    context.evaluate_file(&config_path)?;
134
135    for target in context.targets_to_resolve()? {
136        context.build_resolved_target(&target)?;
137    }
138
139    Ok(())
140}
141
142#[allow(clippy::too_many_arguments)]
143pub fn run(
144    env: &Environment,
145    project_path: &Path,
146    target_triple: Option<&str>,
147    release: bool,
148    target: Option<&str>,
149    extra_vars: HashMap<String, Option<String>>,
150    _extra_args: &[&str],
151    verbose: bool,
152) -> Result<()> {
153    let config_path = find_pyoxidizer_config_file_env(project_path).ok_or_else(|| {
154        anyhow!(
155            "unable to find PyOxidizer config file at {}",
156            project_path.display()
157        )
158    })?;
159    let target_triple = resolve_target(target_triple)?;
160
161    let mut context = EvaluationContextBuilder::new(env, config_path.clone(), target_triple)
162        .extra_vars(extra_vars)
163        .release(release)
164        .verbose(verbose)
165        .resolve_target_optional(target)
166        .into_context()?;
167
168    context.evaluate_file(&config_path)?;
169
170    context.run_target(target)
171}
172
173pub fn cache_clear(env: &Environment) -> Result<()> {
174    let cache_dir = env.cache_dir();
175
176    println!("removing {}", cache_dir.display());
177    remove_dir_all::remove_dir_all(cache_dir)?;
178
179    Ok(())
180}
181
182/// Find resources given a source path.
183pub fn find_resources(
184    env: &Environment,
185    path: Option<&Path>,
186    distributions_dir: Option<&Path>,
187    scan_distribution: bool,
188    target_triple: &str,
189    classify_files: bool,
190    emit_files: bool,
191) -> Result<()> {
192    let distribution_location =
193        default_distribution_location(&DistributionFlavor::Standalone, target_triple, None)?;
194
195    let mut temp_dir = None;
196
197    let extract_path = if let Some(path) = distributions_dir {
198        path
199    } else {
200        temp_dir.replace(env.temporary_directory("python-distribution")?);
201        temp_dir.as_ref().unwrap().path()
202    };
203
204    let dist = resolve_distribution(&distribution_location, extract_path)?;
205
206    if scan_distribution {
207        println!("scanning distribution");
208        for resource in dist.python_resources() {
209            print_resource(&resource);
210        }
211    } else if let Some(path) = path {
212        if path.is_dir() {
213            println!("scanning directory {}", path.display());
214            for resource in find_python_resources(
215                path,
216                dist.cache_tag(),
217                &dist.python_module_suffixes()?,
218                emit_files,
219                classify_files,
220            )? {
221                print_resource(&resource?);
222            }
223        } else if path.is_file() {
224            if let Some(extension) = path.extension() {
225                if extension.to_string_lossy() == "whl" {
226                    println!("parsing {} as a wheel archive", path.display());
227                    let wheel = WheelArchive::from_path(path)?;
228
229                    for resource in wheel.python_resources(
230                        dist.cache_tag(),
231                        &dist.python_module_suffixes()?,
232                        emit_files,
233                        classify_files,
234                    )? {
235                        print_resource(&resource)
236                    }
237
238                    return Ok(());
239                }
240            }
241
242            println!("do not know how to find resources in {}", path.display());
243        } else {
244            println!("do not know how to find resources in {}", path.display());
245        }
246    } else {
247        println!("do not know what to scan");
248    }
249
250    Ok(())
251}
252
253fn print_resource(r: &PythonResource) {
254    match r {
255        PythonResource::ModuleSource(m) => println!(
256            "PythonModuleSource {{ name: {}, is_package: {}, is_stdlib: {}, is_test: {} }}",
257            m.name, m.is_package, m.is_stdlib, m.is_test
258        ),
259        PythonResource::ModuleBytecode(m) => println!(
260            "PythonModuleBytecode {{ name: {}, is_package: {}, is_stdlib: {}, is_test: {}, bytecode_level: {} }}",
261            m.name, m.is_package, m.is_stdlib, m.is_test, i32::from(m.optimize_level)
262        ),
263        PythonResource::ModuleBytecodeRequest(_) => println!(
264            "PythonModuleBytecodeRequest {{ you should never see this }}"
265        ),
266        PythonResource::PackageResource(r) => println!(
267            "PythonPackageResource {{ package: {}, name: {}, is_stdlib: {}, is_test: {} }}", r.leaf_package, r.relative_name, r.is_stdlib, r.is_test
268        ),
269        PythonResource::PackageDistributionResource(r) => println!(
270            "PythonPackageDistributionResource {{ package: {}, version: {}, name: {} }}", r.package, r.version, r.name
271        ),
272        PythonResource::ExtensionModule(em) => {
273            println!(
274                "PythonExtensionModule {{"
275            );
276            println!("    name: {}", em.name);
277            println!("    is_builtin: {}", em.builtin_default);
278            println!("    has_shared_library: {}", em.shared_library.is_some());
279            println!("    has_object_files: {}", !em.object_file_data.is_empty());
280            println!("    link_libraries: {:?}", em.link_libraries);
281            println!("}}");
282        },
283        PythonResource::EggFile(e) => println!(
284            "PythonEggFile {{ path: {} }}", match &e.data {
285                FileData::Path(p) => p.display().to_string(),
286                FileData::Memory(_) => "memory".to_string(),
287            }
288        ),
289        PythonResource::PathExtension(_pe) => println!(
290            "PythonPathExtension",
291        ),
292        PythonResource::File(f) => println!(
293            "File {{ path: {}, is_executable: {} }}", f.path().display(), f.entry().is_executable()
294        ),
295    }
296}
297
298/// Initialize a PyOxidizer configuration file in a given directory.
299pub fn init_config_file(
300    source: &PyOxidizerSource,
301    project_dir: &Path,
302    code: Option<&str>,
303    pip_install: &[&str],
304) -> Result<()> {
305    if project_dir.exists() && !project_dir.is_dir() {
306        return Err(anyhow!(
307            "existing path must be a directory: {}",
308            project_dir.display()
309        ));
310    }
311
312    if !project_dir.exists() {
313        create_dir_all(project_dir)?;
314    }
315
316    let name = project_dir.iter().last().unwrap().to_str().unwrap();
317
318    write_new_pyoxidizer_config_file(source, project_dir, name, code, pip_install)?;
319
320    println!();
321    println!("A new PyOxidizer configuration file has been created.");
322    println!("This configuration file can be used by various `pyoxidizer`");
323    println!("commands");
324    println!();
325    println!("For example, to build and run the default Python application:");
326    println!();
327    println!("  $ cd {}", project_dir.display());
328    println!("  $ pyoxidizer run");
329    println!();
330    println!("The default configuration is to invoke a Python REPL. You can");
331    println!("edit the configuration file to change behavior.");
332
333    Ok(())
334}
335
336/// Initialize a new Rust project with PyOxidizer support.
337pub fn init_rust_project(env: &Environment, project_path: &Path) -> Result<()> {
338    let cargo_exe = env
339        .ensure_rust_toolchain(None)
340        .context("resolving Rust environment")?
341        .cargo_exe;
342
343    initialize_project(
344        &env.pyoxidizer_source,
345        project_path,
346        &cargo_exe,
347        None,
348        &[],
349        "console",
350    )?;
351    println!();
352    println!(
353        "A new Rust binary application has been created in {}",
354        project_path.display()
355    );
356    print!(
357        r#"
358This application can be built most easily by doing the following:
359
360  $ cd {project_path}
361  $ pyoxidizer run
362
363Note however that this will bypass all the Rust code in the project
364folder, and build the project as if you had only created a pyoxidizer.bzl
365file. Building from Rust is more involved, and requires multiple steps.
366Please see the "PyOxidizer Rust Projects" section of the manual for more
367information.
368
369The default configuration is to invoke a Python REPL. You can
370edit the various pyoxidizer.*.bzl config files or the main.rs
371file to change behavior. The application will need to be rebuilt
372for configuration changes to take effect.
373"#,
374        project_path = project_path.display()
375    );
376
377    Ok(())
378}
379
380pub fn python_distribution_extract(
381    download_default: bool,
382    archive_path: Option<&str>,
383    dest_path: &str,
384) -> Result<()> {
385    let dist_path = if let Some(path) = archive_path {
386        PathBuf::from(path)
387    } else if download_default {
388        let location = default_distribution_location(
389            &DistributionFlavor::Standalone,
390            default_target_triple(),
391            None,
392        )?;
393
394        resolve_python_distribution_archive(&location, Path::new(dest_path))?
395    } else {
396        return Err(anyhow!("do not know what distribution to operate on"));
397    };
398
399    let mut fh = std::fs::File::open(&dist_path)?;
400    let mut data = Vec::new();
401    fh.read_to_end(&mut data)?;
402    let cursor = Cursor::new(data);
403    let dctx = zstd::stream::Decoder::new(cursor)?;
404    let mut tf = tar::Archive::new(dctx);
405
406    println!("extracting archive to {}", dest_path);
407    tf.unpack(dest_path)?;
408
409    Ok(())
410}
411
412pub fn python_distribution_info(env: &Environment, dist_path: &str) -> Result<()> {
413    let fh = std::fs::File::open(Path::new(dist_path))?;
414    let reader = std::io::BufReader::new(fh);
415
416    let temp_dir = env.temporary_directory("python-distribution")?;
417    let temp_dir_path = temp_dir.path();
418
419    let dist = StandaloneDistribution::from_tar_zst(reader, temp_dir_path)?;
420
421    println!("High-Level Metadata");
422    println!("===================");
423    println!();
424    println!("Target triple: {}", dist.target_triple);
425    println!("Tag:           {}", dist.python_tag);
426    println!("Platform tag:  {}", dist.python_platform_tag);
427    println!("Version:       {}", dist.version);
428    println!();
429
430    println!("Extension Modules");
431    println!("=================");
432    for (name, ems) in dist.extension_modules {
433        println!("{}", name);
434        println!("{}", "-".repeat(name.len()));
435        println!();
436
437        for em in ems.iter() {
438            println!("{}", em.variant.as_ref().unwrap());
439            println!("{}", "^".repeat(em.variant.as_ref().unwrap().len()));
440            println!();
441            println!("Required: {}", em.required);
442            println!("Built-in Default: {}", em.builtin_default);
443            if let Some(component) = &em.license {
444                println!(
445                    "Licensing: {}",
446                    match component.license() {
447                        LicenseFlavor::Spdx(expression) => expression.to_string(),
448                        LicenseFlavor::OtherExpression(expression) => expression.to_string(),
449                        LicenseFlavor::PublicDomain => "public domain".to_string(),
450                        LicenseFlavor::None => "none".to_string(),
451                        LicenseFlavor::Unknown(terms) => terms.join(","),
452                    }
453                );
454            }
455            if !em.link_libraries.is_empty() {
456                println!(
457                    "Links: {}",
458                    em.link_libraries
459                        .iter()
460                        .map(|l| l.name.clone())
461                        .collect::<Vec<String>>()
462                        .join(", ")
463                );
464            }
465
466            println!();
467        }
468    }
469
470    println!("Python Modules");
471    println!("==============");
472    println!();
473    for name in dist.py_modules.keys() {
474        println!("{}", name);
475    }
476    println!();
477
478    println!("Python Resources");
479    println!("================");
480    println!();
481
482    for (package, resources) in dist.resources {
483        for name in resources.keys() {
484            println!("[{}].{}", package, name);
485        }
486    }
487
488    Ok(())
489}
490
491pub fn python_distribution_licenses(env: &Environment, path: &str) -> Result<()> {
492    let fh = std::fs::File::open(Path::new(path))?;
493    let reader = std::io::BufReader::new(fh);
494
495    let temp_dir = env.temporary_directory("python-distribution")?;
496    let temp_dir_path = temp_dir.path();
497
498    let dist = StandaloneDistribution::from_tar_zst(reader, temp_dir_path)?;
499
500    println!(
501        "Python Distribution Licenses: {}",
502        match dist.licenses {
503            Some(licenses) => itertools::join(licenses, ", "),
504            None => "NO LICENSE FOUND".to_string(),
505        }
506    );
507    println!();
508    println!("Extension Libraries and License Requirements");
509    println!("============================================");
510    println!();
511
512    for (name, variants) in &dist.extension_modules {
513        for variant in variants.iter() {
514            if variant.link_libraries.is_empty() {
515                continue;
516            }
517
518            let name = if variant.variant.as_ref().unwrap() == "default" {
519                name.clone()
520            } else {
521                format!("{} ({})", name, variant.variant.as_ref().unwrap())
522            };
523
524            println!("{}", name);
525            println!("{}", "-".repeat(name.len()));
526            println!();
527
528            for link in &variant.link_libraries {
529                println!("Dependency: {}", &link.name);
530                println!(
531                    "Link Type: {}",
532                    if link.system {
533                        "system"
534                    } else if link.framework {
535                        "framework"
536                    } else {
537                        "library"
538                    }
539                );
540
541                println!();
542            }
543
544            if let Some(component) = &variant.license {
545                match component.license() {
546                    LicenseFlavor::Spdx(expression) => {
547                        println!("Licensing: Valid SPDX: {}", expression);
548                    }
549                    LicenseFlavor::OtherExpression(expression) => {
550                        println!("Licensing: Invalid SPDX: {}", expression);
551                    }
552                    LicenseFlavor::PublicDomain => {
553                        println!("Licensing: Public Domain");
554                    }
555                    LicenseFlavor::None => {
556                        println!("Licensing: None defined");
557                    }
558                    LicenseFlavor::Unknown(terms) => {
559                        println!("Licensing: {}", terms.join(", "));
560                    }
561                }
562            } else {
563                println!("Licensing: UNKNOWN");
564            }
565
566            println!();
567        }
568    }
569
570    Ok(())
571}
572
573/// Generate artifacts for embedding Python in a binary.
574pub fn generate_python_embedding_artifacts(
575    env: &Environment,
576    target_triple: &str,
577    flavor: &str,
578    python_version: Option<&str>,
579    dest_path: &Path,
580) -> Result<()> {
581    let flavor = DistributionFlavor::try_from(flavor).map_err(|e| anyhow!("{}", e))?;
582
583    std::fs::create_dir_all(dest_path)
584        .with_context(|| format!("creating directory {}", dest_path.display()))?;
585
586    let dest_path = canonicalize_path(dest_path).context("canonicalizing destination directory")?;
587
588    let distribution_record = PYTHON_DISTRIBUTIONS
589        .find_distribution(target_triple, &flavor, python_version)
590        .ok_or_else(|| anyhow!("could not find Python distribution matching requirements"))?;
591
592    let distribution_cache = DistributionCache::new(Some(&env.python_distributions_dir()));
593
594    let dist = distribution_cache
595        .resolve_distribution(&distribution_record.location, None)
596        .context("resolving Python distribution")?;
597
598    let host_dist = distribution_cache
599        .host_distribution(Some(dist.python_major_minor_version().as_str()), None)
600        .context("resolving host distribution")?;
601
602    let policy = dist
603        .create_packaging_policy()
604        .context("creating packaging policy")?;
605
606    let mut interpreter_config = dist
607        .create_python_interpreter_config()
608        .context("creating Python interpreter config")?;
609
610    interpreter_config.config.profile = PythonInterpreterProfile::Python;
611    interpreter_config.allocator_backend = MemoryAllocatorBackend::Default;
612
613    let mut builder = dist.as_python_executable_builder(
614        default_target_triple(),
615        target_triple,
616        "python",
617        BinaryLibpythonLinkMode::Default,
618        &policy,
619        &interpreter_config,
620        Some(host_dist.clone_trait()),
621    )?;
622
623    builder.set_tcl_files_path(Some("tcl".to_string()));
624
625    builder
626        .add_distribution_resources(None)
627        .context("adding distribution resources")?;
628
629    let embedded_context = builder
630        .to_embedded_python_context(env, "1")
631        .context("resolving embedded context")?;
632
633    embedded_context
634        .write_files(&dest_path)
635        .context("writing embedded artifact files")?;
636
637    embedded_context
638        .extra_files
639        .materialize_files(&dest_path)
640        .context("writing extra files")?;
641
642    // Write out a copy of the standard library.
643    let mut m = FileManifest::default();
644    for resource in find_python_resources(
645        &dist.stdlib_path,
646        dist.cache_tag(),
647        &dist.python_module_suffixes()?,
648        true,
649        false,
650    )? {
651        if let PythonResource::File(file) = resource? {
652            m.add_file_entry(file.path(), file.entry())?;
653        } else {
654            panic!("find_python_resources() should only emit File variant");
655        }
656    }
657
658    m.materialize_files_with_replace(dest_path.join("stdlib"))
659        .context("writing standard library")?;
660
661    Ok(())
662}
663
664pub fn rust_project_licensing(
665    env: &Environment,
666    project_path: &Path,
667    all_features: bool,
668    target_triple: Option<&str>,
669    unified_license: bool,
670) -> Result<()> {
671    let manifest_path = project_path.join("Cargo.toml");
672
673    let toolchain = env
674        .ensure_rust_toolchain(None)
675        .context("resolving Rust toolchain")?;
676
677    let licensing = licenses_from_cargo_manifest(
678        &manifest_path,
679        all_features,
680        [],
681        target_triple,
682        &toolchain,
683        true,
684    )?;
685
686    if unified_license {
687        println!("{}", licensing.aggregate_license_document(true)?);
688    } else {
689        log_licensing_info(&licensing);
690    }
691
692    Ok(())
693}