Skip to content

Commit f57bda7

Browse files
authored
Introspection: add a CLI to generate stubs (#5904)
* Introspection: add a CLI to generate stubs Useful for setuptools-rust Use it also for the integration tests * Makes sure to inject the module name * Add documentation
1 parent 20246a8 commit f57bda7

6 files changed

Lines changed: 87 additions & 139 deletions

File tree

guide/src/type-stub.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ It works using:
1111

1212
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.
1313
2. The `pyo3-introspection` crate that can parse the generated binaries, extract the JSON strings and build stub files from it.
14-
3. \[Not done yet\] Build tools like `maturin` exposing `pyo3-introspection` features in their CLI API.
14+
3. Build tools like `maturin` exposing options in their CLI API to generate the stubs file.
1515

1616
For example, the following Rust code
1717

@@ -72,6 +72,11 @@ def list_of_int_identity(arg: "list[int]") -> "list[int]": ...
7272
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).
7373
This is useful when PyO3 is not able to derive proper type annotations by itself.
7474

75+
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.
76+
You can also directly integrate the stubs in the built wheels by doing `maturin build --generate-stubs` (works also with `maturin develop`).
77+
78+
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.
79+
7580
## Constraints and limitations
7681

7782
- The `experimental-inspect` feature is required to generate the introspection fragments.

newsfragments/5904.added.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
`pyo3-introspection`: add a small CLI to generate stubs

noxfile.py

Lines changed: 56 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1427,35 +1427,62 @@ def update_ui_tests(session: nox.Session):
14271427

14281428
@nox.session(name="test-introspection")
14291429
def test_introspection(session: nox.Session):
1430-
session.install("maturin")
1431-
session.install("ruff")
1432-
options = []
1433-
target = os.environ.get("CARGO_BUILD_TARGET")
1434-
if target is not None:
1435-
options += ("--target", target)
1436-
profile = os.environ.get("CARGO_BUILD_PROFILE")
1437-
if profile == "release":
1438-
options.append("--release")
1439-
session.run_always(
1440-
"maturin",
1441-
"develop",
1442-
"-m",
1443-
"./pytests/Cargo.toml",
1444-
"--features",
1445-
"experimental-async,experimental-inspect",
1446-
*options,
1447-
)
1448-
lib_file = session.run(
1449-
"python",
1450-
"-c",
1451-
"import pyo3_pytests; print(pyo3_pytests.pyo3_pytests.__file__)",
1452-
silent=True,
1453-
).strip()
1454-
_run_cargo_test(
1455-
session,
1456-
package="pyo3-introspection",
1457-
env={"PYO3_PYTEST_LIB_PATH": lib_file},
1458-
)
1430+
with tempfile.TemporaryDirectory() as stub_dir:
1431+
session.install("maturin")
1432+
session.install("ruff")
1433+
options = []
1434+
target = os.environ.get("CARGO_BUILD_TARGET")
1435+
if target is not None:
1436+
options += ("--target", target)
1437+
profile = os.environ.get("CARGO_BUILD_PROFILE")
1438+
if profile == "release":
1439+
options.append("--release")
1440+
_run(
1441+
session,
1442+
"maturin",
1443+
"develop",
1444+
"-m",
1445+
"./pytests/Cargo.toml",
1446+
"--features",
1447+
"experimental-async,experimental-inspect",
1448+
*options,
1449+
)
1450+
lib_file = session.run(
1451+
"python",
1452+
"-c",
1453+
"import pyo3_pytests; print(pyo3_pytests.pyo3_pytests.__file__)",
1454+
silent=True,
1455+
).strip()
1456+
_run_cargo(
1457+
session,
1458+
"run",
1459+
"-p",
1460+
"pyo3-introspection",
1461+
"--",
1462+
lib_file,
1463+
"pyo3_pytests",
1464+
stub_dir,
1465+
)
1466+
_run(session, "ruff", "format", stub_dir)
1467+
_ensure_directory_equals(Path(stub_dir), Path("pytests/stubs"))
1468+
1469+
1470+
def _ensure_directory_equals(expected_dir: Path, actual_dir: Path):
1471+
# Assert all expected files are in actual and are equals
1472+
for expected_file_path in expected_dir.rglob("*"):
1473+
file_path = expected_file_path.relative_to(expected_dir)
1474+
actual_file_path = actual_dir / file_path
1475+
assert actual_file_path.exists(), f"File {file_path} does not exist"
1476+
assert expected_file_path.read_text() == actual_file_path.read_text(), (
1477+
f"Content is different in {file_path}"
1478+
)
1479+
# Assert all actual files are expected
1480+
for actual_file_path in actual_dir.rglob("*"):
1481+
file_path = actual_file_path.relative_to(actual_dir)
1482+
expected_file_path = expected_dir / file_path
1483+
assert expected_file_path.exists(), (
1484+
f"File {file_path} exist even if not expected"
1485+
)
14591486

14601487

14611488
@lru_cache()

pyo3-introspection/Cargo.toml

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,5 @@ goblin = ">=0.9, <0.11"
1414
serde = { version = "1", features = ["derive"] }
1515
serde_json = "1"
1616

17-
[dev-dependencies]
18-
tempfile = "3.12.0"
19-
2017
[lints]
2118
workspace = true

pyo3-introspection/src/main.rs

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
//! 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).
2+
3+
use anyhow::{anyhow, Context, Result};
4+
use pyo3_introspection::{introspect_cdylib, module_stub_files};
5+
use std::path::Path;
6+
use std::{env, fs};
7+
8+
fn main() -> Result<()> {
9+
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"))?;
10+
let module = introspect_cdylib(&binary_path, &module_name)
11+
.with_context(|| format!("Failed to introspect module {binary_path}"))?;
12+
let actual_stubs = module_stub_files(&module);
13+
for (path, module) in actual_stubs {
14+
let file_path = Path::new(&output_path).join(path);
15+
if let Some(parent) = file_path.parent() {
16+
fs::create_dir_all(parent).with_context(|| {
17+
format!("Failed to create output directory {}", file_path.display())
18+
})?;
19+
}
20+
fs::write(&file_path, module)
21+
.with_context(|| format!("Failed to write module {}", file_path.display()))?;
22+
}
23+
Ok(())
24+
}

pyo3-introspection/tests/test.rs

Lines changed: 0 additions & 106 deletions
This file was deleted.

0 commit comments

Comments
 (0)