From 99e84986ae83246ab5b83becdf5d761cf8abb93a Mon Sep 17 00:00:00 2001 From: Simon Gerber Date: Wed, 17 Dec 2025 10:28:36 +0100 Subject: [PATCH 1/3] Switch top-level Python module definition to `mod reclass_rs` --- src/lib.rs | 26 ++++++++++++-------------- 1 file changed, 12 insertions(+), 14 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 40777f5..5275c21 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -413,20 +413,18 @@ fn buildinfo() -> HashMap<&'static str, &'static str> { } #[pymodule] -fn reclass_rs(_py: Python, m: &Bound<'_, PyModule>) -> PyResult<()> { - // Register the top-level `Reclass` Python class which is used to configure the library - m.add_class::()?; - // Register the `Config` class and `CompatFlag` enum - m.add_class::()?; - m.add_class::()?; - // Register the NodeInfoMeta and NodeInfo classes - m.add_class::()?; - m.add_class::()?; - // Register the Inventory class - m.add_class::()?; - // Register the buildinfo method - m.add_function(wrap_pyfunction!(buildinfo, m)?)?; - Ok(()) +mod reclass_rs { + #[pymodule_export] + use super::Reclass; + #[pymodule_export] + use super::buildinfo; + + #[pymodule_export] + use crate::config::{CompatFlag, Config}; + #[pymodule_export] + use crate::inventory::Inventory; + #[pymodule_export] + use crate::node::{NodeInfo, NodeInfoMeta}; } #[cfg(test)] From 8537706e9fb9cd4cdfbca4cc34585b79d92dc58f Mon Sep 17 00:00:00 2001 From: Simon Gerber Date: Wed, 17 Dec 2025 10:29:26 +0100 Subject: [PATCH 2/3] Setup Python type stubs for `reclass_rs` We switch to managing the Python package contents ourselves, so we can include the type stubs in a way which makes mypy's `stubtest` happy. Notably `stubtest` will complain about missing type stubs if we don't provide both `__init__.pyi` and `reclass_rs.pyi`. --- pyproject.toml | 1 + python/reclass_rs/__init__.py | 5 ++ python/reclass_rs/__init__.pyi | 1 + python/reclass_rs/py.typed | 0 python/reclass_rs/reclass_rs.pyi | 116 +++++++++++++++++++++++++++++++ 5 files changed, 123 insertions(+) create mode 100644 python/reclass_rs/__init__.py create mode 120000 python/reclass_rs/__init__.pyi create mode 100644 python/reclass_rs/py.typed create mode 100644 python/reclass_rs/reclass_rs.pyi diff --git a/pyproject.toml b/pyproject.toml index ae5a199..f210a0c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,4 +14,5 @@ dynamic = ["version"] [tool.maturin] +python-source = "python" features = ["pyo3/extension-module"] diff --git a/python/reclass_rs/__init__.py b/python/reclass_rs/__init__.py new file mode 100644 index 0000000..7ea0d79 --- /dev/null +++ b/python/reclass_rs/__init__.py @@ -0,0 +1,5 @@ +from .reclass_rs import * + +__doc__ = reclass_rs.__doc__ +if hasattr(reclass_rs, "__all__"): + __all__ = reclass_rs.__all__ diff --git a/python/reclass_rs/__init__.pyi b/python/reclass_rs/__init__.pyi new file mode 120000 index 0000000..3d91bfd --- /dev/null +++ b/python/reclass_rs/__init__.pyi @@ -0,0 +1 @@ +reclass_rs.pyi \ No newline at end of file diff --git a/python/reclass_rs/py.typed b/python/reclass_rs/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/python/reclass_rs/reclass_rs.pyi b/python/reclass_rs/reclass_rs.pyi new file mode 100644 index 0000000..dd0de04 --- /dev/null +++ b/python/reclass_rs/reclass_rs.pyi @@ -0,0 +1,116 @@ +from enum import Enum +from typing import Any, Optional, final +from datetime import datetime + +__all__: list[str] = [ + "CompatFlag", + "Config", + "Inventory", + "NodeInfo", + "NodeInfoMeta", + "Reclass", + "buildinfo", +] + +@final +class CompatFlag(Enum): + ComposeNodeNameLiteralDots = "ComposeNodeNameLiteralDots" + +@final +class Config: + @property + def class_mappings(self) -> list[str]: ... + @property + def class_mappings_match_path(self) -> bool: ... + @property + def classes_path(self) -> str: ... + @property + def compatflags(self) -> set[CompatFlag]: ... + @property + def compose_node_name(self) -> bool: ... + @classmethod + def from_dict( + cls, inventory_path: str, config: dict[str, Any], verbose: bool = False + ) -> Config: ... + @property + def ignore_class_notfound(self) -> bool: ... + @property + def ignore_class_notfound_regexp(self) -> list[str]: ... + @property + def ignore_overwritten_missing_references(self) -> bool: ... + @property + def inventory_path(self) -> str: ... + @property + def nodes_path(self) -> str: ... + @property + def verbose_warnings(self) -> bool: ... + +@final +class NodeInfoMeta: + @property + def environment(self) -> str: ... + @property + def name(self) -> str: ... + @property + def node(self) -> str: ... + @property + def render_time(self) -> datetime: ... + @property + def uri(self) -> str: ... + +@final +class NodeInfo: + @property + def __reclass__(self) -> NodeInfoMeta: ... + @property + def applications(self) -> list[str]: ... + def as_dict(self) -> dict[str, Any]: ... + @property + def classes(self) -> list[str]: ... + @property + def exports(self) -> dict[str, Any]: ... + @property + def parameters(self) -> dict[str, Any]: ... + def reclass_as_dict(self) -> dict[str, Any]: ... + +@final +class Inventory: + @property + def applications(self) -> dict[str, list[str]]: ... + def as_dict(self) -> dict[str, Any]: ... + @property + def classes(self) -> dict[str, list[str]]: ... + @property + def nodes(self) -> dict[str, NodeInfo]: ... + +@final +class Reclass: + @property + def config(self) -> Config: ... + def __new__( + cls, + inventory_path: Optional[str] = ".", + nodes_path: Optional[str] = None, + classes_path: Optional[str] = None, + ignore_class_notfound: Optional[bool] = None, + ): ... + @classmethod + def from_config_file( + cls, inventory_path: str, config_file: str, verbose: bool = False + ) -> Reclass: ... + @classmethod + def from_config(cls, config: Config) -> Reclass: ... + def nodeinfo(self, nodename: str) -> NodeInfo: ... + def inventory(self) -> Inventory: ... + @classmethod + def set_thread_count(cls, count: int): ... + def set_compat_flag(self, flag: CompatFlag): ... + def unset_compat_flag(self, flag: CompatFlag): ... + def clear_compat_flags(self): ... + @property + def nodes(self) -> dict[str, str]: ... + @property + def classes(self) -> dict[str, str]: ... + def set_ignore_class_notfound_regexp(self, patterns: list[str]): ... + +def buildinfo() -> dict[str, str]: ... From 306887f90407211a270ba54e1d7de6eab3999367 Mon Sep 17 00:00:00 2001 From: Simon Gerber Date: Wed, 17 Dec 2025 10:32:24 +0100 Subject: [PATCH 3/3] Run `stubtest` for reclass_rs and `mypy` for the Python tests in CI We extend the x86_64 Python workflow to run `stubtest` and `mypy` to get basic validation for the type stubs. This should be sufficient to catch PRs that introduce new Python API surface without adding type stubs. Unfortunately, from some limited testing, `stubtest` doesn't necessarily catch incorrect type stubs, since some of the PyO3 generated functions apparently have very limited metadata (especially for return types). --- .github/workflows/python.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml index c0b96c5..45e10cd 100644 --- a/.github/workflows/python.yml +++ b/.github/workflows/python.yml @@ -94,7 +94,9 @@ jobs: python3 -m venv .venv source .venv/bin/activate pip install reclass-rs --find-links dist --force-reinstall - pip install pytest kapicorp-reclass + pip install pytest mypy kapicorp-reclass + stubtest reclass_rs + mypy --check-untyped-defs --ignore-missing-imports tests/ pytest - name: pytest if: ${{ !startsWith(matrix.platform.target, 'x86') && matrix.platform.target != 'ppc64' }}