简体   繁体   中英

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 ,

使用 Python、PyCharm 和 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,

没有 __init__ 类型提示

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

意外的争论

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

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.

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

类型提示的屏幕截图

The technical post webpages of this site follow the CC BY-SA 4.0 protocol. If you need to reprint, please indicate the site URL or the original address.Any question please contact:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM