Skip to content

Commit a936e30

Browse files
authored
[mypyc] Generate __getattr__ wrapper (#19909)
Fixes mypyc/mypyc#887 Generate a wrapper function for `__getattr__` in classes where it's defined and put it into the `tp_getattro` slot. At runtime, this wrapper function is called for every attribute access, so to match behavior of interpreted python it needs to first check if the given name is present in the type dictionary and only call the user-defined `__getattr__` when it's not present. Checking the type dictionary is implemented using a cpython function `_PyObject_GenericGetAttrWithDict` which is also used in the default attribute access handler in [cpython](https://github.com/python/cpython/blob/dd45179fa0f5ad2fd169cdd35065df2c3bce85bc/Objects/typeobject.c#L10676). In compiled code, the wrapper will only be called when the attribute name cannot be statically resolved. When it can be resolved, the attribute will be accessed directly in the underlying C struct, or the generated function will be directly called in case of resolving method names. No change from existing behavior. When the name cannot be statically resolved, mypyc generates calls to `PyObject_GetAttr` which internally calls `tp_getattro`. In interpreted code that uses compiled classes, attribute access will always result in calls to `PyObject_GetAttr` so there's always a dict look-up in the wrapper to find the attribute. But that's the case already, the dict look-up happens in the default attribute access handler in cpython. So the wrapper should not bring any negative performance impact.
1 parent c058b09 commit a936e30

File tree

11 files changed

+648
-4
lines changed

11 files changed

+648
-4
lines changed

mypyc/codegen/emitclass.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,12 @@ def native_slot(cl: ClassIR, fn: FuncIR, emitter: Emitter) -> str:
3939
return f"{NATIVE_PREFIX}{fn.cname(emitter.names)}"
4040

4141

42+
def dunder_attr_slot(cl: ClassIR, fn: FuncIR, emitter: Emitter) -> str:
43+
wrapper_fn = cl.get_method(fn.name + "__wrapper")
44+
assert wrapper_fn
45+
return f"{NATIVE_PREFIX}{wrapper_fn.cname(emitter.names)}"
46+
47+
4248
# We maintain a table from dunder function names to struct slots they
4349
# correspond to and functions that generate a wrapper (if necessary)
4450
# and return the function name to stick in the slot.
@@ -55,6 +61,7 @@ def native_slot(cl: ClassIR, fn: FuncIR, emitter: Emitter) -> str:
5561
"__iter__": ("tp_iter", native_slot),
5662
"__hash__": ("tp_hash", generate_hash_wrapper),
5763
"__get__": ("tp_descr_get", generate_get_wrapper),
64+
"__getattr__": ("tp_getattro", dunder_attr_slot),
5865
}
5966

6067
AS_MAPPING_SLOT_DEFS: SlotTable = {

mypyc/ir/ops.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1221,6 +1221,7 @@ def __init__(
12211221
var_arg_idx: int = -1,
12221222
*,
12231223
is_pure: bool = False,
1224+
returns_null: bool = False,
12241225
) -> None:
12251226
self.error_kind = error_kind
12261227
super().__init__(line)
@@ -1235,7 +1236,10 @@ def __init__(
12351236
# and all the arguments are immutable. Pure functions support
12361237
# additional optimizations. Pure functions never fail.
12371238
self.is_pure = is_pure
1238-
if is_pure:
1239+
# The function might return a null value that does not indicate
1240+
# an error.
1241+
self.returns_null = returns_null
1242+
if is_pure or returns_null:
12391243
assert error_kind == ERR_NEVER
12401244

12411245
def sources(self) -> list[Value]:

mypyc/irbuild/builder.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1241,6 +1241,7 @@ def enter_method(
12411241
ret_type: RType,
12421242
fn_info: FuncInfo | str = "",
12431243
self_type: RType | None = None,
1244+
internal: bool = False,
12441245
) -> Iterator[None]:
12451246
"""Generate IR for a method.
12461247
@@ -1268,7 +1269,7 @@ def enter_method(
12681269
sig = FuncSignature(args, ret_type)
12691270
name = self.function_name_stack.pop()
12701271
class_ir = self.class_ir_stack.pop()
1271-
decl = FuncDecl(name, class_ir.name, self.module_name, sig)
1272+
decl = FuncDecl(name, class_ir.name, self.module_name, sig, internal=internal)
12721273
ir = FuncIR(decl, arg_regs, blocks)
12731274
class_ir.methods[name] = ir
12741275
class_ir.method_decls[name] = ir.decl

mypyc/irbuild/function.py

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@
4242
)
4343
from mypyc.ir.ops import (
4444
BasicBlock,
45+
ComparisonOp,
4546
GetAttr,
4647
Integer,
4748
LoadAddress,
@@ -81,7 +82,7 @@
8182
dict_new_op,
8283
exact_dict_set_item_op,
8384
)
84-
from mypyc.primitives.generic_ops import py_setattr_op
85+
from mypyc.primitives.generic_ops import generic_getattr, py_setattr_op
8586
from mypyc.primitives.misc_ops import register_function
8687
from mypyc.primitives.registry import builtin_names
8788
from mypyc.sametype import is_same_method_signature, is_same_type
@@ -364,6 +365,56 @@ def gen_func_ir(
364365
return (func_ir, func_reg)
365366

366367

368+
def generate_getattr_wrapper(builder: IRBuilder, cdef: ClassDef, getattr: FuncDef) -> None:
369+
"""
370+
Generate a wrapper function for __getattr__ that can be put into the tp_getattro slot.
371+
The wrapper takes one argument besides self which is the attribute name.
372+
It first checks if the name matches any of the attributes of this class.
373+
If it does, it returns that attribute. If none match, it calls __getattr__.
374+
375+
__getattr__ is not supported in classes that allow interpreted subclasses because the
376+
tp_getattro slot is inherited by subclasses and if the subclass overrides __getattr__,
377+
the override would be ignored in our wrapper. TODO: To support this, the wrapper would
378+
have to check type of self and if it's not the compiled class, resolve "__getattr__" against
379+
the type at runtime and call the returned method, like _Py_slot_tp_getattr_hook in cpython.
380+
381+
__getattr__ is not supported in classes which inherit from non-native classes because those
382+
have __dict__ which currently has some strange interactions when class attributes and
383+
variables are assigned through __dict__ vs. through regular attribute access. Allowing
384+
__getattr__ on top of that could be problematic.
385+
"""
386+
name = getattr.name + "__wrapper"
387+
ir = builder.mapper.type_to_ir[cdef.info]
388+
line = getattr.line
389+
390+
error_base = f'"__getattr__" not supported in class "{cdef.name}" because '
391+
if ir.allow_interpreted_subclasses:
392+
builder.error(error_base + "it allows interpreted subclasses", line)
393+
if ir.inherits_python:
394+
builder.error(error_base + "it inherits from a non-native class", line)
395+
396+
with builder.enter_method(ir, name, object_rprimitive, internal=True):
397+
attr_arg = builder.add_argument("attr", object_rprimitive)
398+
generic_getattr_result = builder.call_c(generic_getattr, [builder.self(), attr_arg], line)
399+
400+
return_generic, call_getattr = BasicBlock(), BasicBlock()
401+
null = Integer(0, object_rprimitive, line)
402+
got_generic = builder.add(
403+
ComparisonOp(generic_getattr_result, null, ComparisonOp.NEQ, line)
404+
)
405+
builder.add_bool_branch(got_generic, return_generic, call_getattr)
406+
407+
builder.activate_block(return_generic)
408+
builder.add(Return(generic_getattr_result, line))
409+
410+
builder.activate_block(call_getattr)
411+
# No attribute matched so call user-provided __getattr__.
412+
getattr_result = builder.gen_method_call(
413+
builder.self(), getattr.name, [attr_arg], object_rprimitive, line
414+
)
415+
builder.add(Return(getattr_result, line))
416+
417+
367418
def handle_ext_method(builder: IRBuilder, cdef: ClassDef, fdef: FuncDef) -> None:
368419
# Perform the function of visit_method for methods inside extension classes.
369420
name = fdef.name
@@ -430,6 +481,9 @@ def handle_ext_method(builder: IRBuilder, cdef: ClassDef, fdef: FuncDef) -> None
430481
class_ir.glue_methods[(class_ir, name)] = f
431482
builder.functions.append(f)
432483

484+
if fdef.name == "__getattr__":
485+
generate_getattr_wrapper(builder, cdef, fdef)
486+
433487

434488
def handle_non_ext_method(
435489
builder: IRBuilder, non_ext: NonExtClassInfo, cdef: ClassDef, fdef: FuncDef

mypyc/irbuild/ll_builder.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2048,6 +2048,7 @@ def call_c(
20482048
line,
20492049
var_arg_idx,
20502050
is_pure=desc.is_pure,
2051+
returns_null=desc.returns_null,
20512052
)
20522053
)
20532054
if desc.is_borrowed:
@@ -2131,6 +2132,7 @@ def primitive_op(
21312132
desc.extra_int_constants,
21322133
desc.priority,
21332134
is_pure=desc.is_pure,
2135+
returns_null=False,
21342136
)
21352137
return self.call_c(c_desc, args, line, result_type=result_type)
21362138

mypyc/lib-rt/CPy.h

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -949,6 +949,10 @@ PyObject *CPy_GetANext(PyObject *aiter);
949949
void CPy_SetTypeAliasTypeComputeFunction(PyObject *alias, PyObject *compute_value);
950950
void CPyTrace_LogEvent(const char *location, const char *line, const char *op, const char *details);
951951

952+
static inline PyObject *CPyObject_GenericGetAttr(PyObject *self, PyObject *name) {
953+
return _PyObject_GenericGetAttrWithDict(self, name, NULL, 1);
954+
}
955+
952956
#if CPY_3_11_FEATURES
953957
PyObject *CPy_GetName(PyObject *obj);
954958
#endif

mypyc/primitives/generic_ops.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -401,3 +401,12 @@
401401
c_function_name="CPy_GetName",
402402
error_kind=ERR_MAGIC,
403403
)
404+
405+
# look-up name in tp_dict but don't raise AttributeError on failure
406+
generic_getattr = custom_op(
407+
arg_types=[object_rprimitive, object_rprimitive],
408+
return_type=object_rprimitive,
409+
c_function_name="CPyObject_GenericGetAttr",
410+
error_kind=ERR_NEVER,
411+
returns_null=True,
412+
)

mypyc/primitives/registry.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ class CFunctionDescription(NamedTuple):
6161
extra_int_constants: list[tuple[int, RType]]
6262
priority: int
6363
is_pure: bool
64+
returns_null: bool
6465

6566

6667
# A description for C load operations including LoadGlobal and LoadAddress
@@ -253,6 +254,7 @@ def custom_op(
253254
is_borrowed: bool = False,
254255
*,
255256
is_pure: bool = False,
257+
returns_null: bool = False,
256258
) -> CFunctionDescription:
257259
"""Create a one-off CallC op that can't be automatically generated from the AST.
258260
@@ -274,6 +276,7 @@ def custom_op(
274276
extra_int_constants,
275277
0,
276278
is_pure=is_pure,
279+
returns_null=returns_null,
277280
)
278281

279282

mypyc/test-data/irbuild-classes.test

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2088,3 +2088,128 @@ class NonNative:
20882088
@mypyc_attr(free_list_len=1, allow_interpreted_subclasses=True) # E: "free_list_len" can't be used in a class that allows interpreted subclasses
20892089
class InterpSub:
20902090
pass
2091+
2092+
[case testUnsupportedGetAttr]
2093+
from mypy_extensions import mypyc_attr
2094+
2095+
@mypyc_attr(allow_interpreted_subclasses=True)
2096+
class AllowsInterpreted:
2097+
def __getattr__(self, attr: str) -> object: # E: "__getattr__" not supported in class "AllowsInterpreted" because it allows interpreted subclasses
2098+
return 0
2099+
2100+
class InheritsInterpreted(dict):
2101+
def __getattr__(self, attr: str) -> object: # E: "__getattr__" not supported in class "InheritsInterpreted" because it inherits from a non-native class
2102+
return 0
2103+
2104+
@mypyc_attr(native_class=False)
2105+
class NonNative:
2106+
pass
2107+
2108+
class InheritsNonNative(NonNative):
2109+
def __getattr__(self, attr: str) -> object: # E: "__getattr__" not supported in class "InheritsNonNative" because it inherits from a non-native class
2110+
return 0
2111+
2112+
[case testGetAttr]
2113+
from typing import ClassVar
2114+
2115+
class GetAttr:
2116+
class_var = "x"
2117+
class_var_annotated: ClassVar[int] = 99
2118+
2119+
def __init__(self, regular_attr: int):
2120+
self.regular_attr = regular_attr
2121+
2122+
def __getattr__(self, attr: str) -> object:
2123+
return attr
2124+
2125+
def method(self) -> int:
2126+
return 0
2127+
2128+
def test_getattr() -> list[object]:
2129+
i = GetAttr(42)
2130+
one = i.one
2131+
two = i.regular_attr
2132+
three = i.class_var
2133+
four = i.class_var_annotated
2134+
five = i.method()
2135+
return [one, two, three, four, five]
2136+
2137+
[typing fixtures/typing-full.pyi]
2138+
[out]
2139+
def GetAttr.__init__(self, regular_attr):
2140+
self :: __main__.GetAttr
2141+
regular_attr :: int
2142+
L0:
2143+
self.regular_attr = regular_attr
2144+
return 1
2145+
def GetAttr.__getattr__(self, attr):
2146+
self :: __main__.GetAttr
2147+
attr :: str
2148+
L0:
2149+
return attr
2150+
def GetAttr.__getattr____wrapper(__mypyc_self__, attr):
2151+
__mypyc_self__ :: __main__.GetAttr
2152+
attr, r0 :: object
2153+
r1 :: bit
2154+
r2 :: str
2155+
r3 :: object
2156+
L0:
2157+
r0 = CPyObject_GenericGetAttr(__mypyc_self__, attr)
2158+
r1 = r0 != 0
2159+
if r1 goto L1 else goto L2 :: bool
2160+
L1:
2161+
return r0
2162+
L2:
2163+
r2 = cast(str, attr)
2164+
r3 = __mypyc_self__.__getattr__(r2)
2165+
return r3
2166+
def GetAttr.method(self):
2167+
self :: __main__.GetAttr
2168+
L0:
2169+
return 0
2170+
def GetAttr.__mypyc_defaults_setup(__mypyc_self__):
2171+
__mypyc_self__ :: __main__.GetAttr
2172+
r0 :: str
2173+
L0:
2174+
r0 = 'x'
2175+
__mypyc_self__.class_var = r0
2176+
return 1
2177+
def test_getattr():
2178+
r0, i :: __main__.GetAttr
2179+
r1 :: str
2180+
r2, one :: object
2181+
r3, two :: int
2182+
r4, three, r5 :: str
2183+
r6 :: object
2184+
r7, four, r8, five :: int
2185+
r9 :: list
2186+
r10, r11, r12 :: object
2187+
r13 :: ptr
2188+
L0:
2189+
r0 = GetAttr(84)
2190+
i = r0
2191+
r1 = 'one'
2192+
r2 = CPyObject_GetAttr(i, r1)
2193+
one = r2
2194+
r3 = i.regular_attr
2195+
two = r3
2196+
r4 = i.class_var
2197+
three = r4
2198+
r5 = 'class_var_annotated'
2199+
r6 = CPyObject_GetAttr(i, r5)
2200+
r7 = unbox(int, r6)
2201+
four = r7
2202+
r8 = i.method()
2203+
five = r8
2204+
r9 = PyList_New(5)
2205+
r10 = box(int, two)
2206+
r11 = box(int, four)
2207+
r12 = box(int, five)
2208+
r13 = list_items r9
2209+
buf_init_item r13, 0, one
2210+
buf_init_item r13, 1, r10
2211+
buf_init_item r13, 2, three
2212+
buf_init_item r13, 3, r11
2213+
buf_init_item r13, 4, r12
2214+
keep_alive r9
2215+
return r9

0 commit comments

Comments
 (0)