diff --git a/newsfragments/5552.added.md b/newsfragments/5552.added.md new file mode 100644 index 00000000000..e0d1380090f --- /dev/null +++ b/newsfragments/5552.added.md @@ -0,0 +1 @@ +Introspection: `@typing.final` on final classes \ No newline at end of file diff --git a/pyo3-introspection/src/introspection.rs b/pyo3-introspection/src/introspection.rs index 706ac870f8e..42789941660 100644 --- a/pyo3-introspection/src/introspection.rs +++ b/pyo3-introspection/src/introspection.rs @@ -124,9 +124,17 @@ fn convert_members<'a>( chunks_by_parent, )?); } - Chunk::Class { name, id } => { - classes.push(convert_class(id, name, chunks_by_id, chunks_by_parent)?) - } + Chunk::Class { + name, + id, + decorators, + } => classes.push(convert_class( + id, + name, + decorators, + chunks_by_id, + chunks_by_parent, + )?), Chunk::Function { name, id: _, @@ -178,6 +186,7 @@ fn convert_members<'a>( fn convert_class( id: &str, name: &str, + decorators: &[ChunkTypeHint], chunks_by_id: &HashMap<&str, &Chunk>, chunks_by_parent: &HashMap<&str, Vec<&Chunk>>, ) -> Result { @@ -198,9 +207,29 @@ fn convert_class( name: name.into(), methods, attributes, + decorators: decorators + .iter() + .map(convert_decorator) + .collect::>()?, }) } +fn convert_decorator(decorator: &ChunkTypeHint) -> Result { + match convert_type_hint(decorator) { + TypeHint::Plain(id) => Ok(PythonIdentifier { + module: None, + name: id.clone(), + }), + TypeHint::Ast(expr) => { + if let TypeHintExpr::Identifier(i) = expr { + Ok(i) + } else { + bail!("PyO3 introspection currently only support decorators that are identifiers of a Python function") + } + } + } +} + fn convert_function( name: &str, arguments: &ChunkArguments, @@ -211,19 +240,7 @@ fn convert_function( name: name.into(), decorators: decorators .iter() - .map(|d| match convert_type_hint(d) { - TypeHint::Plain(id) => Ok(PythonIdentifier { - module: None, - name: id.clone(), - }), - TypeHint::Ast(expr) => { - if let TypeHintExpr::Identifier(i) = expr { - Ok(i) - } else { - bail!("A decorator must be the identifier of a Python function") - } - } - }) + .map(convert_decorator) .collect::>()?, arguments: Arguments { positional_only_arguments: arguments.posonlyargs.iter().map(convert_argument).collect(), @@ -444,6 +461,8 @@ enum Chunk { Class { id: String, name: String, + #[serde(default)] + decorators: Vec, }, Function { #[serde(default)] diff --git a/pyo3-introspection/src/model.rs b/pyo3-introspection/src/model.rs index d6fdd8945ee..e747b5c3e52 100644 --- a/pyo3-introspection/src/model.rs +++ b/pyo3-introspection/src/model.rs @@ -13,6 +13,8 @@ pub struct Class { pub name: String, pub methods: Vec, pub attributes: Vec, + /// decorator like 'typing.final' + pub decorators: Vec, } #[derive(Debug, Eq, PartialEq, Clone, Hash)] diff --git a/pyo3-introspection/src/stubs.rs b/pyo3-introspection/src/stubs.rs index 7181a02ab18..11f8c25a848 100644 --- a/pyo3-introspection/src/stubs.rs +++ b/pyo3-introspection/src/stubs.rs @@ -119,7 +119,15 @@ fn module_stubs(module: &Module, parents: &[&str]) -> String { } fn class_stubs(class: &Class, imports: &Imports) -> String { - let mut buffer = format!("class {}:", class.name); + let mut buffer = String::new(); + for decorator in &class.decorators { + buffer.push('@'); + imports.serialize_identifier(decorator, &mut buffer); + buffer.push('\n'); + } + buffer.push_str("class "); + buffer.push_str(&class.name); + buffer.push(':'); if class.methods.is_empty() && class.attributes.is_empty() { buffer.push_str(" ..."); return buffer; @@ -433,6 +441,9 @@ impl ElementsUsedInAnnotations { } fn walk_class(&mut self, class: &Class) { + for decorator in &class.decorators { + self.walk_identifier(decorator); + } for method in &class.methods { self.walk_function(method); } @@ -658,6 +669,10 @@ mod tests { name: "A".into(), methods: Vec::new(), attributes: Vec::new(), + decorators: vec![PythonIdentifier { + module: Some("typing".into()), + name: "final".into(), + }], }], functions: vec![Function { name: String::new(), @@ -683,6 +698,7 @@ mod tests { "from bat import A as A2", "from builtins import int as int2", "from foo import A as A3, B", + "from typing import final" ] ); let mut output = String::new(); diff --git a/pyo3-macros-backend/src/introspection.rs b/pyo3-macros-backend/src/introspection.rs index bab266b960b..deac4c807d2 100644 --- a/pyo3-macros-backend/src/introspection.rs +++ b/pyo3-macros-backend/src/introspection.rs @@ -89,19 +89,26 @@ pub fn class_introspection_code( pyo3_crate_path: &PyO3CratePath, ident: &Ident, name: &str, + is_final: bool, ) -> TokenStream { - IntrospectionNode::Map( - [ - ("type", IntrospectionNode::String("class".into())), - ( - "id", - IntrospectionNode::IntrospectionId(Some(ident_to_type(ident))), - ), - ("name", IntrospectionNode::String(name.into())), - ] - .into(), - ) - .emit(pyo3_crate_path) + let mut desc = HashMap::from([ + ("type", IntrospectionNode::String("class".into())), + ( + "id", + IntrospectionNode::IntrospectionId(Some(ident_to_type(ident))), + ), + ("name", IntrospectionNode::String(name.into())), + ]); + if is_final { + desc.insert( + "decorators", + IntrospectionNode::List(vec![IntrospectionNode::ConstantType( + PythonIdentifier::module_attr("typing", "final"), + ) + .into()]), + ); + } + IntrospectionNode::Map(desc).emit(pyo3_crate_path) } #[expect(clippy::too_many_arguments)] diff --git a/pyo3-macros-backend/src/pyclass.rs b/pyo3-macros-backend/src/pyclass.rs index 8e6f1cebf77..2d37d3cec7b 100644 --- a/pyo3-macros-backend/src/pyclass.rs +++ b/pyo3-macros-backend/src/pyclass.rs @@ -2683,7 +2683,12 @@ impl<'a> PyClassImplsBuilder<'a> { let Ctx { pyo3_path, .. } = ctx; let name = get_class_python_name(self.cls, self.attr).to_string(); let ident = self.cls; - let static_introspection = class_introspection_code(pyo3_path, ident, &name); + let static_introspection = class_introspection_code( + pyo3_path, + ident, + &name, + self.attr.options.subclass.is_none(), + ); let introspection_id = introspection_id_const(); quote! { #static_introspection diff --git a/pytests/src/lib.rs b/pytests/src/lib.rs index 26a33f81cab..60cb741adeb 100644 --- a/pytests/src/lib.rs +++ b/pytests/src/lib.rs @@ -25,7 +25,7 @@ mod pyo3_pytests { #[pymodule_export] use { comparisons::comparisons, consts::consts, enums::enums, pyclasses::pyclasses, - pyfunctions::pyfunctions, + pyfunctions::pyfunctions, subclassing::subclassing, }; // Inserting to sys.modules allows importing submodules nicely from Python @@ -43,7 +43,6 @@ mod pyo3_pytests { m.add_wrapped(wrap_pymodule!(othermod::othermod))?; m.add_wrapped(wrap_pymodule!(path::path))?; m.add_wrapped(wrap_pymodule!(sequence::sequence))?; - m.add_wrapped(wrap_pymodule!(subclassing::subclassing))?; // Inserting to sys.modules allows importing submodules nicely from Python // e.g. import pyo3_pytests.buf_and_str as bas diff --git a/pytests/src/subclassing.rs b/pytests/src/subclassing.rs index 8e451cd9183..ab3f2a6786f 100644 --- a/pytests/src/subclassing.rs +++ b/pytests/src/subclassing.rs @@ -2,23 +2,22 @@ use pyo3::prelude::*; -#[pyclass(subclass)] -pub struct Subclassable {} +#[pymodule(gil_used = false)] +pub mod subclassing { + use pyo3::prelude::*; -#[pymethods] -impl Subclassable { - #[new] - fn new() -> Self { - Subclassable {} - } + #[pyclass(subclass)] + pub struct Subclassable {} - fn __str__(&self) -> &'static str { - "Subclassable" - } -} + #[pymethods] + impl Subclassable { + #[new] + fn new() -> Self { + Subclassable {} + } -#[pymodule] -pub fn subclassing(m: &Bound<'_, PyModule>) -> PyResult<()> { - m.add_class::()?; - Ok(()) + fn __str__(&self) -> &'static str { + "Subclassable" + } + } } diff --git a/pytests/stubs/comparisons.pyi b/pytests/stubs/comparisons.pyi index 7287dea5d7c..77ea05a5123 100644 --- a/pytests/stubs/comparisons.pyi +++ b/pytests/stubs/comparisons.pyi @@ -1,17 +1,23 @@ +from typing import final + +@final class Eq: def __eq__(self, /, other: Eq) -> bool: ... def __ne__(self, /, other: Eq) -> bool: ... def __new__(cls, /, value: int) -> Eq: ... +@final class EqDefaultNe: def __eq__(self, /, other: EqDefaultNe) -> bool: ... def __new__(cls, /, value: int) -> EqDefaultNe: ... +@final class EqDerived: def __eq__(self, /, other: EqDerived) -> bool: ... def __ne__(self, /, other: EqDerived) -> bool: ... def __new__(cls, /, value: int) -> EqDerived: ... +@final class Ordered: def __eq__(self, /, other: Ordered) -> bool: ... def __ge__(self, /, other: Ordered) -> bool: ... @@ -21,6 +27,7 @@ class Ordered: def __ne__(self, /, other: Ordered) -> bool: ... def __new__(cls, /, value: int) -> Ordered: ... +@final class OrderedDefaultNe: def __eq__(self, /, other: OrderedDefaultNe) -> bool: ... def __ge__(self, /, other: OrderedDefaultNe) -> bool: ... @@ -29,6 +36,7 @@ class OrderedDefaultNe: def __lt__(self, /, other: OrderedDefaultNe) -> bool: ... def __new__(cls, /, value: int) -> OrderedDefaultNe: ... +@final class OrderedDerived: def __eq__(self, /, other: OrderedDerived) -> bool: ... def __ge__(self, /, other: OrderedDerived) -> bool: ... @@ -40,6 +48,7 @@ class OrderedDerived: def __new__(cls, /, value: int) -> OrderedDerived: ... def __str__(self, /) -> str: ... +@final class OrderedRichCmp: def __eq__(self, /, other: OrderedRichCmp) -> bool: ... def __ge__(self, /, other: OrderedRichCmp) -> bool: ... diff --git a/pytests/stubs/consts.pyi b/pytests/stubs/consts.pyi index 66b0672c8a5..dfff3973371 100644 --- a/pytests/stubs/consts.pyi +++ b/pytests/stubs/consts.pyi @@ -1,7 +1,8 @@ -from typing import Final +from typing import Final, final PI: Final[float] SIMPLE: Final = "SIMPLE" +@final class ClassWithConst: INSTANCE: Final[ClassWithConst] diff --git a/pytests/stubs/enums.pyi b/pytests/stubs/enums.pyi index 95eaabac451..ec43dca52f8 100644 --- a/pytests/stubs/enums.pyi +++ b/pytests/stubs/enums.pyi @@ -1,6 +1,9 @@ +from typing import final + class ComplexEnum: ... class MixedComplexEnum: ... +@final class SimpleEnum: def __eq__(self, /, other: SimpleEnum | int) -> bool: ... def __ne__(self, /, other: SimpleEnum | int) -> bool: ... diff --git a/pytests/stubs/pyclasses.pyi b/pytests/stubs/pyclasses.pyi index c89ca836b4f..7aba82f4dfa 100644 --- a/pytests/stubs/pyclasses.pyi +++ b/pytests/stubs/pyclasses.pyi @@ -1,9 +1,10 @@ from _typeshed import Incomplete -from typing import Any +from typing import Any, final class AssertingBaseClass: def __new__(cls, /, expected_type: Any) -> AssertingBaseClass: ... +@final class ClassWithDecorators: def __new__(cls, /) -> ClassWithDecorators: ... @property @@ -18,16 +19,20 @@ class ClassWithDecorators: @staticmethod def static_method() -> int: ... +@final class ClassWithDict: def __new__(cls, /) -> ClassWithDict: ... +@final class ClassWithoutConstructor: ... +@final class EmptyClass: def __len__(self, /) -> int: ... def __new__(cls, /) -> EmptyClass: ... def method(self, /) -> None: ... +@final class PlainObject: @property def bar(self, /) -> int: ... @@ -38,10 +43,12 @@ class PlainObject: @foo.setter def foo(self, /, value: str) -> None: ... +@final class PyClassIter: def __new__(cls, /) -> PyClassIter: ... def __next__(self, /) -> int: ... +@final class PyClassThreadIter: def __new__(cls, /) -> PyClassThreadIter: ... def __next__(self, /) -> int: ... diff --git a/pytests/stubs/subclassing.pyi b/pytests/stubs/subclassing.pyi new file mode 100644 index 00000000000..80f512ca014 --- /dev/null +++ b/pytests/stubs/subclassing.pyi @@ -0,0 +1,3 @@ +class Subclassable: + def __new__(cls, /) -> Subclassable: ... + def __str__(self, /) -> str: ...