diff --git a/.github/workflows/Examples.yml b/.github/workflows/Examples.yml index ca3c7a00..0572d59e 100644 --- a/.github/workflows/Examples.yml +++ b/.github/workflows/Examples.yml @@ -38,16 +38,18 @@ jobs: - SampleCrate.jl - SampleCratePyO3.jl - SampleCratePyO3Only.jl + - RustCrateMacroPyO3Only.jl steps: - uses: actions/checkout@v6 - uses: actions/setup-python@v5 - # SampleCratePyO3Only.jl binds a PyO3-only crate through RustCall's - # generated wrapper crate, and pyo3 is a mandatory dependency of that - # crate, so the wrapper links libpython (link plan `:link_libpython`): - # its build needs an interpreter whose library directory RustCall can - # find. Install one before Julia runs and pin it with PYO3_PYTHON - # below, rather than trusting whatever `python3` the runner image has. - # Harmless for the other packages: their crates never link Python. + # SampleCratePyO3Only.jl and RustCrateMacroPyO3Only.jl bind a PyO3-only + # crate through RustCall's generated wrapper crate, and pyo3 is a + # mandatory dependency of those crates, so the wrapper links libpython + # (link plan `:link_libpython`): its build needs an interpreter whose + # library directory RustCall can find. Install one before Julia runs + # and pin it with PYO3_PYTHON below, rather than trusting whatever + # `python3` the runner image has. Harmless for the other packages: + # their crates never link Python. id: python with: python-version: '3.x' @@ -67,7 +69,8 @@ jobs: env: # The interpreter pyo3 configures itself for and whose library # directory the wrapper links against (docs/src/pyo3.md, "Which - # Python"). Only SampleCratePyO3Only.jl reads it. + # Python"). Only SampleCratePyO3Only.jl and + # RustCrateMacroPyO3Only.jl read it. PYO3_PYTHON: ${{ steps.python.outputs.python-path }} run: > julia --color=yes --project=examples/${{ matrix.package }} -e ' diff --git a/examples/README.md b/examples/README.md index 26a287be..eaf8b2a2 100644 --- a/examples/README.md +++ b/examples/README.md @@ -23,6 +23,7 @@ Before running the examples, ensure you have: | [SampleCrate.jl](./SampleCrate.jl/) | Julia package with a Rust crate using `#[julia]` embedded under `deps/sample_crate/` | Intermediate | `#[julia]`, `@rust_crate`, `write_bindings_to_file`, Rust and Julia in separate files | | [SampleCratePyO3.jl](./SampleCratePyO3.jl/) | Julia package with a dual Julia/Python crate embedded under `deps/sample_crate_pyo3/` | Advanced | PyO3 integration, feature flags | | [SampleCratePyO3Only.jl](./SampleCratePyO3Only.jl/) | Julia package with a **PyO3-only** crate (no RustCall attribute) embedded under `deps/sample_crate_pyo3_only/`, bound through RustCall's generated wrapper crate | Advanced | `#[pyfunction]` / `#[pyclass]` without `#[julia]`, `PyResult` → `RustResult`, `:link_libpython` (needs a Python interpreter to build) | +| [RustCrateMacroPyO3Only.jl](./RustCrateMacroPyO3Only.jl/) | The same **PyO3-only** shape, embedded under `deps/macro_pyo3_only/`, bound with the **`@rust_crate` macro** at the package's top level instead of a `deps/build.jl` | Advanced | `@rust_crate ... submodule="Bindings"` in a package, bindings generated while the package is precompiled, nothing generated in the repository, `:link_libpython` | | [pluto/hello.jl](./pluto/hello.jl) | Pluto notebook with a `// cargo-deps:` block | Beginner | Inline Rust in Pluto, run headlessly in CI | Every `*.jl` directory is a Julia package: `Pkg.test()` runs its tests, and the @@ -32,13 +33,15 @@ Every `*.jl` directory is a Julia package: `Pkg.test()` runs its tests, and the guide prescribes), and no Julia file contains Rust source. The only reference an example makes outside its own directory is the `juliacall_macros` path dependency in its `Cargo.toml`, because the proc-macro crate is not on crates.io -yet — and `SampleCratePyO3Only.jl` makes none at all: its crate depends on pyo3 -alone, and the wrapper crate RustCall generates for it is what depends on +yet — and the two PyO3-only packages, `SampleCratePyO3Only.jl` and +`RustCrateMacroPyO3Only.jl`, make none at all: their crates depend on pyo3 +alone, and the wrapper crate RustCall generates for each is what depends on `juliacall_macros`. (RustCall's own test suite uses separate fixture crates under `test/fixtures/`, not the examples.) ```bash -# any of MyExample.jl, SampleCrate.jl, SampleCratePyO3.jl, SampleCratePyO3Only.jl +# any of MyExample.jl, SampleCrate.jl, SampleCratePyO3.jl, SampleCratePyO3Only.jl, +# RustCrateMacroPyO3Only.jl cd examples/SampleCrate.jl julia --project=. -e 'using Pkg; Pkg.develop(path="../.."); Pkg.test()' ``` @@ -231,6 +234,40 @@ SampleCratePyO3Only.Bindings.parse("x").value == RustCall.PYO3_OPAQUE_ERROR # prints what the wrapper exports and what it skips (only the `#[pymodule]` initializer), and the link plan. +### RustCrateMacroPyO3Only.jl + +The same PyO3-only crate shape as above — `deps/macro_pyo3_only/` carries no +RustCall attribute and no `juliacall_macros` dependency — bound through the +**other front door**: `@rust_crate ... submodule="Bindings"` at the package's +top level, which +[#339](https://github.com/AtelierArith/RustCall.jl/issues/339) made +precompilable. Read it next to `SampleCratePyO3Only.jl`: the two crates are +interchangeable, and the whole difference is in `src/`. + +**Features demonstrated:** +- `@rust_crate` in a package's `src/`, with `submodule="Bindings"` defining the generated module as `RustCrateMacroPyO3Only.Bindings` so `using .Bindings: ...` works (`name=` only renames the otherwise hidden module and defines nothing) +- No `deps/build.jl` and no `src/generated/`: the crate is built and the bindings module generated while the package is *precompiled*, straight into its precompile image +- The library is a `Base.include_dependency` of the package, so `RustCall.clear_cache()` or an edit to the crate makes the cache stale and the next `using` rebuilds +- The generated module reaches `Libdl` through RustCall, so the package needs `RustCall` alone among its dependencies +- The same `PyResult` → `RustResult` lowering with `RustCall.PYO3_OPAQUE_ERROR`, and a Julia layer (`safe_div`) that turns it into a `DivideError` + +**The Python requirement** is the same as `SampleCratePyO3Only.jl`'s: pyo3 is a +mandatory dependency of the crate, so the wrapper links libpython +(`:link_libpython`) and *precompiling* the package needs an interpreter whose +library directory RustCall can find. + +**How to use from Julia:** +```bash +cd examples/RustCrateMacroPyO3Only.jl +julia --project=. -e 'using Pkg; Pkg.develop(path="../.."); Pkg.test()' +``` +```julia +using RustCrateMacroPyO3Only +scale(Int32(3), Int32(4)) # 12 +c = Counter(Int64(10), Int64(2)); bump(c) # 12 +safe_div(7, 2) # 3; safe_div(1, 0) throws DivideError +``` + ### pluto/hello.jl A [Pluto](https://plutojl.org/) notebook that compiles a `rust"""..."""` block with a @@ -284,7 +321,13 @@ We recommend learning RustCall.jl in this order: - See what `PyResult` becomes, and why its error is opaque - Understand the link plan and the Python requirement of `:link_libpython` -5. **Read the documentation** +5. **Compare RustCrateMacroPyO3Only.jl** (optional) + - The same crate shape bound with `@rust_crate ... submodule="Bindings"` in + a package's `src/`, instead of `write_bindings_to_file` in a `deps/build.jl` + - See what a package that generates nothing looks like, and when to prefer + each of the two front doors + +6. **Read the documentation** - [Tutorial](../docs/src/tutorial.md) - [Crate Bindings (Phase 6)](../docs/src/crate_bindings.md) - [Troubleshooting](../docs/src/troubleshooting.md) diff --git a/examples/RustCrateMacroPyO3Only.jl/.gitignore b/examples/RustCrateMacroPyO3Only.jl/.gitignore new file mode 100644 index 00000000..251a2d3f --- /dev/null +++ b/examples/RustCrateMacroPyO3Only.jl/.gitignore @@ -0,0 +1,6 @@ +# Cargo's output for the embedded crate (the generated wrapper crate is built +# under it too). This example writes nothing into the package: the bindings +# module exists only in the package's precompile image. +/deps/macro_pyo3_only/target/ +/deps/macro_pyo3_only/Cargo.lock +/Manifest.toml diff --git a/examples/RustCrateMacroPyO3Only.jl/Project.toml b/examples/RustCrateMacroPyO3Only.jl/Project.toml new file mode 100644 index 00000000..c500e6f1 --- /dev/null +++ b/examples/RustCrateMacroPyO3Only.jl/Project.toml @@ -0,0 +1,22 @@ +name = "RustCrateMacroPyO3Only" +uuid = "6457296d-690f-447e-9e8b-2dc409aa65de" +version = "0.1.0" +authors = ["Satoshi Terasaki "] + +# No `Libdl`: the module `@rust_crate` generates reaches it through RustCall +# (`import RustCall.Libdl`), so a package that uses the macro needs RustCall +# alone. +[deps] +RustCall = "7ac5b1a4-9e37-4f0e-9aa3-3305a66bfb1c" + +# 0.3.1 is the first release whose `@rust_crate` has `submodule=` (#339); 0.3.0 +# would not define `Bindings`, and `using .Bindings` below would fail. +[compat] +julia = "1.12" +RustCall = "0.3.1" + +[extras] +Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40" + +[targets] +test = ["Test"] diff --git a/examples/RustCrateMacroPyO3Only.jl/README.md b/examples/RustCrateMacroPyO3Only.jl/README.md new file mode 100644 index 00000000..b35f613c --- /dev/null +++ b/examples/RustCrateMacroPyO3Only.jl/README.md @@ -0,0 +1,223 @@ +# RustCrateMacroPyO3Only.jl + +A Julia **package** that binds a **PyO3-only** Rust crate with the +**`@rust_crate` macro**, at the package's top level. The crate +[`deps/macro_pyo3_only`](./deps/macro_pyo3_only/) carries no RustCall attribute +— no `#[julia]`, no `juliacall_macros` dependency — just `#[pyfunction]`, +`#[pyclass]` and `#[pymethods]`, the way its author wrote it for Python. +RustCall binds it as is +([#275](https://github.com/AtelierArith/RustCall.jl/issues/275) Phase 2) by +generating a **wrapper crate** that depends on it and exports one `extern "C"` +entry point per item. + +## What is different from `SampleCratePyO3Only.jl` + +Nothing about the Rust side, and that is the point. `../SampleCratePyO3Only.jl` +binds a crate of the same shape; the two examples differ only in **how the +bindings are obtained**: + +| | `SampleCratePyO3Only.jl` | this package | +|---|---|---| +| front door | `RustCall.write_bindings_to_file` in `deps/build.jl` | `@rust_crate ... submodule="Bindings"` in `src/` | +| when the crate is built | `Pkg.build("SampleCratePyO3Only")` | when the package is **precompiled** | +| generated Julia code | a file, `src/generated/Bindings.jl`, `include`d | in memory, straight into the package's precompile image | +| in the repository | a `.gitignore`d generated file and library | nothing generated at all | +| needs Rust to load | no, once built — the package carries the library | yes: precompilation builds the crate | + +Use the written-file form when the package is to be shipped to machines that +have no Rust toolchain, or when you want to read the generated bindings. Use +`@rust_crate`, as here, when the crate is developed alongside the package and +the extra build step is not worth it. + +The macro at a package's top level is what +[#339](https://github.com/AtelierArith/RustCall.jl/issues/339) made possible: +the generated module is defined **inside** the module that expands the macro, +so it is part of the package's module tree and Julia can serialize it. + +## Layout: Rust and Julia in separate files + +``` +RustCrateMacroPyO3Only.jl/ +├── Project.toml +├── deps/ +│ └── macro_pyo3_only/ # Rust: a PyO3 crate, untouched +│ ├── Cargo.toml # pyo3 mandatory, `crate-type = ["rlib"]`, no RustCall dependency +│ └── src/lib.rs # #[pyfunction], #[pyclass], #[pymethods], #[pymodule] — no #[julia] +├── src/ +│ └── RustCrateMacroPyO3Only.jl # hand-written Julia; @rust_crate is one line of it +└── test/runtests.jl # Pkg.test +``` + +There is no `deps/build.jl` and no `src/generated/`. + +The example is **self-contained**: everything it builds and tests is inside +this directory. The crate itself refers to nothing outside it; the wrapper +crate RustCall generates depends on `juliacall_macros` from this checkout, and +is written under the crate's own `target/`. + +## The one line + +```julia +module RustCrateMacroPyO3Only +using RustCall +@rust_crate joinpath(@__DIR__, "..", "deps", "macro_pyo3_only") submodule="Bindings" +using .Bindings: scale, join_words, checked_div, Counter, zeroed, bump, current, describe, advance +export scale, join_words, checked_div, Counter, zeroed, bump, current, describe, advance +end +``` + +`submodule="Bindings"` **defines** the generated module in this module, as +`RustCrateMacroPyO3Only.Bindings`, which is what makes the `using .Bindings` +line possible. It is the option a *package* wants. + +Without it the module still exists — hidden inside the calling module, in a +namespace nothing else can name — and is reached only through the value the +macro returns: + +```julia +const B = @rust_crate joinpath(@__DIR__, "..", "deps", "macro_pyo3_only") +B.scale(Int32(3), Int32(4)) +``` + +which is the shape to use at the REPL or inside a function. `name="X"` is a +separate option and does a separate job: it chooses what that hidden module is +*called* (the default is the crate name in PascalCase) and still defines +nothing in the caller, which is why `const B = @rust_crate path name="B"` keeps +working — the constant is the only binding the caller gets. Never write +`const Bindings = @rust_crate path submodule="Bindings"`: the constant would be +bound over the module the macro just defined. + +### What happens when + +1. **Precompilation** (the first `using`, or `Pkg.precompile()`): `@rust_crate` + scans the crate, generates and builds the PyO3 wrapper crate, and defines + the bindings module — which Julia compiles into the package's cache. This is + the step that needs `cargo` and a Python interpreter. +2. **Load** (`using RustCrateMacroPyO3Only`, in any later session): nothing is + built. The generated module's `__init__` copies the library out of RustCall's + cache and opens the copy. +3. **After `RustCall.clear_cache()`, or a change to the crate**: the library is + a precompile dependency of the package (`Base.include_dependency`), so Julia + considers the cache stale and re-precompiles — step 1 again — rather than + opening a path that is gone. + +## The Python requirement + +pyo3 is a plain **mandatory** dependency of the crate +(`default-features = false, features = ["macros"]`), which is what most PyO3 +crates look like: pyo3's inner attributes (`#[new]`, `#[staticmethod]`, +`#[pyo3(get, set)]`, ...) cannot be put behind `#[cfg_attr(feature = ...)]`, so +a class always needs pyo3 in the graph. Any build whose graph contains pyo3 +links libpython, so RustCall's link plan for this crate is **`:link_libpython`** +(`RustCall.pyo3_link_plan("deps/macro_pyo3_only").mode`), and **precompiling** +this package needs a **Python interpreter** whose library directory RustCall can +find: + +- by default the `python3` (or `python`) on `PATH` is asked for its library + directory, which becomes the linker search path and, on Unix, an rpath + recorded in the wrapper library; +- `PYO3_PYTHON=/path/to/python3` pins a specific interpreter (a virtual + environment, a Conda one); `RUSTCALL_PYTHON_LIBDIR` overrides the directory + outright; +- on **Windows** there is no rpath: the generated module records the + interpreter's `python3xy.dll` by full path and preloads it before the wrapper, + so the DLL need not be on `PATH`. + +The interpreter — its path and what it reports about itself — is part of the +wrapper's artifact identity, so switching Pythons rebuilds the wrapper rather +than reusing one configured for the other. The details, and the two other link +plan modes (`:python_free`, `:unlinkable`), are in RustCall's +[`docs/src/pyo3.md`](../../docs/src/pyo3.md). + +No Python is *called*: the wrapper never initializes an interpreter, it only +links the library pyo3 refers to. The `Examples` workflow installs one with +`actions/setup-python` before Julia runs. + +## Run the tests + +```bash +cd examples/RustCrateMacroPyO3Only.jl +julia --project=. -e 'using Pkg; Pkg.develop(path="../.."); Pkg.test()' +``` + +`Pkg.develop(path="../..")` uses the RustCall of this checkout; with a +registered RustCall — **0.3.1 or later**, the first release whose `@rust_crate` +has `submodule=` — `Pkg.instantiate()` is enough. There is no build step to +run: the first `using` (which `Pkg.test()` performs) precompiles the package +and builds the crate. + +The same tests run in CI (the `Examples` workflow, job +`Example - RustCrateMacroPyO3Only.jl`). + +## Usage + +```julia +using RustCrateMacroPyO3Only + +scale(Int32(3), Int32(4)) # 12 — a #[pyfunction] +join_words("hello", "world") # "hello world" — String in, String out + +c = Counter(Int64(10), Int64(2)) # #[new] +c.value, c.step # 10, 2 — #[pyclass(get_all, set_all)] +current(c) # 10 — a &self method +bump(c) # 12; mutates c — a &mut self method +describe(c) # "12 (+2)" — a String-returning method +zeroed() # Counter(0, 1) — a #[staticmethod]; also zeroed(Counter) +safe_div(7, 2) # 3 — a Julia-side convenience +``` + +### What `PyResult` becomes + +`checked_div(a, b) -> PyResult` in Rust is `RustResult{Int32, String}` in +Julia, and the `Err` payload is always the same fixed sentence: + +```julia +r = checked_div(Int32(7), Int32(2)) # RustResult{Int32, String}(true, 3) +bad = checked_div(Int32(1), Int32(0)) +RustCall.is_err(bad) # true +bad.value == RustCall.PYO3_OPAQUE_ERROR # true +# "PyErr (Python-side error; message unavailable without an interpreter)" +``` + +That is deliberate: creating and dropping a `PyErr` needs no interpreter, but +*rendering* one does, so the generated code never looks at it. The Julia layer +in `src/RustCrateMacroPyO3Only.jl` turns it into something idiomatic: + +```julia +safe_div(7, 2) # 3::Int32 +safe_div(1, 0) # throws DivideError +safe_div(typemin(Int32), -1) # throws DivideError too: the quotient does not fit +``` + +(The crate uses `i32::checked_div`, so that last case is an `Err` and not a +Rust panic — `/` panics on it even in release builds.) + +The same lowering applies to a `PyResult` *method*: `advance(c, 3)` is +`RustResult{Int64, String}`. + +## Notes + +- **`juliacall_macros` is not needed by the crate.** + `deps/macro_pyo3_only/Cargo.toml` depends on pyo3 and nothing else. The + wrapper crate RustCall generates does depend on `juliacall_macros`, from this + checkout — that is where the `extern "C"` entry points, the string ABI and + the panic channel come from — but that is RustCall's business, not the + crate's. +- **`Libdl` is not among this package's dependencies.** The generated module + reaches it through RustCall (`import RustCall.Libdl`), so a package that uses + `@rust_crate` needs RustCall alone. +- **What a PyO3 crate needs to be wrappable**: every item Julia should see must + be `pub` (pyo3 does not need that; a wrapper crate compiled outside the crate + does), the `[lib]` must offer an `rlib` target (`["cdylib", "rlib"]` for a + real extension module), and pyo3's `extension-module` feature must not be on + unconditionally — a build with it cannot be loaded outside an interpreter + (link plan `:unlinkable`). +- **The `#[pymodule]` initializer is skipped.** It means nothing outside a + Python interpreter, and the scan says so: + `RustCall.scan_report("deps/macro_pyo3_only")` lists the ten items the + wrapper exports, the one it skips and why, and the link plan. +- **Nothing this package exports shadows a `Base` name.** A PyO3 method called + `peek` or `parse` would, and `using` the package would then make that name + ambiguous at every call site; `../SampleCratePyO3Only.jl` shows what to do + then — leave it out of the `using .Bindings` list and reach it as + `Bindings.parse`. diff --git a/examples/RustCrateMacroPyO3Only.jl/deps/macro_pyo3_only/Cargo.toml b/examples/RustCrateMacroPyO3Only.jl/deps/macro_pyo3_only/Cargo.toml new file mode 100644 index 00000000..5d44e86c --- /dev/null +++ b/examples/RustCrateMacroPyO3Only.jl/deps/macro_pyo3_only/Cargo.toml @@ -0,0 +1,34 @@ +# A crate written for PyO3 only: it knows nothing about RustCall and carries no +# RustCall attribute anywhere, and it does not depend on `juliacall_macros`. +# RustCall binds it anyway (#275 Phase 2) by generating a *wrapper crate* that +# depends on this one, building that, and binding the result. +# +# The difference from ../../../SampleCratePyO3Only.jl, whose crate has the same +# shape, is only where that happens: there a `deps/build.jl` writes the +# bindings to a file, here `@rust_crate` does it in memory while the Julia +# package is precompiled (#339). Nothing in this directory differs because of +# it — the crate cannot tell which front door binds it. +# +# pyo3 is a plain mandatory dependency with `default-features = false, +# features = ["macros"]`, which is what most real PyO3 crates look like. The +# price is that the generated wrapper links libpython (link plan +# `:link_libpython`), so building this package needs a Python interpreter. +# +# Two things a PyO3 crate needs for RustCall to be able to wrap it: +# +# * an `rlib` target, so the wrapper crate can depend on it as a Rust +# library — a crate that is `crate-type = ["cdylib"]` alone provides no +# linkable target (write `["cdylib", "rlib"]` for a real extension module); +# * `extension-module` NOT enabled unconditionally: a build whose pyo3 has +# that feature cannot be loaded outside a Python interpreter (link plan +# `:unlinkable`). Put it behind a feature maturin enables. +[package] +name = "macro_pyo3_only" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["rlib"] + +[dependencies] +pyo3 = { version = "0.29", default-features = false, features = ["macros"] } diff --git a/examples/RustCrateMacroPyO3Only.jl/deps/macro_pyo3_only/src/lib.rs b/examples/RustCrateMacroPyO3Only.jl/deps/macro_pyo3_only/src/lib.rs new file mode 100644 index 00000000..0fa73798 --- /dev/null +++ b/examples/RustCrateMacroPyO3Only.jl/deps/macro_pyo3_only/src/lib.rs @@ -0,0 +1,120 @@ +//! A small crate written for PyO3, and for PyO3 only. +//! +//! There is no RustCall attribute in this file and no RustCall dependency in +//! `Cargo.toml`: this is what a PyO3 extension crate looks like before anyone +//! thinks of Julia. RustCall binds it as is (#275 Phase 2): it scans the `pub` +//! items PyO3 exposes, generates a wrapper crate that depends on this one and +//! exports one `extern "C"` entry point per item (`rustcall_`, +//! `rustcall_Counter_`, `Counter_free`, ...), builds it, and binds the +//! result. +//! +//! The Julia package around this crate (`RustCrateMacroPyO3Only.jl`) does that +//! with the **`@rust_crate` macro**, at the package's top level: no generated +//! file, no build step. `../../../SampleCratePyO3Only.jl` binds a crate of the +//! same shape the other way, with `write_bindings_to_file` in a +//! `deps/build.jl`. +//! +//! Two rules to keep a crate wrappable: every item Julia should see must be +//! `pub` (pyo3 does not need that, a wrapper crate compiled outside does), and +//! a class has **one** `#[pymethods]` block unless the crate enables pyo3's +//! `multiple-pymethods` feature. + +use pyo3::prelude::*; + +/// A free function: `rustcall_scale` in the wrapper, `scale(x, k)` in Julia. +#[pyfunction] +pub fn scale(x: i32, k: i32) -> i32 { + x * k +} + +/// Strings cross as `(ptr, len)` pairs; the returned `String` is released +/// through a generated `..._free_rust_string`. +#[pyfunction] +pub fn join_words(a: String, b: String) -> String { + format!("{} {}", a.trim(), b.trim()) +} + +/// `PyResult` becomes `RustResult{T, String}` in Julia, with an *opaque* +/// error: creating and dropping a `PyErr` needs no interpreter, but rendering +/// one does, so the generated code never looks at it and Julia reports +/// `RustCall.PYO3_OPAQUE_ERROR` instead of this message. +/// +/// Both ways an `i32` division can fail are errors, not panics: `b == 0`, and +/// `i32::MIN / -1`, whose quotient does not fit — Rust's `/` panics on that +/// one even in release builds, and a panic here would surface as a +/// `RustCall.RuntimeError` instead of an `Err`. `checked_div` returns `None` +/// for both. +#[pyfunction] +pub fn checked_div(a: i32, b: i32) -> PyResult { + if b == 0 { + return Err(pyo3::exceptions::PyZeroDivisionError::new_err( + "division by zero", + )); + } + a.checked_div(b) + .ok_or_else(|| pyo3::exceptions::PyOverflowError::new_err("quotient does not fit in i32")) +} + +/// A class: an opaque handle in Julia, freed by the generated `Counter_free`, +/// which runs this type's destructor. `get_all, set_all` exposes every `pub` +/// field, so `c.value` and `c.value = 1` work in Julia as they do in Python. +#[pyclass(get_all, set_all)] +pub struct Counter { + pub value: i64, + pub step: i64, +} + +#[pymethods] +impl Counter { + /// `#[new]` is the Julia constructor: `Counter(0, 2)`. + #[new] + pub fn new(value: i64, step: i64) -> Self { + Counter { value, step } + } + + /// `#[staticmethod]`: a module-level Julia function, `zeroed()`. + #[staticmethod] + pub fn zeroed() -> Self { + Counter { value: 0, step: 1 } + } + + /// `&mut self` method: `bump(c)` mutates `c` in place and returns the new + /// value. + pub fn bump(&mut self) -> i64 { + self.value += self.step; + self.value + } + + /// `&self` method: `current(c)`. + pub fn current(&self) -> i64 { + self.value + } + + /// A `String`-returning method: an owned buffer, released by the wrapper. + pub fn describe(&self) -> String { + format!("{} (+{})", self.value, self.step) + } + + /// A `PyResult` method: `RustResult{Int64, String}` on the Julia side, + /// with the same opaque error as `checked_div`. + pub fn advance(&mut self, times: i64) -> PyResult { + if times < 0 { + return Err(pyo3::exceptions::PyValueError::new_err( + "times must not be negative", + )); + } + self.value += self.step * times; + Ok(self.value) + } +} + +/// The module initializer Python's importer calls. RustCall skips it: it means +/// nothing outside an interpreter, and the scan says so. +#[pymodule] +fn macro_pyo3_only(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_function(wrap_pyfunction!(scale, m)?)?; + m.add_function(wrap_pyfunction!(join_words, m)?)?; + m.add_function(wrap_pyfunction!(checked_div, m)?)?; + m.add_class::()?; + Ok(()) +} diff --git a/examples/RustCrateMacroPyO3Only.jl/src/RustCrateMacroPyO3Only.jl b/examples/RustCrateMacroPyO3Only.jl/src/RustCrateMacroPyO3Only.jl new file mode 100644 index 00000000..9b5b7b5a --- /dev/null +++ b/examples/RustCrateMacroPyO3Only.jl/src/RustCrateMacroPyO3Only.jl @@ -0,0 +1,93 @@ +""" + RustCrateMacroPyO3Only + +A Julia package with the Rust crate `deps/macro_pyo3_only` embedded in it: a +crate **written for PyO3 only**. It carries no RustCall attribute — no +`#[julia]` anywhere — and does not depend on `juliacall_macros`; it is a PyO3 +extension crate as its author wrote it for Python. RustCall binds it anyway +(#275 Phase 2): it scans the `pub` items PyO3 exposes, generates a **wrapper +crate** that depends on the crate, and builds it. + +What this example shows is the **front door**: `@rust_crate`, at the package's +top level, with `submodule="Bindings"`. There is no generated file and no build +step — the crate is built and the bindings module is generated while this +package is *precompiled*, and the module is compiled into the package's +precompile image like any other submodule (#339). `../SampleCratePyO3Only.jl` +binds a crate of the same shape the other way, with `write_bindings_to_file` +in a `deps/build.jl`; compare the two `src/` files. + +The two halves live in separate files: + +- **Rust**: `deps/macro_pyo3_only/src/lib.rs` — `#[pyfunction]`, `#[pyclass]`, + `#[pymethods]` and nothing else. No Julia file contains Rust source, and no + Rust file mentions Julia. +- **Julia**: this file. Everything under `Bindings` is generated in memory. + +The wrapper links libpython (pyo3 is a mandatory dependency of the crate, link +plan `:link_libpython`), so *precompiling* this package needs a Python +interpreter and a Rust toolchain; see `README.md`. + +Every exported name here is the Julia binding of the same-named PyO3 item, +except `safe_div`, which is a Julia-side convenience. +""" +module RustCrateMacroPyO3Only + +using RustCall +using RustCall: is_ok, unwrap + +# The one line this example is about. +# +# `@rust_crate` scans the crate, builds it (through the generated PyO3 wrapper +# crate) and defines the generated module here, as +# `RustCrateMacroPyO3Only.Bindings` — `submodule=` is what defines it in this +# module under a name of our choosing, and therefore what makes the +# `using .Bindings` below possible. It runs when this package is precompiled, +# so the Rust build is paid once, not on every `using`; the library itself is +# opened by the generated module's `__init__`, in the session that loads the +# package (#339). +# +# Without `submodule=` the module would still be generated — hidden inside +# this one — and reachable only through the value the macro returns +# (`const B = @rust_crate ...; B.scale(...)`), which is the shape to use at +# the REPL or inside a function. `name=` is a different option: it renames +# that hidden module and defines nothing here. +@rust_crate joinpath(@__DIR__, "..", "deps", "macro_pyo3_only") submodule="Bindings" + +# Explicit list: exactly the names this module re-exports unchanged. Every one +# of them is checked against `Base` first — a PyO3 item called `peek` or +# `parse` must *not* be re-exported, because `using` this package would then +# make the name ambiguous with the `Base` export at every call site; reach such +# a name as `Bindings.` instead (`../SampleCratePyO3Only.jl` does that +# for `parse`). +using .Bindings: scale, join_words, checked_div, + Counter, zeroed, bump, current, describe, advance + +# Names the PyO3 crate exposes, re-exported unchanged: `#[pyfunction]`s ... +export scale, join_words, checked_div +# ... and the `#[pyclass]` with its `#[new]`, `#[staticmethod]` and methods. +# The generated field accessors (`get_value` / `set_value!`, `get_step` / +# `set_step!`) are left in `Bindings`: `c.value` and `c.value = 1` go through +# them anyway. +export Counter, zeroed, bump, current, describe, advance + +# The Julia-side convenience defined below. +export safe_div + +""" + safe_div(a::Integer, b::Integer) -> Int32 + +Integer division in Rust through the crate's `checked_div`, a `#[pyfunction]` +returning `PyResult`. The wrapper cannot render a `PyErr` without a Python +interpreter, so the generated binding returns `RustResult{Int32, String}` whose +`Err` payload is always the fixed sentence `RustCall.PYO3_OPAQUE_ERROR`; this +wrapper turns that into a `DivideError` instead — for a zero divisor and for +`typemin(Int32) ÷ -1`, whose quotient does not fit, exactly the two cases in +which Julia's own `div` throws it. +""" +function safe_div(a::Integer, b::Integer)::Int32 + r = checked_div(Int32(a), Int32(b)) + is_ok(r) || throw(DivideError()) + return unwrap(r) +end + +end # module RustCrateMacroPyO3Only diff --git a/examples/RustCrateMacroPyO3Only.jl/test/runtests.jl b/examples/RustCrateMacroPyO3Only.jl/test/runtests.jl new file mode 100644 index 00000000..ea42af80 --- /dev/null +++ b/examples/RustCrateMacroPyO3Only.jl/test/runtests.jl @@ -0,0 +1,104 @@ +using RustCrateMacroPyO3Only +using RustCall: RustResult, RustError, is_ok, is_err, unwrap, PYO3_OPAQUE_ERROR +using Test + +# Every binding below reaches a crate that has no RustCall attribute anywhere, +# through the module `@rust_crate` generated at this package's top level +# (#275 Phase 2 for the wrapper, #339 for the macro in a precompiled package). +@testset "RustCrateMacroPyO3Only.jl" begin + @testset "the bindings are a submodule of this package" begin + B = RustCrateMacroPyO3Only.Bindings + @test B isa Module + @test parentmodule(B) === RustCrateMacroPyO3Only + @test nameof(B) === :Bindings + # Loaded from the package's precompile image, not re-generated on + # `using`: the library the module opened is a copy of the durable path + # it carries. + @test isfile(B._LIB_PATH) + end + + @testset "#[pyfunction]" begin + @test scale(Int32(3), Int32(4)) == 12 + @test scale(Int32(-2), Int32(5)) == -10 + @test join_words("hello", "world") == "hello world" + @test join_words(" a ", " b ") == "a b" + end + + @testset "PyResult -> RustResult{Int32, String}, opaque error" begin + ok = checked_div(Int32(7), Int32(2)) + @test ok isa RustResult{Int32, String} + @test is_ok(ok) + @test unwrap(ok) == 3 + + bad = checked_div(Int32(1), Int32(0)) + @test is_err(bad) + # The `PyErr` is never rendered (that needs an interpreter): the error + # payload is RustCall's fixed sentence, not pyo3's message. + @test bad.value == PYO3_OPAQUE_ERROR + @test !occursin("division by zero", bad.value) + + # The other way an i32 division fails: the quotient of + # `typemin(Int32) ÷ -1` does not fit. Rust's `/` panics on it even in + # release builds, so the crate uses `checked_div` and this is an `Err` + # like the zero divisor, not a panic. + overflow = checked_div(typemin(Int32), Int32(-1)) + @test is_err(overflow) + @test overflow.value == PYO3_OPAQUE_ERROR + + # The Julia layer: a value, or a DivideError. + @test safe_div(7, 2) == 3 + @test safe_div(7, 2) isa Int32 + @test_throws DivideError safe_div(1, 0) + @test_throws DivideError safe_div(typemin(Int32), -1) + end + + @testset "#[pyclass(get_all, set_all)] Counter" begin + # `#[new]` is the constructor. + c = Counter(Int64(10), Int64(2)) + @test c isa Counter + + # `#[staticmethod] zeroed` is a module-level function, in both forms + # the generator emits: typed and bare. + z = zeroed(Counter) + @test z isa Counter + @test (z.value, z.step) == (0, 1) + @test current(zeroed()) == 0 + + # Fields, through the generated `rustcall_Counter_get_value` / `_set_value`. + @test (c.value, c.step) == (10, 2) + @test propertynames(c) == (:value, :step) + c.value = 20 + @test c.value == 20 + c.step = 3 # converted to the field's i64 + @test c.step == 3 + @test_throws ErrorException c.missing_field + c.value, c.step = 10, 2 + + # `&self` and `&mut self` methods. + @test current(c) == 10 + @test bump(c) == 12 # mutates in place + @test current(c) == 12 + + # A `String`-returning method: the owned buffer comes back as a + # Julia String. + @test describe(c) == "12 (+2)" + + # A `PyResult` method, both ways. + good = advance(c, Int64(3)) + @test good isa RustResult{Int64, String} + @test is_ok(good) && unwrap(good) == 18 + bad = advance(c, Int64(-1)) + @test is_err(bad) + @test bad.value == PYO3_OPAQUE_ERROR + @test current(c) == 18 # refused, so unchanged + end + + @testset "Counter_free runs the Rust destructor" begin + c = Counter(Int64(1), Int64(1)) + finalize(c) + # After `finalize`, the handle is gone and a call is refused rather + # than handed to Rust. + @test_throws RustError current(c) + @test_throws RustError c.value + end +end