Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Build platform independent rust binary wheel through wasi #1107

Merged
merged 8 commits into from
Oct 9, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .cirrus.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ build_and_test: &BUILD_AND_TEST
setup_script:
- curl https://sh.rustup.rs -sSf --output rustup.sh
- sh rustup.sh -y --default-toolchain stable
- rustup target add wasm32-wasi
- python3 -m pip install --upgrade cffi virtualenv
build_script:
- cargo build
Expand Down
3 changes: 2 additions & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ jobs:
profile: minimal
toolchain: stable
override: true
target: wasm32-wasi # Additional target
- name: Install cargo-nextest
uses: taiki-e/install-action@nextest
- name: Install aarch64-apple-darwin Rust target
Expand Down Expand Up @@ -351,7 +352,7 @@ jobs:
strategy:
fail-fast: ${{ !contains(github.event.pull_request.labels.*.name, 'CI-no-fail-fast') }}
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
os: [ ubuntu-latest, macos-latest, windows-latest ]
steps:
- uses: actions/checkout@v3
- uses: dorny/paths-filter@v2
Expand Down
2 changes: 2 additions & 0 deletions Changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

* Initial support for shipping bin targets as wasm32-wasi binaries that are run through wasmtime in [#1107](https://github.com/PyO3/maturin/pull/1107). Note that wasmtime currently only support the five most popular platforms and that wasi binaries have restrictions when interacting with the host. Usage is by setting `--target wasm32-wasi`.

## [0.13.6] - 2022-10-08

* Fix `maturin develop` in Windows conda virtual environment in [#1146](https://github.com/PyO3/maturin/pull/1146)
Expand Down
108 changes: 97 additions & 11 deletions src/build_context.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@ use crate::auditwheel::{PlatformTag, Policy};
use crate::build_options::CargoOptions;
use crate::compile::warn_missing_py_init;
use crate::module_writer::{
add_data, write_bin, write_bindings_module, write_cffi_module, write_python_part, WheelWriter,
add_data, write_bin, write_bindings_module, write_cffi_module, write_python_part,
write_wasm_launcher, WheelWriter,
};
use crate::project_layout::ProjectLayout;
use crate::source_distribution::source_distribution;
Expand All @@ -14,6 +15,7 @@ use anyhow::{anyhow, bail, Context, Result};
use cargo_metadata::Metadata;
use fs_err as fs;
use lddtree::Library;
use regex::Regex;
use sha2::{Digest, Sha256};
use std::collections::{HashMap, HashSet};
use std::fmt::{Display, Formatter};
Expand Down Expand Up @@ -74,6 +76,59 @@ impl Display for BridgeModel {
}
}

/// Insert wasm launcher scripts as entrypoints and the wasmtime dependency
fn bin_wasi_helper(
artifacts_and_files: &[(&BuildArtifact, String)],
mut metadata21: Metadata21,
) -> Result<Metadata21> {
eprintln!("⚠️ Warning: wasi support is experimental");
// escaped can contain [\w\d.], but i don't know how we'd handle dots correctly here
if metadata21.get_distribution_escaped().contains('.') {
bail!(
"Can't build wasm wheel if there is a dot in the name ('{}')",
metadata21.get_distribution_escaped()
)
}
if !metadata21.entry_points.is_empty() {
bail!("You can't define entrypoints yourself for a binary project");
}

let mut console_scripts = HashMap::new();
for (_, bin_name) in artifacts_and_files {
let base_name = bin_name
.strip_suffix(".wasm")
.context("No .wasm suffix in wasi binary")?;
console_scripts.insert(
base_name.to_string(),
format!(
"{}.{}:main",
metadata21.get_distribution_escaped(),
base_name.replace('-', "_")
),
);
}

metadata21
.entry_points
.insert("console_scripts".to_string(), console_scripts);

// A real pip version specification parser would be better, but bearing this we use this regex
// which tries to find the name wasmtime and then any specification
let wasmtime_re = Regex::new("^wasmtime[^a-zA-Z.-_]").unwrap();
if !metadata21
.requires_dist
.iter()
.any(|requirement| wasmtime_re.is_match(requirement))
{
// Having the wasmtime version hardcoded is not ideal, it's easy enough to overwrite
metadata21
.requires_dist
.push("wasmtime>=1.0.1,<2.0.0".to_string());
}

Ok(metadata21)
}

/// Contains all the metadata required to build the crate
#[derive(Clone)]
pub struct BuildContext {
Expand Down Expand Up @@ -653,28 +708,59 @@ impl BuildContext {
};

if !self.metadata21.scripts.is_empty() {
bail!("Defining entrypoints and working with a binary doesn't mix well");
bail!("Defining scripts and working with a binary doesn't mix well");
}

let mut writer = WheelWriter::new(&tag, &self.out, &self.metadata21, &tags)?;
let mut artifacts_and_files = Vec::new();
for artifact in artifacts {
// I wouldn't know of any case where this would be the wrong (and neither do
// I know a better alternative)
let bin_name = artifact
.path
.file_name()
.context("Couldn't get the filename from the binary produced by cargo")?
.to_str()
.context("binary produced by cargo has non-utf8 filename")?
.to_string();

// From https://packaging.python.org/en/latest/specifications/entry-points/
// > The name may contain any characters except =, but it cannot start or end with any
// > whitespace character, or start with [. For new entry points, it is recommended to
// > use only letters, numbers, underscores, dots and dashes (regex [\w.-]+).
// All of these rules are already enforced by cargo:
// https://github.com/rust-lang/cargo/blob/58a961314437258065e23cb6316dfc121d96fb71/src/cargo/util/restricted_names.rs#L39-L84
// i.e. we don't need to do any bin name validation here anymore

artifacts_and_files.push((artifact, bin_name))
}

let metadata21 = if self.target.is_wasi() {
bin_wasi_helper(&artifacts_and_files, self.metadata21.clone())?
} else {
self.metadata21.clone()
};

let mut writer = WheelWriter::new(&tag, &self.out, &metadata21, &tags)?;

if let Some(python_module) = &self.project_layout.python_module {
if self.target.is_wasi() {
// TODO: Can we have python code and the wasm launchers coexisting
// without clashes?
bail!("Sorry, adding python code to a wasm binary is currently not supported")
}
if !self.editable {
write_python_part(&mut writer, python_module)
.context("Failed to add the python module to the package")?;
}
}

let mut artifacts_ref = Vec::with_capacity(artifacts.len());
for artifact in artifacts {
artifacts_ref.push(artifact);
// I wouldn't know of any case where this would be the wrong (and neither do
// I know a better alternative)
let bin_name = artifact
.path
.file_name()
.expect("Couldn't get the filename from the binary produced by cargo");
for (artifact, bin_name) in &artifacts_and_files {
artifacts_ref.push(*artifact);
write_bin(&mut writer, &artifact.path, &self.metadata21, bin_name)?;
if self.target.is_wasi() {
write_wasm_launcher(&mut writer, &self.metadata21, bin_name)?;
}
}
self.add_external_libs(&mut writer, &artifacts_ref, ext_libs)?;

Expand Down
54 changes: 53 additions & 1 deletion src/module_writer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -761,7 +761,7 @@ pub fn write_bin(
writer: &mut impl ModuleWriter,
artifact: &Path,
metadata: &Metadata21,
bin_name: &OsStr,
bin_name: &str,
) -> Result<()> {
let data_dir = PathBuf::from(format!(
"{}-{}.data",
Expand All @@ -777,6 +777,58 @@ pub fn write_bin(
Ok(())
}

/// Adds a wrapper script that start the wasm binary through wasmtime.
///
/// Note that the wasm binary needs to be written separately by [write_bin]
pub fn write_wasm_launcher(
writer: &mut impl ModuleWriter,
metadata: &Metadata21,
bin_name: &str,
) -> Result<()> {
let entrypoint_script = format!(
r#"from pathlib import Path

from wasmtime import Store, Module, Engine, WasiConfig, Linker

import sysconfig

def main():
# The actual executable
program_location = Path(sysconfig.get_path("scripts")).joinpath("{}")
# wasmtime-py boilerplate
engine = Engine()
store = Store(engine)
# TODO: is there an option to just get the default of the wasmtime cli here?
wasi = WasiConfig()
wasi.inherit_argv()
wasi.inherit_env()
wasi.inherit_stdout()
wasi.inherit_stderr()
wasi.inherit_stdin()
store.set_wasi(wasi)
linker = Linker(engine)
linker.define_wasi()
module = Module.from_file(store.engine, str(program_location))
linking1 = linker.instantiate(store, module)
# TODO: this is taken from https://docs.wasmtime.dev/api/wasmtime/struct.Linker.html#method.get_default
# is this always correct?
start = linking1.exports(store).get("") or linking1.exports(store)["_start"]
start(store)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I remember that wasmtime execution is sandboxed, so you might need to give access to file systems explicitly somehow?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i think so, let's see if i get a response in bytecodealliance/wasmtime-py#94 and otherwise i'll have to collect some real world use cases compatible with wasm


if __name__ == '__main__':
main()
"#,
bin_name
);

// We can't use add_file since we want to mark the file as executable
let launcher_path = Path::new(&metadata.get_distribution_escaped())
.join(bin_name.replace('-', "_"))
.with_extension("py");
writer.add_bytes_with_permissions(&launcher_path, entrypoint_script.as_bytes(), 0o755)?;
Ok(())
}

/// Adds the python part of a mixed project to the writer,
pub fn write_python_part(
writer: &mut impl ModuleWriter,
Expand Down
18 changes: 16 additions & 2 deletions src/target.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ pub enum Os {
Illumos,
Haiku,
Emscripten,
Wasi,
messense marked this conversation as resolved.
Show resolved Hide resolved
}

impl fmt::Display for Os {
Expand All @@ -43,6 +44,7 @@ impl fmt::Display for Os {
Os::Illumos => write!(f, "Illumos"),
Os::Haiku => write!(f, "Haiku"),
Os::Emscripten => write!(f, "Emscripten"),
Os::Wasi => write!(f, "Wasi"),
}
}
}
Expand Down Expand Up @@ -123,7 +125,7 @@ fn get_supported_architectures(os: &Os) -> Vec<Arch> {
Os::Dragonfly => vec![Arch::X86_64],
Os::Illumos => vec![Arch::X86_64],
Os::Haiku => vec![Arch::X86_64],
Os::Emscripten => vec![Arch::Wasm32],
Os::Emscripten | Os::Wasi => vec![Arch::Wasm32],
}
}

Expand Down Expand Up @@ -176,6 +178,7 @@ impl Target {
OperatingSystem::Illumos => Os::Illumos,
OperatingSystem::Haiku => Os::Haiku,
OperatingSystem::Emscripten => Os::Emscripten,
OperatingSystem::Wasi => Os::Wasi,
unsupported => bail!("The operating system {:?} is not supported", unsupported),
};

Expand Down Expand Up @@ -353,6 +356,9 @@ impl Target {
let release = release.replace('.', "_").replace('-', "_");
format!("emscripten_{}_wasm32", release)
}
(Os::Wasi, Arch::Wasm32) => {
"any".to_string()
}
(_, _) => panic!("unsupported target should not have reached get_platform_tag()"),
};
Ok(tag)
Expand Down Expand Up @@ -406,6 +412,8 @@ impl Target {
Os::Illumos => "sunos",
Os::Haiku => "haiku",
Os::Emscripten => "emscripten",
// This isn't real, there's no sys.platform here
Os::Wasi => "wasi",
}
}

Expand Down Expand Up @@ -476,7 +484,8 @@ impl Target {
| Os::Dragonfly
| Os::Illumos
| Os::Haiku
| Os::Emscripten => true,
| Os::Emscripten
| Os::Wasi => true,
}
}

Expand Down Expand Up @@ -535,6 +544,11 @@ impl Target {
self.os == Os::Emscripten
}

/// Returns true if we're building a binary for wasm32-wasi
pub fn is_wasi(&self) -> bool {
self.os == Os::Wasi
}

/// Returns true if the current platform's target env is Musl
pub fn is_musl_target(&self) -> bool {
matches!(
Expand Down
3 changes: 2 additions & 1 deletion test-crates/cargo-mock/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,8 @@ fn run() -> Result<()> {
.replace("--quiet", "")
.replace(&cwd, "")
.replace(" ", "-")
.replace("/", "-");
.replace("/", "-")
.replace("-----C-link-arg=-s", "");
messense marked this conversation as resolved.
Show resolved Hide resolved

let cache_path = base_cache_path.join(&env_key).join(&cargo_key);
let stdout_path = cache_path.join("cargo.stdout");
Expand Down
2 changes: 1 addition & 1 deletion test-crates/hello-world/check_installed/check_installed.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ def main():
raise Exception(output)

output = check_output(["foo"]).decode("utf-8").strip()
if not output == "Hello, world!":
if not output == "🦀 Hello, world! 🦀":
raise Exception(output)
print("SUCCESS")

Expand Down
2 changes: 1 addition & 1 deletion test-crates/hello-world/src/bin/foo.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
fn main() {
println!("Hello, world!");
println!("🦀 Hello, world! 🦀");
}
11 changes: 10 additions & 1 deletion tests/common/integration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ pub fn test_integration(
bindings: Option<String>,
unique_name: &str,
zig: bool,
target: Option<&str>,
) -> Result<()> {
maybe_mock_cargo();

Expand Down Expand Up @@ -45,6 +46,11 @@ pub fn test_integration(
cli.push(bindings);
}

if let Some(target) = target {
cli.push("--target");
cli.push(target)
}

let test_zig = if zig && (env::var("GITHUB_ACTIONS").is_ok() || Zig::find_zig().is_ok()) {
cli.push("--zig");
true
Expand Down Expand Up @@ -84,11 +90,14 @@ pub fn test_integration(
};
assert!(filename.to_string_lossy().ends_with(file_suffix))
}
let venv_suffix = if supported_version == "py3" {
let mut venv_suffix = if supported_version == "py3" {
"py3".to_string()
} else {
format!("{}.{}", python_interpreter.major, python_interpreter.minor,)
};
if let Some(target) = target {
venv_suffix = format!("{}-{}", venv_suffix, target);
}
let (venv_dir, python) = create_virtualenv(
&package,
&venv_suffix,
Expand Down
2 changes: 1 addition & 1 deletion tests/common/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ pub fn handle_result<T>(result: Result<T>) -> T {
match result {
Err(e) => {
for cause in e.chain().collect::<Vec<_>>().iter().rev() {
eprintln!("{}", cause);
eprintln!("Cause: {}", cause);
}
panic!("{}", e);
}
Expand Down
Loading