From 908c537c6fc4a63fe150ab274aadc7209de38016 Mon Sep 17 00:00:00 2001 From: Valentin Stanciu <250871+svalentin@users.noreply.github.com> Date: Fri, 14 Mar 2025 22:21:53 +0000 Subject: [PATCH 1/5] [mypyc] Initial support for annotating classes to be non extension --- mypyc/irbuild/util.py | 23 ++++++++++------ mypyc/test-data/fixtures/ir.py | 2 ++ mypyc/test-data/run-classes.test | 45 ++++++++++++++++++++++++++++++++ 3 files changed, 62 insertions(+), 8 deletions(-) diff --git a/mypyc/irbuild/util.py b/mypyc/irbuild/util.py index 43ee547f8b4ff..ab25933f4a08c 100644 --- a/mypyc/irbuild/util.py +++ b/mypyc/irbuild/util.py @@ -126,14 +126,21 @@ def get_mypyc_attrs(stmt: ClassDef | Decorator) -> dict[str, Any]: def is_extension_class(cdef: ClassDef) -> bool: - if any( - not is_trait_decorator(d) - and not is_dataclass_decorator(d) - and not get_mypyc_attr_call(d) - and not is_final_decorator(d) - for d in cdef.decorators - ): - return False + for d in cdef.decorators: + mypyc_attr_call = get_mypyc_attr_call(d) + # Classes decorated with "@mypyc_attr(non_extension_class=True)" are not extension classes + if mypyc_attr_call and "non_extension_class" in mypyc_attr_call.arg_names: + return False + + # Classes that have any decorator other than supported decorators, are not extension classes + if ( + not is_trait_decorator(d) + and not is_dataclass_decorator(d) + and not mypyc_attr_call + and not is_final_decorator(d) + ): + return False + if cdef.info.typeddict_type: return False if cdef.info.is_named_tuple: diff --git a/mypyc/test-data/fixtures/ir.py b/mypyc/test-data/fixtures/ir.py index e651e7adc3846..0986f75224113 100644 --- a/mypyc/test-data/fixtures/ir.py +++ b/mypyc/test-data/fixtures/ir.py @@ -350,8 +350,10 @@ def next(i: Iterator[_T]) -> _T: pass def next(i: Iterator[_T], default: _T) -> _T: pass def hash(o: object) -> int: ... def globals() -> Dict[str, Any]: ... +def hasattr(obj: object, name: str) -> bool: ... def getattr(obj: object, name: str, default: Any = None) -> Any: ... def setattr(obj: object, name: str, value: Any) -> None: ... +def delattr(obj: object, name: str) -> None: ... def enumerate(x: Iterable[_T]) -> Iterator[Tuple[int, _T]]: ... @overload def zip(x: Iterable[_T], y: Iterable[_S]) -> Iterator[Tuple[_T, _S]]: ... diff --git a/mypyc/test-data/run-classes.test b/mypyc/test-data/run-classes.test index 601d6d7a65a0f..e8cf378e15209 100644 --- a/mypyc/test-data/run-classes.test +++ b/mypyc/test-data/run-classes.test @@ -2829,3 +2829,48 @@ Traceback (most recent call last): File "native.py", line 5, in __del__ raise Exception("e2") Exception: e2 + +[case testNonExtensionClassAttr] +from mypy_extensions import mypyc_attr +from testutil import assertRaises + +@mypyc_attr(non_extension_class=True) +class AnnontatedNonExtensionClass: + pass + +class DerivedClass(AnnontatedNonExtensionClass): + pass + +class ExtensionClass(): + pass + +def test_function(): + setattr(AnnontatedNonExtensionClass, 'attr_class', 5) + assert(hasattr(AnnontatedNonExtensionClass, 'attr_class') == True) + assert(getattr(AnnontatedNonExtensionClass, 'attr_class') == 5) + delattr(AnnontatedNonExtensionClass, 'attr_class') + assert(hasattr(AnnontatedNonExtensionClass, 'attr_class') == False) + + inst = AnnontatedNonExtensionClass() + setattr(inst, 'attr_instance', 6) + assert(hasattr(inst, 'attr_instance') == True) + assert(getattr(inst, 'attr_instance') == 6) + delattr(inst, 'attr_instance') + assert(hasattr(inst, 'attr_instance') == False) + + setattr(DerivedClass, 'attr_class', 5) + assert(hasattr(DerivedClass, 'attr_class') == True) + assert(getattr(DerivedClass, 'attr_class') == 5) + delattr(DerivedClass, 'attr_class') + assert(hasattr(DerivedClass, 'attr_class') == False) + + derived_inst = DerivedClass() + setattr(derived_inst, 'attr_instance', 6) + assert(hasattr(derived_inst, 'attr_instance') == True) + assert(getattr(derived_inst, 'attr_instance') == 6) + delattr(derived_inst, 'attr_instance') + assert(hasattr(derived_inst, 'attr_instance') == False) + + ext_inst = ExtensionClass() + with assertRaises(AttributeError, "'ExtensionClass' object has no attribute 'attr_instance' and no __dict__ for setting new attributes"): + setattr(ext_inst, 'attr_instance', 6) From 7bc105ab71771a6551bbc5a3e641d20b592dc52b Mon Sep 17 00:00:00 2001 From: Valentin Stanciu <250871+svalentin@users.noreply.github.com> Date: Thu, 27 Mar 2025 14:26:14 +0000 Subject: [PATCH 2/5] Make test version independent --- mypyc/test-data/run-classes.test | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mypyc/test-data/run-classes.test b/mypyc/test-data/run-classes.test index e8cf378e15209..b461c6ed57768 100644 --- a/mypyc/test-data/run-classes.test +++ b/mypyc/test-data/run-classes.test @@ -2872,5 +2872,5 @@ def test_function(): assert(hasattr(derived_inst, 'attr_instance') == False) ext_inst = ExtensionClass() - with assertRaises(AttributeError, "'ExtensionClass' object has no attribute 'attr_instance' and no __dict__ for setting new attributes"): + with assertRaises(AttributeError): setattr(ext_inst, 'attr_instance', 6) From f4e5b9335cea2b4a69cdf42b276efcd2452c50ed Mon Sep 17 00:00:00 2001 From: Valentin Stanciu <250871+svalentin@users.noreply.github.com> Date: Thu, 27 Mar 2025 17:09:26 +0000 Subject: [PATCH 3/5] make it more user friendly --- mypyc/irbuild/prepare.py | 2 +- mypyc/irbuild/util.py | 46 +++++++++++++++++++++++++--- mypyc/test-data/irbuild-classes.test | 25 +++++++++++++++ mypyc/test-data/run-classes.test | 6 ++-- 4 files changed, 70 insertions(+), 9 deletions(-) diff --git a/mypyc/irbuild/prepare.py b/mypyc/irbuild/prepare.py index b6cd632e475fd..e014d97fedd91 100644 --- a/mypyc/irbuild/prepare.py +++ b/mypyc/irbuild/prepare.py @@ -88,7 +88,7 @@ def build_type_map( is_abstract=cdef.info.is_abstract, is_final_class=cdef.info.is_final, ) - class_ir.is_ext_class = is_extension_class(cdef) + class_ir.is_ext_class = is_extension_class(module.path, cdef, errors) if class_ir.is_ext_class: class_ir.deletable = cdef.info.deletable_attributes.copy() # If global optimizations are disabled, turn of tracking of class children diff --git a/mypyc/irbuild/util.py b/mypyc/irbuild/util.py index ab25933f4a08c..8e41d013803ec 100644 --- a/mypyc/irbuild/util.py +++ b/mypyc/irbuild/util.py @@ -29,6 +29,7 @@ ) from mypy.semanal import refers_to_fullname from mypy.types import FINAL_DECORATOR_NAMES +from mypyc.errors import Errors DATACLASS_DECORATORS = {"dataclasses.dataclass", "attr.s", "attr.attrs"} @@ -125,18 +126,53 @@ def get_mypyc_attrs(stmt: ClassDef | Decorator) -> dict[str, Any]: return attrs -def is_extension_class(cdef: ClassDef) -> bool: +def is_extension_class(path: str, cdef: ClassDef, errors: Errors) -> bool: + # Check for @mypyc_attr(native_class=True/False) decorator. + # Classes with native_class=False are explicitly marked as non extension. + # Classes with native_class=True should be extension classes, but they might + # not be able to be due to other reasons. Print an error in that case. + forced_native_class = False for d in cdef.decorators: mypyc_attr_call = get_mypyc_attr_call(d) - # Classes decorated with "@mypyc_attr(non_extension_class=True)" are not extension classes - if mypyc_attr_call and "non_extension_class" in mypyc_attr_call.arg_names: - return False + if mypyc_attr_call: + for i, name in enumerate(mypyc_attr_call.arg_names): + if name != "native_class": + continue + + if not isinstance(mypyc_attr_call.args[i], NameExpr): + errors.error( + "native_class must be used with True or False only", path, cdef.line + ) + break + + if mypyc_attr_call.args[i].name == "False": + return False + elif mypyc_attr_call.args[i].name == "True": + forced_native_class = True + break + else: + errors.error( + "native_class must be used with True or False only", path, cdef.line + ) + break + + implicit_extension_class = is_implicit_extension_class(cdef) + + if forced_native_class and not implicit_extension_class: + errors.error( + "Class is marked as native_class=True but it can't be a native class", path, cdef.line + ) + + return implicit_extension_class + +def is_implicit_extension_class(cdef: ClassDef) -> bool: + for d in cdef.decorators: # Classes that have any decorator other than supported decorators, are not extension classes if ( not is_trait_decorator(d) and not is_dataclass_decorator(d) - and not mypyc_attr_call + and not get_mypyc_attr_call(d) and not is_final_decorator(d) ): return False diff --git a/mypyc/test-data/irbuild-classes.test b/mypyc/test-data/irbuild-classes.test index ed7c167d8621c..972146bcb0b4c 100644 --- a/mypyc/test-data/irbuild-classes.test +++ b/mypyc/test-data/irbuild-classes.test @@ -1345,3 +1345,28 @@ class SomeEnum(Enum): ALIAS = Literal[SomeEnum.AVALUE] ALIAS2 = Union[Literal[SomeEnum.AVALUE], None] + +[case testMypycAttrNativeClassErrors] +from mypy_extensions import mypyc_attr + +@mypyc_attr(native_class=False) +class AnnontatedNonExtensionClass: + pass + +@mypyc_attr(native_class=False) +class DerivedExplicitNonNativeClass(AnnontatedNonExtensionClass): + pass + + +def decorator(cls): + return cls + +@mypyc_attr(native_class=True) +@decorator +class NonNativeClassContradiction(): # E: Class is marked as native_class=True but it can't be a native class + pass + + +@mypyc_attr(native_class="yes") +class BadUse(): # E: native_class must be used with True or False only + pass diff --git a/mypyc/test-data/run-classes.test b/mypyc/test-data/run-classes.test index b461c6ed57768..158eba080d207 100644 --- a/mypyc/test-data/run-classes.test +++ b/mypyc/test-data/run-classes.test @@ -2834,14 +2834,14 @@ Exception: e2 from mypy_extensions import mypyc_attr from testutil import assertRaises -@mypyc_attr(non_extension_class=True) +@mypyc_attr(native_class=False) class AnnontatedNonExtensionClass: pass class DerivedClass(AnnontatedNonExtensionClass): pass -class ExtensionClass(): +class ImplicitExtensionClass(): pass def test_function(): @@ -2871,6 +2871,6 @@ def test_function(): delattr(derived_inst, 'attr_instance') assert(hasattr(derived_inst, 'attr_instance') == False) - ext_inst = ExtensionClass() + ext_inst = ImplicitExtensionClass() with assertRaises(AttributeError): setattr(ext_inst, 'attr_instance', 6) From 8d5f8c255048e601082869b9ba5e2141ec3e2a9d Mon Sep 17 00:00:00 2001 From: Valentin Stanciu <250871+svalentin@users.noreply.github.com> Date: Fri, 28 Mar 2025 13:44:50 +0000 Subject: [PATCH 4/5] Fix test + move to helper function with docs --- mypyc/irbuild/util.py | 65 +++++++++++++++++++++++++------------------ 1 file changed, 38 insertions(+), 27 deletions(-) diff --git a/mypyc/irbuild/util.py b/mypyc/irbuild/util.py index 8e41d013803ec..939c543c85a28 100644 --- a/mypyc/irbuild/util.py +++ b/mypyc/irbuild/util.py @@ -128,37 +128,17 @@ def get_mypyc_attrs(stmt: ClassDef | Decorator) -> dict[str, Any]: def is_extension_class(path: str, cdef: ClassDef, errors: Errors) -> bool: # Check for @mypyc_attr(native_class=True/False) decorator. + explicit_native_class = get_explicit_native_class(path, cdef, errors) + # Classes with native_class=False are explicitly marked as non extension. - # Classes with native_class=True should be extension classes, but they might - # not be able to be due to other reasons. Print an error in that case. - forced_native_class = False - for d in cdef.decorators: - mypyc_attr_call = get_mypyc_attr_call(d) - if mypyc_attr_call: - for i, name in enumerate(mypyc_attr_call.arg_names): - if name != "native_class": - continue - - if not isinstance(mypyc_attr_call.args[i], NameExpr): - errors.error( - "native_class must be used with True or False only", path, cdef.line - ) - break - - if mypyc_attr_call.args[i].name == "False": - return False - elif mypyc_attr_call.args[i].name == "True": - forced_native_class = True - break - else: - errors.error( - "native_class must be used with True or False only", path, cdef.line - ) - break + if explicit_native_class is False: + return False implicit_extension_class = is_implicit_extension_class(cdef) - if forced_native_class and not implicit_extension_class: + # Classes with native_class=True should be extension classes, but they might + # not be able to be due to other reasons. Print an error in that case. + if explicit_native_class is True and not implicit_extension_class: errors.error( "Class is marked as native_class=True but it can't be a native class", path, cdef.line ) @@ -166,6 +146,37 @@ def is_extension_class(path: str, cdef: ClassDef, errors: Errors) -> bool: return implicit_extension_class +def get_explicit_native_class(path: str, cdef: ClassDef, errors: Errors) -> bool | None: + """Return value of @mypyc_attr(native_class=True/False) decorator. + + Look for a @mypyc_attr decorator with native_class=True/False and return + the value assigned or None if it doesn't exist. Other values are an error. + """ + + for d in cdef.decorators: + mypyc_attr_call = get_mypyc_attr_call(d) + if not mypyc_attr_call: + continue + + for i, name in enumerate(mypyc_attr_call.arg_names): + if name != "native_class": + continue + + arg = mypyc_attr_call.args[i] + if not isinstance(arg, NameExpr): + errors.error("native_class must be used with True or False only", path, cdef.line) + return None + + if arg.name == "False": + return False + elif arg.name == "True": + return True + else: + errors.error("native_class must be used with True or False only", path, cdef.line) + return None + return None + + def is_implicit_extension_class(cdef: ClassDef) -> bool: for d in cdef.decorators: # Classes that have any decorator other than supported decorators, are not extension classes From fe26bf83b6518d46f0a3918a4acb4fd3f04f172a Mon Sep 17 00:00:00 2001 From: Valentin Stanciu <250871+svalentin@users.noreply.github.com> Date: Fri, 28 Mar 2025 14:24:35 +0000 Subject: [PATCH 5/5] Add test for native_class=True + rename test --- mypyc/test-data/run-classes.test | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/mypyc/test-data/run-classes.test b/mypyc/test-data/run-classes.test index 158eba080d207..edf9e6bf19064 100644 --- a/mypyc/test-data/run-classes.test +++ b/mypyc/test-data/run-classes.test @@ -2830,7 +2830,7 @@ Traceback (most recent call last): raise Exception("e2") Exception: e2 -[case testNonExtensionClassAttr] +[case testMypycAttrNativeClass] from mypy_extensions import mypyc_attr from testutil import assertRaises @@ -2844,6 +2844,10 @@ class DerivedClass(AnnontatedNonExtensionClass): class ImplicitExtensionClass(): pass +@mypyc_attr(native_class=True) +class AnnotatedExtensionClass(): + pass + def test_function(): setattr(AnnontatedNonExtensionClass, 'attr_class', 5) assert(hasattr(AnnontatedNonExtensionClass, 'attr_class') == True) @@ -2874,3 +2878,7 @@ def test_function(): ext_inst = ImplicitExtensionClass() with assertRaises(AttributeError): setattr(ext_inst, 'attr_instance', 6) + + explicit_ext_inst = AnnotatedExtensionClass() + with assertRaises(AttributeError): + setattr(explicit_ext_inst, 'attr_instance', 6)