Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

attr.define seems to break __init_subclass__ (works with attr.s) #971

Closed
sscherfke opened this issue Jun 16, 2022 · 7 comments
Closed

attr.define seems to break __init_subclass__ (works with attr.s) #971

sscherfke opened this issue Jun 16, 2022 · 7 comments

Comments

@sscherfke
Copy link
Contributor

I like to use __init_subclass__ to implement simple plug-in systems, e.g.:

class OptionType:
    name: str
    __types__: dict[str, "OptionType"] = {}

    def __init_subclass__(cls, **kwargs):
        super().__init_subclass__(**kwargs)
        print(cls.name, cls)
        cls.__types__[cls.name] = cls

    @classmethod
    def instance_for(cls, name):
        return cls.__types__[name]()


class Int(OptionType):
    name: str = "int"


class IntRange(Int):
    name: str = "int-range"


print(OptionType.instance_for("int"))

When this is run, __init_subclass__ is run exactly once per class:

int <class '__main__.Int'>
int-range <class '__main__.IntRange'>
<__main__.Int object at 0x101462c70>

This also works with attr.s:

import attr


class OptionType:
    name: str
    __types__: dict[str, "OptionType"] = {}

    def __init_subclass__(cls, **kwargs):
        super().__init_subclass__(**kwargs)
        type_name = cls.name._default
        print(type_name, cls)
        cls.__types__[type_name] = cls

    @classmethod
    def instance_for(cls, name):
        return cls.__types__[name]()


@attr.s
class Int(OptionType):
    name: str = attr.ib("int")


@attr.s
class IntRange(Int):
    name: str = attr.ib("int-range")


print(OptionType.instance_for("int"))
int <class '__main__.Int'>
int-range <class '__main__.IntRange'>
Int(name='int')

However, when I use attr.define, everything falls appart:

import attr


_OPTION_TYPES: dict[str, "OptionType"] = {}


@attr.define
class OptionType:
    name: str

    def __init_subclass__(cls, **kwargs):
        super().__init_subclass__(**kwargs)
        type_name = attr.fields(cls).name.default
        print(type_name, cls)
        _OPTION_TYPES[type_name] = cls

    @classmethod
    def instance_for(cls, name):
        return _OPTION_TYPES[name]()


@attr.define
class Int(OptionType):
    name: str = "int"


@attr.define
class IntRange(Int):
    name: str = "int-range"


print(OptionType.instance_for("int"))
NOTHING <class '__main__.Int'>  # WHAT?
int <class '__main__.Int'>
int <class '__main__.IntRange'>  # ONOES! :-O
int-range <class '__main__.IntRange'>
IntRange(name='int')  # 😿

The main reason is, that __init_subclass__ is now called for every class in the inheritance hierarchy. That means that:

  • the base class must also use attrs.define (or attr.fields(cls) (or cls.name.default) will break)
  • __types__ must move out of the class or I’ll get a TypeError: 'member_descriptor' object does not support item assignment (because since it is typed, an attrs attribute is created for it).
  • IntRange overrides the class for int, so OptionType.instance_for("int") now returns an IntRange() instead of an Int().

I have not yet time to dig deeper into it but I always had the impression that attr.define is just an alias for attr.s with nicer defaults but that seems not to be the case. 🤔

@Tinche
Copy link
Member

Tinche commented Jun 16, 2022

It's probably __slots__ interfering with it. Try the attr.s example with slots=True.

@hynek
Copy link
Member

hynek commented Jun 17, 2022

of course, i'm open for another hack to make some feature work with slots ;)

@sscherfke
Copy link
Contributor Author

@Tinche: It's probably __slots__ interfering with it. Try the attr.s example with slots=True.

The third example works if i set define(slots=False). If I also set auto_attribs=False, I can resemble the behavior of the second example. However, this only works with attr but not with the attrs import:

import attr


@attr.define(slots=False, auto_attribs=False)
class OptionType:
    name: str = attr.field()
    __types__: dict[str, "OptionType"] = {}

    def __init_subclass__(cls, **kwargs):
        super().__init_subclass__(**kwargs)
        type_name = attr.fields(cls).name.default
        print(type_name, cls)
        cls.__types__[type_name] = cls

    @classmethod
    def instance_for(cls, name):
        return cls.__types__[name]()


@attr.define(slots=False)
class Int(OptionType):
    name: str = "int"


@attr.define(slots=False)
class IntRange(Int):
    name: str = "int-range"


print(OptionType.instance_for("int"))

@hynek: of course, i'm open for another hack to make some feature work with slots ;)

That’s exactly the answer I was hoping for. Please notify me when you implemented this! 🤪

@sscherfke
Copy link
Contributor Author

An improved documentation that stronger points out that slots are being used by default and what possible gotchas might look like would be a good-enough solution for me.

@hynek
Copy link
Member

hynek commented Jul 27, 2022

Where exactly would you like that to be?

It's currently prominently in the attrs.define docs:

Screenshot 2022-07-27 at 16 20 48@2x

@sscherfke
Copy link
Contributor Author

The hint in braces is, compared to the amount and potential severness of the issues, not visible enough. I would use an admonition (attention or caution maybe) with a strong advice for users to acutally read the slotted classes gotchas.

@hynek hynek closed this as completed in a67c84f Jul 28, 2022
@sscherfke
Copy link
Contributor Author

Looks good, thanks :)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

3 participants