python-attrs / attrs

Python Classes Without Boilerplate
https://www.attrs.org/
MIT License
5.31k stars 374 forks source link

`AttributeError` when accessing an attribute with a default value set in a child class #1240

Closed Wenzel closed 3 months ago

Wenzel commented 9 months ago

Hi,

I'm surprised that this attrs code doesn't work:

#!/usr/bin/env python3

from abc import ABC
from attrs import define, field

@define(slots=False, auto_attribs=True, auto_detect=True)
class A(ABC):
    field_1: int = 42
    field_2: int = field(init=False)

    def __attrs_post_init__(self):
        print(f"A.__attrs_post_init__: field_1 default = {self.field_1}")
        self.field_2 = self.field_1 * 2

@define(slots=False, auto_attribs=True, auto_detect=True)
class B(A):
    field_3: int = 0

    def __attrs_pre_init__(self, field_1: int, *args, **kwargs):
        super().__init__(field_1)

    def __attrs_post_init__(self):
        super().__attrs_post_init__()
        # raises AttributeError when accessing field_3
        print(f"B.__attrs_post_init__: field_3 default = {self.field_3}")
        print(self.field_3)

if __name__ == '__main__':
    instance = B()

When I try to access field_3 in the child's __attrs_post_init__, the attribute, despite having a default value like another one in the parent class, is undefined.

My assumption was that, by the time attrs has reached __attrs_post_init__, the fields with default values should have been set already (which is the case for the parent class)

Is there some subtlety that i'm missing here with attrs initialization ?

Thanks !

hynek commented 9 months ago

I can't quite follow everything that's happening because you're essentially breaking attrs with you super() calls. attrs never calls super() and is not built for it. Every class builds an optimal version of __init__ according the fields that it discovers.

Wenzel commented 9 months ago

Hi @hynek and thanks for your answer.

So a class decorated by attrs is not meant to call super() ? How should it initialize it's parent attributes ?

In the docs you mentioned that it's __attrs_pre_init__() role: https://www.attrs.org/en/stable/init.html#pre-init

Which is what I did. So i suppose it's my super() call in __attrs_post_init__() that you think is breaking attrs ? Because even if I comment that super() call:

@define(slots=False, auto_attribs=True, auto_detect=True)
class B(A):
    field_3: int = 0

    def __attrs_pre_init__(self, field_1: int, *args, **kwargs):
        super().__init__(field_1)

    def __attrs_post_init__(self):
        # super().__attrs_post_init__()
        # raises AttributeError when accessing field_3
        print(f"B.__attrs_post_init__: field_3 default = {self.field_3}")
        print(self.field_3)

The self.field_3 attribute will not be initialized by the default value at this point.

How attrs is expected to behave with inheritance ? Is that part of the scope ? Or should I only use attrs with standalone without inheritance ?

Thanks !

hynek commented 9 months ago

So a class decorated by attrs is not meant to call super() ? How should it initialize it's parent attributes ?

You don't have to. The __init__ of an attrs class that inherits from another attrs class will initialize them for you.

In the docs you mentioned that it's __attrs_pre_init__() role: https://www.attrs.org/en/stable/init.html#pre-init

It says:

The sole reason for the existence of attrs_pre_init is to give users the chance to call super().init(), because some subclassing-based APIs require that.

I guess I can add a clarification that that's never necessary with attrs classes.

Which is what I did. So i suppose it's my super() call in __attrs_post_init__() that you think is breaking attrs ? Because even if I comment that super() call:

@define(slots=False, auto_attribs=True, auto_detect=True)
class B(A):
    field_3: int = 0

    def __attrs_pre_init__(self, field_1: int, *args, **kwargs):
        super().__init__(field_1)

    def __attrs_post_init__(self):
        # super().__attrs_post_init__()
        # raises AttributeError when accessing field_3
        print(f"B.__attrs_post_init__: field_3 default = {self.field_3}")
        print(self.field_3)

The self.field_3 attribute will not be initialized by the default value at this point.

This works as expected:

from abc import ABC
from attrs import define, field

@define
class A(ABC):
    field_1: int = 42
    field_2: int = field(init=False)

    def __attrs_post_init__(self):
        print(f"A.__attrs_post_init__: field_1 default = {self.field_1}")
        self.field_2 = self.field_1 * 2

@define
class B(A):
    field_3: int = 0

    def __attrs_post_init__(self):
        super().__attrs_post_init__()
        print(f"B.__attrs_post_init__: field_3 default = {self.field_3}")
        print(self.field_2)
        print(self.field_3)

if __name__ == '__main__':
    instance = B()

How attrs is expected to behave with inheritance ? Is that part of the scope ? Or should I only use attrs with standalone without inheritance ?

attrs does work with inheritance in general, but it can get a bit hairy when you use a lot of pre-inits and post-inits.

JFTR, you can always inspect the __init__ that attrs wrote for you:

>>> import inspect
>>> print(inspect.getsource(B.__init__))
def __init__(self, field_1=attr_dict['field_1'].default, field_3=attr_dict['field_3'].default):
    self.field_1 = field_1
    self.field_3 = field_3
    self.__attrs_post_init__()
hynek commented 9 months ago

warning added in https://github.com/python-attrs/attrs/commit/ec9ef8d174e27491a752e4b1286187f6f7842754