Skip to content

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

@sscherfke

Description

@sscherfke

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. 🤔

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions