Skip to content
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
7 changes: 6 additions & 1 deletion guide/src/type-stub.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ It works using:

1. PyO3 macros (`#[pyclass]`) that generate constant JSON strings that are then included in the built binaries by rustc if the `experimental-inspect` feature is enabled.
2. The `pyo3-introspection` crate that can parse the generated binaries, extract the JSON strings and build stub files from it.
3. \[Not done yet\] Build tools like `maturin` exposing `pyo3-introspection` features in their CLI API.
3. Build tools like `maturin` exposing options in their CLI API to generate the stubs file.

For example, the following Rust code

Expand Down Expand Up @@ -72,6 +72,11 @@ def list_of_int_identity(arg: "list[int]") -> "list[int]": ...
The only piece of new syntax is that the `#[pyo3(signature = ...)]` attribute can contain type annotations like `#[pyo3(signature = (arg: "list[int]") -> "list[int]")]` (note the `""` around type annotations).
This is useful when PyO3 is not able to derive proper type annotations by itself.

To generate stubs file with `maturin` you can use `maturin generate-stubs --output stubs` that will build the project then generate the stubs in the `stubs` directory.
You can also directly integrate the stubs in the built wheels by doing `maturin build --generate-stubs` (works also with `maturin develop`).

PyO3 also provides the smaller `pyo3-introspection` binary that allows to generate stubs from an existing built extension using something like `pyo3-introspection my_module_binary.so my_module_name output` to introspect the `my_module_binary.so` dynamic library for the `my_module_name` Python module and generate its stubs in the `output` directory.

## Constraints and limitations

- The `experimental-inspect` feature is required to generate the introspection fragments.
Expand Down
1 change: 1 addition & 0 deletions newsfragments/5904.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
`pyo3-introspection`: add a small CLI to generate stubs
85 changes: 56 additions & 29 deletions noxfile.py
Original file line number Diff line number Diff line change
Expand Up @@ -1427,35 +1427,62 @@ def update_ui_tests(session: nox.Session):

@nox.session(name="test-introspection")
def test_introspection(session: nox.Session):
session.install("maturin")
session.install("ruff")
options = []
target = os.environ.get("CARGO_BUILD_TARGET")
if target is not None:
options += ("--target", target)
profile = os.environ.get("CARGO_BUILD_PROFILE")
if profile == "release":
options.append("--release")
session.run_always(
"maturin",
"develop",
"-m",
"./pytests/Cargo.toml",
"--features",
"experimental-async,experimental-inspect",
*options,
)
lib_file = session.run(
"python",
"-c",
"import pyo3_pytests; print(pyo3_pytests.pyo3_pytests.__file__)",
silent=True,
).strip()
_run_cargo_test(
session,
package="pyo3-introspection",
env={"PYO3_PYTEST_LIB_PATH": lib_file},
)
with tempfile.TemporaryDirectory() as stub_dir:
session.install("maturin")
session.install("ruff")
options = []
target = os.environ.get("CARGO_BUILD_TARGET")
if target is not None:
options += ("--target", target)
profile = os.environ.get("CARGO_BUILD_PROFILE")
if profile == "release":
options.append("--release")
_run(
session,
"maturin",
"develop",
"-m",
"./pytests/Cargo.toml",
"--features",
"experimental-async,experimental-inspect",
*options,
)
lib_file = session.run(
"python",
"-c",
"import pyo3_pytests; print(pyo3_pytests.pyo3_pytests.__file__)",
silent=True,
).strip()
_run_cargo(
session,
"run",
"-p",
"pyo3-introspection",
"--",
lib_file,
"pyo3_pytests",
stub_dir,
)
_run(session, "ruff", "format", stub_dir)
_ensure_directory_equals(Path(stub_dir), Path("pytests/stubs"))


def _ensure_directory_equals(expected_dir: Path, actual_dir: Path):
# Assert all expected files are in actual and are equals
for expected_file_path in expected_dir.rglob("*"):
file_path = expected_file_path.relative_to(expected_dir)
actual_file_path = actual_dir / file_path
assert actual_file_path.exists(), f"File {file_path} does not exist"
assert expected_file_path.read_text() == actual_file_path.read_text(), (
f"Content is different in {file_path}"
)
# Assert all actual files are expected
for actual_file_path in actual_dir.rglob("*"):
file_path = actual_file_path.relative_to(actual_dir)
expected_file_path = expected_dir / file_path
assert expected_file_path.exists(), (
f"File {file_path} exist even if not expected"
)


@lru_cache()
Expand Down
3 changes: 0 additions & 3 deletions pyo3-introspection/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,5 @@ goblin = ">=0.9, <0.11"
serde = { version = "1", features = ["derive"] }
serde_json = "1"

[dev-dependencies]
tempfile = "3.12.0"

[lints]
workspace = true
24 changes: 24 additions & 0 deletions pyo3-introspection/src/main.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
//! Small CLI entry point to introspect a Python cdylib built using PyO3 and generate [type stubs](https://typing.readthedocs.io/en/latest/source/stubs.html).

use anyhow::{anyhow, Context, Result};
use pyo3_introspection::{introspect_cdylib, module_stub_files};
use std::path::Path;
use std::{env, fs};

fn main() -> Result<()> {
let [_, binary_path, module_name, output_path] = env::args().collect::<Vec<_>>().try_into().map_err(|_| anyhow!("pyo3-introspection takes three arguments, the path of the binary to introspect, the name of the python module to introspect and and the path of the directory to write the stub to"))?;
let module = introspect_cdylib(&binary_path, &module_name)
.with_context(|| format!("Failed to introspect module {binary_path}"))?;
let actual_stubs = module_stub_files(&module);
for (path, module) in actual_stubs {
let file_path = Path::new(&output_path).join(path);
if let Some(parent) = file_path.parent() {
fs::create_dir_all(parent).with_context(|| {
format!("Failed to create output directory {}", file_path.display())
})?;
}
fs::write(&file_path, module)
.with_context(|| format!("Failed to write module {}", file_path.display()))?;
}
Ok(())
}
106 changes: 0 additions & 106 deletions pyo3-introspection/tests/test.rs

This file was deleted.

Loading