pyoxidizerlib/
project_layout.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//! Handle file layout of PyOxidizer projects.
6
7use {
8    crate::environment::{PyOxidizerSource, BUILD_GIT_COMMIT, PYOXIDIZER_VERSION},
9    anyhow::{anyhow, Context, Result},
10    handlebars::Handlebars,
11    once_cell::sync::Lazy,
12    serde::Serialize,
13    std::{
14        collections::BTreeMap,
15        io::Write,
16        path::{Path, PathBuf},
17        str::FromStr,
18    },
19};
20
21static HANDLEBARS: Lazy<Handlebars<'static>> = Lazy::new(|| {
22    let mut handlebars = Handlebars::new();
23
24    handlebars
25        .register_template_string(
26            "application-manifest.rc",
27            include_str!("templates/application-manifest.rc.hbs"),
28        )
29        .unwrap();
30    handlebars
31        .register_template_string(
32            "cargo-extra.toml",
33            include_str!("templates/cargo-extra.toml.hbs"),
34        )
35        .unwrap();
36    handlebars
37        .register_template_string("exe.manifest", include_str!("templates/exe.manifest.hbs"))
38        .unwrap();
39    handlebars
40        .register_template_string("new-build.rs", include_str!("templates/new-build.rs.hbs"))
41        .unwrap();
42    handlebars
43        .register_template_string(
44            "new-cargo-config",
45            include_str!("templates/new-cargo-config.hbs"),
46        )
47        .unwrap();
48    handlebars
49        .register_template_string("new-main.rs", include_str!("templates/new-main.rs.hbs"))
50        .unwrap();
51    handlebars
52        .register_template_string(
53            "new-pyoxidizer.bzl",
54            include_str!("templates/new-pyoxidizer.bzl.hbs"),
55        )
56        .unwrap();
57
58    handlebars
59});
60
61/// Contents of Cargo.lock file for the new Rust projects.
62const NEW_PROJECT_CARGO_LOCK: &str = include_str!("new-project-cargo.lock");
63
64/// Package dependencies of new Rust projects to be recorded in the Cargo.lock.
65const NEW_PROJECT_DEPENDENCIES: &[&str] = &[
66    "embed-resource",
67    "jemallocator",
68    "mimalloc",
69    "pyembed",
70    "snmalloc-rs",
71];
72
73#[derive(Serialize)]
74struct PythonDistribution {
75    build_target: String,
76    url: String,
77    sha256: String,
78}
79
80#[derive(Serialize)]
81struct TemplateData {
82    pyoxidizer_version: Option<String>,
83    pyoxidizer_commit: Option<String>,
84    pyoxidizer_local_repo_path: Option<String>,
85    pyoxidizer_git_url: Option<String>,
86    pyoxidizer_git_commit: Option<String>,
87    pyoxidizer_git_tag: Option<String>,
88
89    python_distributions: Vec<PythonDistribution>,
90    program_name: Option<String>,
91    code: Option<String>,
92    pip_install_simple: Vec<String>,
93}
94
95impl TemplateData {
96    fn new() -> TemplateData {
97        TemplateData {
98            pyoxidizer_version: None,
99            pyoxidizer_commit: None,
100            pyoxidizer_local_repo_path: None,
101            pyoxidizer_git_url: None,
102            pyoxidizer_git_commit: None,
103            pyoxidizer_git_tag: None,
104            python_distributions: Vec::new(),
105            program_name: None,
106            code: None,
107            pip_install_simple: Vec::new(),
108        }
109    }
110}
111
112fn populate_template_data(source: &PyOxidizerSource, data: &mut TemplateData) {
113    data.pyoxidizer_version = Some(PYOXIDIZER_VERSION.to_string());
114    data.pyoxidizer_commit = Some(
115        BUILD_GIT_COMMIT
116            .clone()
117            .unwrap_or_else(|| "UNKNOWN".to_string()),
118    );
119
120    match source {
121        PyOxidizerSource::LocalPath { path } => {
122            data.pyoxidizer_local_repo_path = Some(path.display().to_string());
123        }
124        PyOxidizerSource::GitUrl { url, commit, tag } => {
125            data.pyoxidizer_git_url = Some(url.clone());
126
127            if let Some(commit) = commit {
128                data.pyoxidizer_git_commit = Some(commit.clone());
129            }
130            if let Some(tag) = tag {
131                data.pyoxidizer_git_tag = Some(tag.clone());
132            }
133        }
134    }
135}
136
137/// Write a new .cargo/config file for a project path.
138pub fn write_new_cargo_config(project_path: &Path) -> Result<()> {
139    let cargo_path = project_path.join(".cargo");
140
141    if !cargo_path.is_dir() {
142        std::fs::create_dir(&cargo_path)?;
143    }
144
145    let data: BTreeMap<String, String> = BTreeMap::new();
146    let t = HANDLEBARS.render("new-cargo-config", &data)?;
147
148    let config_path = cargo_path.join("config");
149    println!("writing {}", config_path.display());
150    std::fs::write(&config_path, t)?;
151
152    Ok(())
153}
154
155/// Write a Cargo.lock file for a project path.
156///
157/// The Cargo.lock content is under version control and is automatically
158/// updated as part of the release automation. The file is generated in
159/// `--offline` mode and the contents of this `Cargo.lock` should closely
160/// resemble those in this repository's `Cargo.lock`. This helps ensure that
161/// the crate versions used by generated Rust projects match those of the
162/// build/commit of PyOxidizer used to generate the project.
163pub fn write_new_cargo_lock(
164    project_path: &Path,
165    project_name: &str,
166    pyembed_location: &PyembedLocation,
167) -> Result<()> {
168    // Add this project's entry to the lock file contents, otherwise the
169    // lock file will need updating on first use.
170    let mut lock_file = cargo_lock::Lockfile::from_str(NEW_PROJECT_CARGO_LOCK)?;
171
172    let dependencies = NEW_PROJECT_DEPENDENCIES
173        .iter()
174        .map(|dep| cargo_lock::Dependency {
175            name: cargo_lock::Name::from_str(dep)
176                .expect("could not convert dependency name to Name"),
177            version: lock_file
178                .packages
179                .iter()
180                .filter_map(|package| {
181                    if package.name.as_str() == *dep {
182                        Some(package.version.clone())
183                    } else {
184                        None
185                    }
186                })
187                .next()
188                .expect("unable to find dependency in frozen Cargo lock; something is out of sync"),
189            source: None,
190        })
191        .collect::<Vec<_>>();
192
193    lock_file.packages.push(cargo_lock::Package {
194        name: cargo_lock::Name::from_str(project_name)?,
195        version: semver::Version::new(0, 1, 0),
196        source: None,
197        checksum: None,
198        dependencies,
199        replace: None,
200    });
201
202    // The vendored new project Cargo.lock may need updated to properly reference
203    // packages from this repository. Here are the states that the vendored Cargo.lock
204    // can be in.
205    //
206    // a. Ongoing development. e.g. standard state on the `main` branch. Packages
207    //    in this repo are referred to by their version string, which ends in `-pre`.
208    // b. During a release. Version strings lack `-pre` and there are likely
209    //    `source` and `checksum` entries for the package.
210    //
211    // Furthermore, we have to consider how the generated `Cargo.toml` references the
212    // pyembed crate. It can be in one of the states as defined by the `PyembedLocation`
213    // enumeration.
214    //
215    // The pyembed location is important because some state combinations may require
216    // updating the vendored `Cargo.lock` so it doesn't require updates. Here is where
217    // we make those updates.
218
219    // If the pyembed location is referred to by path, no update to Cargo.lock is needed.
220    // If the pyembed location is referred to be a published version, the Cargo.lock should
221    // already have `source` and `checksum` entries for the published version, so it shouldn't
222    // need updates.
223    // If the pyembed location is referred to be a Git repo + commit, we need to define
224    // `source` entries for said packages to keep the Cargo.lock in sync.
225
226    if let PyembedLocation::Git(url, commit) = pyembed_location {
227        for package in lock_file.packages.iter_mut() {
228            if !package.version.pre.is_empty() && package.source.is_none() {
229                package.source = Some(
230                    cargo_lock::SourceId::for_git(
231                        &url::Url::from_str(url).context("parsing Git url")?,
232                        cargo_lock::package::GitReference::Rev(commit.clone()),
233                    )
234                    .context("constructing Cargo.lock Git source")?,
235                );
236            }
237        }
238    }
239
240    // cargo_lock isn't smart enough to sort the packages. So do that here.
241    lock_file
242        .packages
243        .sort_by(|a, b| a.name.as_str().cmp(b.name.as_str()));
244
245    let lock_path = project_path.join("Cargo.lock");
246    println!("writing {}", lock_path.display());
247    std::fs::write(&lock_path, &lock_file.to_string())?;
248
249    Ok(())
250}
251
252pub fn write_new_build_rs(path: &Path, program_name: &str) -> Result<()> {
253    let mut data = TemplateData::new();
254    data.program_name = Some(program_name.to_string());
255    let t = HANDLEBARS.render("new-build.rs", &data)?;
256
257    println!("writing {}", path.display());
258    std::fs::write(path, t)?;
259
260    Ok(())
261}
262
263/// Write a new main.rs file that runs the embedded Python interpreter.
264///
265/// `windows_subsystem` is the value of the `windows_subsystem` Rust attribute.
266pub fn write_new_main_rs(path: &Path, windows_subsystem: &str) -> Result<()> {
267    let mut data: BTreeMap<String, String> = BTreeMap::new();
268    data.insert(
269        "windows_subsystem".to_string(),
270        windows_subsystem.to_string(),
271    );
272    let t = HANDLEBARS.render("new-main.rs", &data)?;
273
274    println!("writing {}", path.to_str().unwrap());
275    let mut fh = std::fs::File::create(path)?;
276    fh.write_all(t.as_bytes())?;
277
278    Ok(())
279}
280
281/// Writes default PyOxidizer config files into a project directory.
282pub fn write_new_pyoxidizer_config_file(
283    source: &PyOxidizerSource,
284    project_dir: &Path,
285    name: &str,
286    code: Option<&str>,
287    pip_install: &[&str],
288) -> Result<()> {
289    let path = project_dir.join("pyoxidizer.bzl");
290
291    let mut data = TemplateData::new();
292    populate_template_data(source, &mut data);
293    data.program_name = Some(name.to_string());
294
295    if let Some(code) = code {
296        // Replace " with \" to work around
297        // https://github.com/google/starlark-rust/issues/230.
298        data.code = Some(code.replace('\"', "\\\""));
299    }
300
301    data.pip_install_simple = pip_install.iter().map(|v| (*v).to_string()).collect();
302
303    let t = HANDLEBARS.render("new-pyoxidizer.bzl", &data)?;
304
305    println!("writing {}", path.to_str().unwrap());
306    let mut fh = std::fs::File::create(path)?;
307    fh.write_all(t.as_bytes())?;
308
309    Ok(())
310}
311
312/// Write an application manifest and corresponding resource file.
313///
314/// This is used on Windows to allow the built executable to use long paths.
315///
316/// Windows 10 version 1607 and above enable long paths by default. So we
317/// might be able to remove this someday. It isn't clear if you get long
318/// paths support if using that version of the Windows SDK or if you have
319/// to be running on a modern Windows version as well.
320pub fn write_application_manifest(project_dir: &Path, program_name: &str) -> Result<()> {
321    let mut data = TemplateData::new();
322    data.program_name = Some(program_name.to_string());
323
324    let manifest_path = project_dir.join(format!("{}.exe.manifest", program_name));
325    let manifest_data = HANDLEBARS.render("exe.manifest", &data)?;
326    println!("writing {}", manifest_path.display());
327    let mut fh = std::fs::File::create(&manifest_path)?;
328    fh.write_all(manifest_data.as_bytes())?;
329
330    let rc_path = project_dir.join(format!("{}-manifest.rc", program_name));
331    let rc_data = HANDLEBARS.render("application-manifest.rc", &data)?;
332    println!("writing {}", rc_path.display());
333    let mut fh = std::fs::File::create(&rc_path)?;
334    fh.write_all(rc_data.as_bytes())?;
335
336    Ok(())
337}
338
339/// How to define the ``pyembed`` crate dependency.
340pub enum PyembedLocation {
341    /// Use a specific version, installed from the crate registry.
342    ///
343    /// (This is how most Rust dependencies are defined.)
344    Version(String),
345
346    /// Use a local filesystem path.
347    Path(PathBuf),
348
349    /// A git repository URL and revision hash.
350    Git(String, String),
351}
352
353impl PyembedLocation {
354    /// Convert the location to a string holding Cargo manifest location info.
355    pub fn cargo_manifest_fields(&self) -> String {
356        match self {
357            Self::Version(version) => format!("version = \"{}\"", version),
358            Self::Path(path) => format!("path = \"{}\"", path.display()),
359            Self::Git(url, commit) => format!("git = \"{}\", rev = \"{}\"", url, commit),
360        }
361    }
362}
363
364/// Update the Cargo.toml of a new Rust project to use pyembed.
365pub fn update_new_cargo_toml(path: &Path, pyembed_location: &PyembedLocation) -> Result<()> {
366    let content = std::fs::read_to_string(path)?;
367
368    // Insert a `[package]` content after the `version = *\n` line. We key off
369    // version because it should always be present.
370    let version_start = match content.find("version =") {
371        Some(off) => off,
372        None => return Err(anyhow!("could not find version line in Cargo.toml")),
373    };
374
375    let nl_off = match &content[version_start..content.len()].find('\n') {
376        Some(off) => version_start + off + 1,
377        None => return Err(anyhow!("could not find newline after version line")),
378    };
379
380    let (before, after) = content.split_at(nl_off);
381
382    let mut content = before.to_string();
383
384    // We license to the public domain because it is the most liberal in terms of allowed use.
385    content.push_str("# The license for the project boilerplate is public domain.\n");
386    content.push_str("# Feel free to change the license if you make modifications.\n");
387    content.push_str("license = \"CC-PDDC\"\n");
388
389    content.push_str("build = \"build.rs\"\n");
390    content.push_str(after);
391
392    content.push_str(&format!(
393        "pyembed = {{ {}, default-features = false }}\n",
394        pyembed_location.cargo_manifest_fields()
395    ));
396    content.push('\n');
397
398    let data = TemplateData::new();
399    content.push_str(
400        &HANDLEBARS
401            .render("cargo-extra.toml", &data)
402            .context("rendering cargo-extra.toml template")?,
403    );
404
405    std::fs::write(path, content)?;
406
407    Ok(())
408}
409
410/// Initialize a new Rust project using PyOxidizer.
411///
412/// The created binary application will have the name of the final
413/// path component.
414///
415/// `windows_subsystem` is the value of the `windows_subsystem` compiler
416/// attribute.
417pub fn initialize_project(
418    source: &PyOxidizerSource,
419    project_path: &Path,
420    cargo_exe: &Path,
421    code: Option<&str>,
422    pip_install: &[&str],
423    windows_subsystem: &str,
424) -> Result<()> {
425    let status = std::process::Command::new(cargo_exe)
426        .arg("init")
427        .arg("--bin")
428        .arg(project_path)
429        .status()
430        .context("invoking cargo init")?;
431
432    if !status.success() {
433        return Err(anyhow!("cargo init failed"));
434    }
435
436    let path = PathBuf::from(project_path);
437    let name = path.iter().last().unwrap().to_str().unwrap();
438    update_new_cargo_toml(&path.join("Cargo.toml"), &source.as_pyembed_location())
439        .context("updating Cargo.toml")?;
440    write_new_cargo_config(&path).context("writing cargo config")?;
441    write_new_cargo_lock(&path, name, &source.as_pyembed_location())
442        .context("writing Cargo.lock")?;
443    write_new_build_rs(&path.join("build.rs"), name).context("writing build.rs")?;
444    write_new_main_rs(&path.join("src").join("main.rs"), windows_subsystem)
445        .context("writing main.rs")?;
446    write_new_pyoxidizer_config_file(source, &path, name, code, pip_install)
447        .context("writing PyOxidizer config file")?;
448    write_application_manifest(&path, name).context("writing application manifest")?;
449
450    Ok(())
451}