Skip to content

Register Hooks for Generic Classes #216

@raabf

Description

@raabf
  • cattrs version: 1.10.0
  • Python version: 3.7
  • Operating System: Linux 5.16.1-1 OpenSUSE Thumbleweed

I want to register structure/unstructure hooks for classes which inherit typing.Generic and using a T = typing.TypeVar('T'), i.e. MyClass(typing.Generic[T]) – so that the structure/unstructure hooks are called when MyClass is used with a concrete type, for example MyClass[str].
In the code box below you can see an example in the function main1() about that. So the question is: Is there an intended correct way to register structure/unstructure hooks for Generic Classes?

I currently make use of register_structure_hook_func and check the correct condition myself. The statement MyClass[str] will generate an typing._GenericAlias instance which __origin__ arrtribute holds a reference to the class MyClass, which is the condition I check.

from typing import Generic, TypeVar

import attr
import cattr

T = TypeVar('T')

converter = cattr.GenConverter()


class MyClass(Generic[T]):

    @staticmethod
    def deserialize(value, cls):
        print(f"deserialize {value}, {cls}")
        return MyClass()

    def serialize(self):
        print(f"serialize {self}")
        return 'foobar'


@attr.define
class MyBase:
    a: MyClass[str]


def main1() -> None:
    # MyClass[str] is faulty behaviour here

    print("•••• direct ••••")
    converter.unstructure(MyClass())  # OK
    converter.structure("hello1", MyClass)  # OK

    print("•••• With T ••••")
    converter.unstructure(MyClass[T]())  # OK
    converter.structure("hello2", MyClass[T])  # OK

    print("•••• With str ••••")
    print(f"subclass: {isinstance(MyClass[str](), MyClass)}{isinstance(MyClass[str], MyClass)}")
    converter.unstructure(MyClass[str]()) # Probably unintended behaviour but `serialize()` is called because of above `issubclass` is True

    b = converter.unstructure(MyBase(a=MyClass[str]()))
    print("b:", b)  # unstructured is an instance of 'MyClass' instead of string 'foobar'

    try:  # cattr.errors.StructureHandlerNotFoundError: Unsupported type: __main__.MyClass[str]. Register a structure hook for it.
        converter.structure("hello3", MyClass[str])
    except Exception as err:
        print(err)


def main2() -> None:
    # Everything works fine here

    print("•••• direct ••••")
    converter.unstructure(MyClass())
    converter.structure("hello1", MyClass)

    print("•••• With T ••••")
    converter.unstructure(MyClass[T]())
    converter.structure("hello2", MyClass[T])

    print("•••• With str ••••")
    converter.unstructure(MyClass[str]())

    b = converter.unstructure(MyBase(a=MyClass[str]()))
    print("b:", b)  # correctly is 'foobar'

    converter.structure("hello3", MyClass[str])


if __name__ == '__main__':
    print("•••• main1 ••••")

    converter.register_structure_hook(MyClass, MyClass.deserialize)
    converter.register_unstructure_hook(MyClass, MyClass.serialize)

    converter.register_structure_hook(MyClass[T], MyClass.deserialize)
    converter.register_unstructure_hook(MyClass[T], MyClass.serialize)

    main1()


    print()
    print("•••• main2 ••••")

    converter.register_structure_hook_func(
        # the default value (here `bool`) must be something so that the expression is `False`
        # The object has a __origin__ attribute if it us used as `Class[TYPE]`, then __origin__ will point to `Class`. This
        # test is secure enough since it is not only tested that the class has the attribute, but that it is also
        # a subclass, which is the class we want to support with this converter.
        lambda cls: issubclass(getattr(cls, '__origin__', bool), MyClass),
        MyClass.deserialize,
    )
    converter.register_unstructure_hook_func(
        lambda cls: issubclass(getattr(cls, '__origin__', bool), MyClass),
        MyClass.serialize,
    )

    main2()

Output:

•••• main1 ••••
•••• direct ••••
serialize <__main__.MyClass object at 0x7f6bdb8515d0>
deserialize hello1, <class '__main__.MyClass'>
•••• With T ••••
serialize <__main__.MyClass object at 0x7f6bdb8515d0>
deserialize hello2, __main__.MyClass[~T]
•••• With str ••••
subclass: True – False
serialize <__main__.MyClass object at 0x7f6bdb8515d0>
b: {'a': <__main__.MyClass object at 0x7f6bdb8515d0>}
Unsupported type: __main__.MyClass[str]. Register a structure hook for it.

•••• main2 ••••
•••• direct ••••
serialize <__main__.MyClass object at 0x7f6bdbb97510>
deserialize hello1, <class '__main__.MyClass'>
•••• With T ••••
serialize <__main__.MyClass object at 0x7f6bdbb97510>
deserialize hello2, __main__.MyClass[~T]
•••• With str ••••
serialize <__main__.MyClass object at 0x7f6bdbb97510>
serialize <__main__.MyClass object at 0x7f6bdbb97510>
b: {'a': 'foobar'}
deserialize hello3, __main__.MyClass[str]

The reason why converter.register_structure_hook(MyClass[T], MyClass.deserialize) is probably how typing._GenericAlias defines __eq__ and __hash__, i.e two of them are only equal when both __origin__ and all args (above this is T vs. str) are equal :

class _GenericAlias(_Final, _root=True):

    def __eq__(self, other):
        if not isinstance(other, _GenericAlias):
            return NotImplemented
        if self.__origin__ != other.__origin__:
            return False
        if self.__origin__ is Union and other.__origin__ is Union:
            return frozenset(self.__args__) == frozenset(other.__args__)
        return self.__args__ == other.__args__

    def __hash__(self):
        if self.__origin__ is Union:
            return hash((Union, frozenset(self.__args__)))
        return hash((self.__origin__, self.__args__))

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