diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 02967ea262b..b52ae874895 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -917,6 +917,26 @@ jobs: - run: uv run --project .. --isolated --only-group nox nox -s ${{matrix.checker}} working-directory: pytests + verify-nostd: + needs: [fmt] + runs-on: ubuntu-latest + continue-on-error: true + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Install Rust toolchain + run: | + rustup toolchain install stable --profile minimal --no-self-update --component rust-src + rustup default stable + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.14" + - uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0 + with: + save-cache: ${{ github.ref == 'refs/heads/main' || contains(github.event.pull_request.labels.*.name, 'CI-save-pr-cache') }} + - run: uv run --isolated --only-group nox nox -s verify-nostd + conclusion: needs: - zizmor diff --git a/examples/maturin-starter-nostd/.template/Cargo.toml b/examples/maturin-starter-nostd/.template/Cargo.toml new file mode 100644 index 00000000000..dfd6cc14de8 --- /dev/null +++ b/examples/maturin-starter-nostd/.template/Cargo.toml @@ -0,0 +1,16 @@ +[package] +authors = ["{{authors}}"] +name = "{{project-name}}" +version = "0.1.0" +edition = "2021" + +[lib] +name = "maturin_starter" +crate-type = ["cdylib"] + +[dependencies] +pyo3 = { version = "{{PYO3_VERSION}}", default-features = false, features = [ + "macros", + "hashbrown", + "parking_lot", +] } diff --git a/examples/maturin-starter-nostd/.template/pre-script.rhai b/examples/maturin-starter-nostd/.template/pre-script.rhai new file mode 100644 index 00000000000..76ae8c8c984 --- /dev/null +++ b/examples/maturin-starter-nostd/.template/pre-script.rhai @@ -0,0 +1,4 @@ +variable::set("PYO3_VERSION", "0.29.2"); +file::rename(".template/Cargo.toml", "Cargo.toml"); +file::rename(".template/pyproject.toml", "pyproject.toml"); +file::delete(".template"); diff --git a/examples/maturin-starter-nostd/.template/pyproject.toml b/examples/maturin-starter-nostd/.template/pyproject.toml new file mode 100644 index 00000000000..d27a1be9832 --- /dev/null +++ b/examples/maturin-starter-nostd/.template/pyproject.toml @@ -0,0 +1,10 @@ +[build-system] +requires = ["maturin>=1,<2"] +build-backend = "maturin" + +[project] +name = "{{project-name}}" +version = "0.1.0" + +[project.optional-dependencies] +dev = ["pytest"] diff --git a/examples/maturin-starter-nostd/Cargo.toml b/examples/maturin-starter-nostd/Cargo.toml new file mode 100644 index 00000000000..c34a16b3c72 --- /dev/null +++ b/examples/maturin-starter-nostd/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "maturin-starter" +version = "0.1.0" +edition = "2021" +rust-version = "1.83" + +[lib] +name = "maturin_starter" +crate-type = ["cdylib"] + +[dependencies] +pyo3 = { path = "../../", default-features = false, features = [ + "macros", + "hashbrown", + "parking_lot", +] } + +[features] +abi3 = ["pyo3/abi3-py39"] + +[workspace] diff --git a/examples/maturin-starter-nostd/MANIFEST.in b/examples/maturin-starter-nostd/MANIFEST.in new file mode 100644 index 00000000000..becccf7bc9a --- /dev/null +++ b/examples/maturin-starter-nostd/MANIFEST.in @@ -0,0 +1,2 @@ +include pyproject.toml Cargo.toml +recursive-include src * diff --git a/examples/maturin-starter-nostd/README.md b/examples/maturin-starter-nostd/README.md new file mode 100644 index 00000000000..c3ca80901e6 --- /dev/null +++ b/examples/maturin-starter-nostd/README.md @@ -0,0 +1,35 @@ +# maturin-starter + +An example of a basic Python extension module built using PyO3 and [`maturin`](https://github.com/PyO3/maturin). + +## Building and Testing + +To build this package, first install `maturin`: + +```shell +pip install maturin +``` + +To build and test use `maturin develop`: + +```shell +pip install -r requirements-dev.txt +maturin develop && pytest +``` + +Alternatively, install nox and run the tests inside an isolated environment: + +```shell +nox +``` + +## Copying this example + +Use [`cargo-generate`](https://crates.io/crates/cargo-generate): + +```bash +$ cargo install cargo-generate +$ cargo generate --git https://github.com/PyO3/pyo3 examples/maturin-starter-nostd +``` + +(`cargo generate` will take a little while to clone the PyO3 repo first; be patient when waiting for the command to run.) diff --git a/examples/maturin-starter-nostd/cargo-generate.toml b/examples/maturin-starter-nostd/cargo-generate.toml new file mode 100644 index 00000000000..d750c4de7a3 --- /dev/null +++ b/examples/maturin-starter-nostd/cargo-generate.toml @@ -0,0 +1,5 @@ +[template] +ignore = [".nox"] + +[hooks] +pre = [".template/pre-script.rhai"] diff --git a/examples/maturin-starter-nostd/maturin_starter/__init__.py b/examples/maturin-starter-nostd/maturin_starter/__init__.py new file mode 100644 index 00000000000..ab2c15bc559 --- /dev/null +++ b/examples/maturin-starter-nostd/maturin_starter/__init__.py @@ -0,0 +1,15 @@ +# import the contents of the Rust library into the Python extension +from .maturin_starter import * + +# optional: include the documentation from the Rust module +from .maturin_starter import ( + __all__, + __doc__, # noqa: F401 +) + +__all__ = __all__ + ["PythonClass"] + + +class PythonClass: + def __init__(self, value: int) -> None: + self.value = value diff --git a/examples/maturin-starter-nostd/noxfile.py b/examples/maturin-starter-nostd/noxfile.py new file mode 100644 index 00000000000..e5ee4a1a468 --- /dev/null +++ b/examples/maturin-starter-nostd/noxfile.py @@ -0,0 +1,17 @@ +import argparse + +import nox + + +@nox.session +def python(session): + parser = argparse.ArgumentParser() + parser.add_argument("--features") + args = parser.parse_args(session.posargs) + + session.env["MATURIN_PEP517_ARGS"] = "--profile=dev" + if args.features: + session.env["MATURIN_PEP517_ARGS"] += f" --features={args.features}" + + session.install(".[dev]") + session.run("pytest") diff --git a/examples/maturin-starter-nostd/pyproject.toml b/examples/maturin-starter-nostd/pyproject.toml new file mode 100644 index 00000000000..52ecba15edc --- /dev/null +++ b/examples/maturin-starter-nostd/pyproject.toml @@ -0,0 +1,19 @@ +[build-system] +requires = ["maturin>=1,<2"] +build-backend = "maturin" + +[project] +name = "maturin-starter" +version = "0.1.0" +classifiers = [ + "License :: OSI Approved :: MIT License", + "Development Status :: 3 - Alpha", + "Intended Audience :: Developers", + "Programming Language :: Python", + "Programming Language :: Rust", + "Operating System :: POSIX", + "Operating System :: MacOS :: MacOS X", +] + +[project.optional-dependencies] +dev = ["pytest"] diff --git a/examples/maturin-starter-nostd/src/lib.rs b/examples/maturin-starter-nostd/src/lib.rs new file mode 100644 index 00000000000..0bfa4fa3f00 --- /dev/null +++ b/examples/maturin-starter-nostd/src/lib.rs @@ -0,0 +1,37 @@ +#![no_std] + +use pyo3::prelude::*; +use pyo3::types::PyDict; +use pyo3::wrap_pymodule; + +mod submodule; + +#[pyclass] +struct ExampleClass { + #[pyo3(get, set)] + value: i32, +} + +#[pymethods] +impl ExampleClass { + #[new] + pub fn new(value: i32) -> Self { + ExampleClass { value } + } +} + +/// An example module implemented in Rust using PyO3. +#[pymodule] +fn maturin_starter(py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_class::()?; + m.add_wrapped(wrap_pymodule!(submodule::submodule))?; + + // Inserting to sys.modules allows importing submodules nicely from Python + // e.g. from maturin_starter.submodule import SubmoduleClass + + let sys = PyModule::import(py, "sys")?; + let sys_modules: Bound<'_, PyDict> = sys.getattr("modules")?.cast_into()?; + sys_modules.set_item("maturin_starter.submodule", m.getattr("submodule")?)?; + + Ok(()) +} diff --git a/examples/maturin-starter-nostd/src/submodule.rs b/examples/maturin-starter-nostd/src/submodule.rs new file mode 100644 index 00000000000..f3eb174100b --- /dev/null +++ b/examples/maturin-starter-nostd/src/submodule.rs @@ -0,0 +1,22 @@ +use pyo3::prelude::*; + +#[pyclass] +struct SubmoduleClass {} + +#[pymethods] +impl SubmoduleClass { + #[new] + pub fn __new__() -> Self { + SubmoduleClass {} + } + + pub fn greeting(&self) -> &'static str { + "Hello, world!" + } +} + +#[pymodule] +pub fn submodule(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_class::()?; + Ok(()) +} diff --git a/examples/maturin-starter-nostd/tests/test_maturin_starter.py b/examples/maturin-starter-nostd/tests/test_maturin_starter.py new file mode 100644 index 00000000000..1f3e8d9f7f4 --- /dev/null +++ b/examples/maturin-starter-nostd/tests/test_maturin_starter.py @@ -0,0 +1,19 @@ +from maturin_starter import ExampleClass, PythonClass + + +def test_python_class() -> None: + py_class = PythonClass(value=10) + assert py_class.value == 10 + + +def test_example_class() -> None: + example = ExampleClass(value=11) + assert example.value == 11 + + +def test_doc() -> None: + import maturin_starter + + assert ( + maturin_starter.__doc__ == "An example module implemented in Rust using PyO3." + ) diff --git a/examples/maturin-starter-nostd/tests/test_submodule.py b/examples/maturin-starter-nostd/tests/test_submodule.py new file mode 100644 index 00000000000..5cf2f57c2cb --- /dev/null +++ b/examples/maturin-starter-nostd/tests/test_submodule.py @@ -0,0 +1,6 @@ +from maturin_starter.submodule import SubmoduleClass + + +def test_submodule_class() -> None: + submodule_class = SubmoduleClass() + assert submodule_class.greeting() == "Hello, world!" diff --git a/noxfile.py b/noxfile.py index 90528b85e87..8154c9dd758 100644 --- a/noxfile.py +++ b/noxfile.py @@ -1,6 +1,7 @@ from __future__ import annotations import functools +import io import json import os import re @@ -15,6 +16,7 @@ from difflib import unified_diff from functools import lru_cache from glob import glob +from operator import contains from pathlib import Path from shlex import quote from typing import Any, Literal, Protocol @@ -298,6 +300,83 @@ def clippy(session: nox.Session) -> bool: session.error("one or more jobs failed") +@nox.session(name="verify-nostd", venv_backend="none") +def verify_nostd(session: nox.Sesstion): + manifest_path = "examples/maturin-starter-nostd/Cargo.toml" + args = [ + "cargo", + "build", + f"--manifest-path={manifest_path}", + "--message-format=json-diagnostic-rendered-ansi", + ] + + dylib_path: str | None = None + is_github_actions = _is_github_actions() + failed = False + if is_github_actions: + # Insert ::group:: at the start of nox's command line output + print("::group::", end="", flush=True, file=sys.stderr) + try: + env = os.environ.copy() + env["PYO3_WIP_NO_STD"] = "1" + stdout = subprocess.Popen( + args, + stdout=subprocess.PIPE, + env=env, + ).stdout + assert stdout is not None + for line in io.TextIOWrapper(stdout).readlines(): + line = line.strip() + if not line.startswith("{"): + print(line) + continue + + try: + message = json.loads(line) + reason: str = message["reason"] + + if reason == "compiler-message": + print(message["message"]["rendered"]) + + elif reason == "compiler-artifact": + this_manifest_path: str = message["manifest_path"] + if not this_manifest_path.endswith(manifest_path): + continue + target_kinds: list[str] = message["target"]["kind"] + if not contains(target_kinds, "cdylib"): + continue + filenames: list[str] = message["filenames"] + if len(filenames) != 1: + session.error( + f"found multiple files for cdylib artifact: {filenames}", + ) + return + dylib_path = filenames[0] + + except json.JSONDecodeError: + print(line) + + except Exception: + failed = True + raise + finally: + if is_github_actions: + print("::endgroup::", file=sys.stderr) + # Defer the error message until after the group to make them easier + # to find in the log + if failed: + command = " ".join(args) + print(f"::error::`{command}` failed", file=sys.stderr) + + if dylib_path is None: + session.error("could not find path to compiled dylib artifact") + return + + _run_cargo( + session, "run", "--manifest-path=pyo3-verify-nostd/Cargo.toml", "--", dylib_path + ) + + def _clippy( session: nox.Session, *, diff --git a/pyo3-verify-nostd/Cargo.toml b/pyo3-verify-nostd/Cargo.toml new file mode 100644 index 00000000000..ce9b38a4348 --- /dev/null +++ b/pyo3-verify-nostd/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "pyo3-verify-nostd" +version = "0.1.0" +edition = "2024" +publish = false + +[dependencies] +anyhow = "1.0.104" +object = "0.40" + +[workspace] diff --git a/pyo3-verify-nostd/src/main.rs b/pyo3-verify-nostd/src/main.rs new file mode 100644 index 00000000000..3ebc03c258e --- /dev/null +++ b/pyo3-verify-nostd/src/main.rs @@ -0,0 +1,42 @@ +use std::{env, fs}; + +use anyhow::{Context, Result, bail, ensure}; +use object::{Object, ObjectSymbol}; + +fn main() -> Result<()> { + const HELP: &str = + "should be called with exactly one command line arg that is path of file to analyze"; + + let mut args = env::args().skip(1); + let path = args.next().context(HELP)?; + ensure!(args.next().is_none(), "{HELP}"); + + let file = fs::read(&path).with_context(|| format!("failed to read file: {path}"))?; + let file = object::read::File::parse(file.as_slice()) + .with_context(|| format!("failed to parse file: {path}"))?; + let mut count = 0; + for symbol in file.symbols() { + let name = symbol.name().context("failed to get name of a symbol")?; + if is_std_symbol(name) { + count += 1; + println!("found symbol from `std`: {name}"); + } + } + + if count > 0 { + bail!("\ncompiled library contains {count} symbols from std"); + } + + Ok(()) +} + +fn is_std_symbol(name: &str) -> bool { + let Some(idx_std) = name.find("std") else { + return false; + }; + // ignore false positives from the conversions::std module + match name.find("conversions") { + Some(idx_conversions) => idx_conversions > idx_std, + None => true, + } +}