Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Type-hinting for the __init__ function from class meta information in Python

What I'd like to do is replicate what SQLAlchemy does, with its DeclarativeMeta class. With this code,

from sqlalchemy import Column, Integer, String
from sqlalchemy.ext.declarative import declarative_base

Base = declarative_base()


class Person(Base):
    __tablename__ = 'person'
    id = Column(Integer, primary_key=True)

    name = Column(String)
    age = Column(Integer)

When you go to create a person in PyCharm, Person(..., you get typing hints about id: int, name: str, age: int,

Type-hinting with Python, PyCharm and SQLAlchemy

How it works at runtime is via the SQLAlchemy's _declarative_constructor functions,

def _declarative_constructor(self, **kwargs):
    cls_ = type(self)
    for k in kwargs:
        if not hasattr(cls_, k):
            raise TypeError(
                "%r is an invalid keyword argument for %s" %
                (k, cls_.__name__))
        setattr(self, k, kwargs[k])
_declarative_constructor.__name__ = '__init__'

And to get the really nice type-hinting (where if your class has a id field, Column(Integer) your constructor type-hints it as id: int), PyCharm is actually doing some under-the-hood magic, specific to SQLAlchemy, but I don't need it to be that good / nice, I'd just like to be able to programatically add type-hinting, from the meta information of the class.

So, in a nutshell, if I have a class like,

class Simple:
    id: int = 0

    name: str = ''
    age: int = 0

I want to be able to init the class like above, Simple(id=1, name='asdf'), but also get the type-hinting along with it. I can get halfway (the functionality), but not the type-hinting.

If I set things up like SQLAlchemy does it,

class SimpleMeta(type):
    def __init__(cls, classname, bases, dict_):
        type.__init__(cls, classname, bases, dict_)


metaclass = SimpleMeta(
    'Meta', (object,), dict(__init__=_declarative_constructor))


class Simple(metaclass):
    id: int = 0

    name: str = ''
    age: int = 0


print('cls', typing.get_type_hints(Simple))
print('init before', typing.get_type_hints(Simple.__init__))
Simple.__init__.__annotations__.update(Simple.__annotations__)
print('init after ', typing.get_type_hints(Simple.__init__))
s = Simple(id=1, name='asdf')
print(s.id, s.name)

It works, but I get no type hinting,

No __init__ type hint

And if I do pass parameters, I actually get an Unexpected Argument warning,

Unexpected Argument

In the code, I've manually updated the __annotations__, which makes the get_type_hints return the correct thing,

cls {'id': <class 'int'>, 'name': <class 'str'>, 'age': <class 'int'>}
init before {}
init after  {'id': <class 'int'>, 'name': <class 'str'>, 'age': <class 'int'>}
1 asdf
like image 704
seaders Avatar asked Mar 04 '18 13:03

seaders


People also ask

What are type hints in Python?

Source code: Lib/typing.py The Python runtime does not enforce function and variable type annotations. They can be used by third party tools such as type checkers, IDEs, linters, etc. This module provides runtime support for type hints. The most fundamental support consists of the types Any, Union, Callable , TypeVar, and Generic.

What is the Python runtime type hint module?

The Python runtime does not enforce function and variable type annotations. They can be used by third party tools such as type checkers, IDEs, linters, etc. This module provides runtime support for type hints.

How to implement custom metaclasses in Python?

Metaclasses are classes that inherit directly from type. The method that custom metaclasses should implement is the __new__ method. The arguments mentioned in the __new__ method of metaclasses reflects in the __new__ method of type class. It has four positional arguments. They are as follows: The first argument is the metaclass itself.

Why can’t I inherit from two metaclasses in Python?

Here you will get the below error message while trying to inherit from two different metaclasses. This is because Python can only have one metaclass for a class. Here, class C can’t inherit from two metaclasses, which results in ambiguity. In most cases, we don’t need to go for a metaclass, normal code will fit with the class and object.


2 Answers

From python 3.7 above, you can achieve same effect by using @dataclass and adding appropriate typehint to the instance fields.

https://docs.python.org/3/library/dataclasses.html

screenshot of typehint

like image 60
Jack Ng Avatar answered Sep 25 '22 01:09

Jack Ng


Updating the __annotations__ from __init__ is the correct way to go there. It is possible to do so using a metaclass, classdecorator, or an appropriate __init_subclass__ method on your base classes.

However, PyCharm raising this warning should be treated as a bug in Pycharm itself: Python has documented mechanisms in the language so that object.__new__ will ignore extra arguments on a class instantiation (which is a "class call") if an __init__ is defined in any subclass in the inheritance chain. On yielding this warning, pycharm is actually behaving differently than the language spec.

The work around it is to have the same mechanism that updates __init__ to create a proxy __new__ method with the same signature. However, this method will have to swallow any args itself - so getting the correct behavior if your class hierarchy needs an actual __new__ method somewhere is a complicating edge case.

The version with __init_subclass__ would be more or less:

class Base:
    def __init_subclass__(cls, *args, **kw):
        super().__init_subclass__(*args, **kw)
        if not "__init__" in cls.__dict__:
            cls.__init__ = lambda self, *args, **kw: super(self.__class__, self).__init__(*args, **kw)
        cls.__init__.__annotations__.update(cls.__annotations__)
        if "__new__" not in cls.__dict__:
            cls.__new__ = lambda cls, *args, **kw: super(cls, cls).__new__(cls)
                cls.__new__.__annotations__.update(cls.__annotations__)

Python correctly updates a class' .__annotations__ attribute upon inheritance, therefore even this simple code works with inheritance (and multiple inheritance) - the __init__ and __new__ methods are always set with the correct annotations even for attributes defined in superclasses.

like image 34
jsbueno Avatar answered Sep 25 '22 01:09

jsbueno