pythonでmypyを騙してfieldにmetadataを付加したクラスを定義してみる
ちょっとだけ背景説明
なんでそんなことがしたくなったかというと、やっぱり定義を記述したらおしまいでいられる世界が理想なので。そして特定の領域(e.g. OpenAPI, GraphQL, Protcol Buffers, database定義, ...)に属さないような中立的な表現が欲しくなったので。
この記事は一言で言うならpythonのクラスの各フィールドにどうやってメタデータを持たせようか?ということに対するメモです。
どうしてpython?
現状よく触るのがgoとpythonなのですが、goは直和型が素直に記述できないので1。
普及している言語の中ではTypeScriptの方がゆるふわな表現を良い感じに型パズルしやすいとは想うのですが、literal typeやtyped dictやprotocolなどそこそこpythonでも悪くはない形で型チェックが可能になってきたかなーということが1つ。
あと、型情報が消えてしまわず直接値として参照できるという点もpythonのtype hintsは特殊で、実はこの部分が意外と便利に機能するのではないかという思いがあったりします。
そして複雑な型パズルがしたいというよりは至る所にメタデータをガチャガチャとくっつけたいという気持ちになったためです(reflect-metadata周りでtypescriptでも機能する気がしますし、最悪ASTを取り出してあれこれするという形でも良いので、やっぱり結局手に馴染んだものでのプロトタイプという意味合いが強い気がします)。
メタデータ無しの場合
例えば以下の様なpythonのコードがあったとします。
person.py
import typing as t class Person: name: str age: int nickname: t.Optional[str]
このPersonというクラスから良い感じに情報を抜き出して使いまわそうと思っているのですがどうすれば良いでしょうか?仮の変換先としてOpenAPI Docのschemaの形式を利用することにします。
何もメタデータ無しの現状では以下の様に素直に変換できますね。
person.yaml
components: schemas: Person: properties: name: type: string age: type: integer nickname: type: string required: - name - age
メタデータありの場合
ここからが本題です。
クラスに対するメタデータ
クラスに対するメタデータの方法は以下の2つが考えられそうです。
- デコレーター
- なにか特定の値をクラス変数として仕込む
デコレーター
@metadata(x="xxx") @metadata(y="yyy") @metadata(z="zzz") class Person: ...
なにか特定の値をクラス変数として仕込む
class Person: tablename = "people" ...
あるいはdescriptionなどはそのままdocstringが使えそうですね。
class Person: """this is person""" ...
クラスの方は対応方法が色々ありそうなのでどうにかなりそうな気がします。
クラスの各フィールドに対するメタデータ
今度はクラスの各フィールドに対する話です。
メソッドとデコレータ(上手くいかない)
クラスのときと同様にフィールドに対するデコレータもと考えると、メソッドにデコレーターでメタデータを付加すれば良いということになりますが。
class Person: @metadata(xxx="yyy") def name(self) -> str: """name of person"""" ... ...
これはnameがメソッドであるというインターフェイスを前提としての記述になってしまうので嬉しくないです。以下の2つは違うので。
Person().name Person().name()
そして可能ならDSLのためのベースとなるような記述が、他の部分の実際の実装に全く影響や前提を設けないというような形に持っていきたいです。
プロパティとデコレータ(上手くいかない)
それではということでプロパティにしてしまいましょう。duck-typingというやつです。
例えば以下の様な形です。
class Person: @property @metadata(xxx="yyy") def name(self) -> str: """name of person"""" ... ...
ついでにドキュメントのためのプロパティということを表すためにmarker的な機能を付与したデコレータに変えていきたいところですが。これはまだまだpropertyをwrapしたgenericなデコレーターの型付けがサポートされていないっぽいので無理です。
class Person: @field(xxx="yyy") # まだ無理(夢) def name(self) -> str: """name of person"""" ... ...
加えてpropertyを直接使うとインターフェイスを固定してしまいますね。。。
class Person: @property def name(self) -> str: return "" p = Person() p.name = "foo" # AttributeError: can't set attribute print(p.name)
素直にインスタンス変数として使える形になっていて欲しいものです。
自前で定義したディスクリプターとデフォルト値(暫定的な回答)
そんなわけで以下をどうにかこうにか満たしたいわけです。
- しっかりとmypyの型チェックが通るようにしたい
- (なるべくmypyのpluginsなどには手を出したくない)
- 利用方法に制限や前提を設けたくない(インターフェイスを固定したくない)
色々考えてみた結果の暫定的な回答は自前でディスクリプターを定義してそれをデフォルト値として使うということでした。
まず、なぜ自前でディスクリプターを定義すると良いのかと言うと、pyramidのreifyの妙と同じ話で、プロパティの機能の裏側にあるディスクリプターとインスタンス変数の優先順位の関係で、インスタンス変数への上書きを制限しなくなります。
class Person: @reify def name(self): return "" p = Person() print(p.name) # => "" p.name = "foo" # エラーにならない print(p.name) # => "foo"
また、インスタンス変数のデフォルト値をクラス変数として設定するのはmypyの型付けの流儀に沿ったものです2
# mypyドキュメントからの引用 class MyClass: # You can optionally declare instance variables in the class body attr: int # This is an instance variable with a default value charge_percent: int = 100 # The "__init__" method doesn't return anything, so it gets return # type "None" just like any other method that doesn't return anything def __init__(self) -> None: ... # For instance methods, omit type for "self" def my_method(self, num: int, str1: str) -> str: return num * str1
dataclassesと似たような記述にすると言うと分かる人もいるかもしれません。ただdataclassesほど複雑ではないのでmypyの対応もpluginsという形での特別扱いは不要でした。
# documentから引用 @dataclass class C: x: int y: int = field(repr=False) z: int = field(repr=False, default=10) t: int = 20
なぜディスクリプターが良いか
なぜディスクリプターが良いかというとメタデータにアクセスする方法を提供してくれるからです。ディスクリプター自体は通常の属性アクセスでは__get__()
が呼ばれるのですが。
class Person: name : str = field("", x="xxx") # fieldはディスクリプター Person.name # fieldの`__get__()` が呼ばれる
直接__dict__
の中を覗くことでディスクリプタの実態にさわれます。
Person.__dict__["name"] # <fieldのインスタンス>
そんなわけでディスクリプタ自身にメタデータをもたせておけば、外見上は属性アクセスを担保しつつフィールド毎にメタデータを保持することも可能になります。
# default値にアクセス Person.name # => "" # メタデータにアクセス Person.__dict__["name"].metadata # => {"x": "xxx"}
実行時の感じとしては良さそうですね。
実際の定義
実際に動く実装を作って以下のように使えるようになることを目指します。
class Person: name: str age: int = 0 class WPerson(Person): nickname: t.Optional[str] = field(default=None, metadata=dict(doc="hmm"))
デフォルト値とメタデータを保持するディスクリプターの定義自体は以下の様な感じになります。が。
import typing as t T = t.TypeVar("T") MetaData = t.Optional[t.Dict[str, t.Any]] class Field(t.Generic[T]): default: T metadata: t.Optional[MetaData] def __init__(self, default: T, *, metadata: t.Optional[MetaData] = None): self.default = default self.metadata = metadata def __get__(self, obj: object, type: t.Optional[t.Any] = None) -> T: return self.default
実際に上手くmypyの型チェックをすり抜けるために少しhack的なものが必要です。
def field(*, default: T, metadata: t.Optional[t.Dict[str, t.Any]] = None) -> T: return t.cast(T, Field(default, metadata=metadata)) # xxx: HACK
mypyにはあたかも以下が同じモノであるかのように見えるためのhackです。
class X: name : str name2 : str = "" name3 : str = field(default="", metadata={"x": "xxx"})
この様にやってあげると以下が実行もでき型チェックも通ります。
09typing.py
class Person: name: str age: int = field(default=0) class WPerson(Person): nickname: t.Optional[str] = field(default=None, metadata=dict(doc="hmm")) print(WPerson.nickname, WPerson.age) print(get_metadata(WPerson, "nickname")) # metadataを取得する関数(gistに) print("----------------------------------------") # 各フィールドをiterateする関数(gistに) for x in walk(WPerson): print(x) if t.TYPE_CHECKING: # mypyのときだけ実行される場所 reveal_type(WPerson.nickname) reveal_type(WPerson().nickname) print("========================================") wp = WPerson() print(wp.nickname) wp.nickname = "foo" print(wp.nickname)
実行結果
$ python 09*.py None 0 {'doc': 'hmm'} ---------------------------------------- ('name', <class 'str'>, None) ('age', <class 'int'>, None) ('nickname', typing.Union[str, NoneType], {'doc': 'hmm'}) ======================================== None foo
mypyの結果
$ $ mypy --strict --pretty 09*.py 09typing.py:54: note: Revealed type is 'Union[builtins.str, None]' 09typing.py:55: note: Revealed type is 'Union[builtins.str, None]'
reifyのようにデコレーターベースになっていると、メソッドのような形で記述してdocstringでのdescriptionをという夢も膨らみますが前述の通りでまだ無理です。
gist
自分で動作確認したい人のためのgist。
under construction
:construction: まだ説明する気は無いですが、この知見を使ってのパッケージを作成中です。 :construction:
pythonのtyping_extensions.Protocolがなぜ嬉しいか(propertyの例)
mypyで使えるProtocolが便利という話の例。 structural subtypingをやる際にgoでもほしいと想うことが多かった例がProtocolでは大丈夫なので良いなーという例(今回はそのうちのひとつだけを紹介)。
Protocol?
Protocolをいつ使いたくなるかというと、大抵は複数ある何かを同一視したくなった場合。
例えば以下の様な例があげられる。
- クラスの継承関係を無視して同様のふるまいを持つものを同一視したいとき
- ある値を持つものを同一視したいとき
- 関数とcallable objectを同一視したいとき
- 同一視した表現を受け取って再帰的に同一視した表現を返したいとき (self-reference)
mypyでの型チェックはnominalなので、こういう構造を利用して同一視したい場合にはstructural subtypingが欲しくなる。これ用のinterfaceをProtocolと呼んでいる。
状況説明
例えばただただnameが欲しいだけの状況を考えてみる。こういう関数に渡されることをイメージした状態。
# <?????> の所に入る値は後々明らかになる def get_name(o: <?????>) -> str: return o.name
nameが取得できれば同じと見做す。そのような状況のときに、外から眺めた見た目としては、以下の2つのクラスは同じふるまいをするように見える(属性アクセスでnameが取れるので)。
class Person: name: str def __init__(self, name: str) -> None: self.name = name class Display: def __init__(self, typ: t.Type[t.Any]) -> None: self.typ = typ @property def name(self) -> str: return self.typ.__name__
例えば以下の様な形で使われる。
get_name(Person("foo")) # => "foo" get_name(Display(Person)) # => "Person"
duck-typing としてはとても自然。
Protocol を記述
そんなわけで上の様に動作することを期待するProtocolを記述したい。見た目だけで考えると以下のように書けると思うかもしれない。name
が見えれば良いので。
from typing_extensions as tx class HasName(tx.Protocol): name: str
しかしこれはだめ。以下のようなエラーが出る。
$ mypy --strict 04protocol.py ... 04protocol.py:31: error: Argument 1 to "get_name" has incompatible type "Display"; expected "HasName" 04protocol.py:31: note: Protocol member HasName.name expected settable variable, got read-only attribute
エラーの意味はwriteableでもある属性を期待しているがread-onlyな属性が渡されているよということ。前者は後者を内包しているから一見通るのが正しいと感じもするけれど、確かに安全性の観点から考えるならread-onlyであることを強調したい(まじめに色々なことは考えていないけれど。意図しないミスを防ぎたいという観点で考えるとこの2つを混同して使っている状況に危うさを感じたりはしそう)。
そんなわけで以下の様なProtocolを書いてあげると大丈夫。
class HasName(tx.Protocol): @property def name(self) -> str: ...
というわけで先程まで秘密にしていたget_name()
の定義は以下の様になる。
def get_name(o: HasName) -> str: return o.name
今度は以下が動く。
def main() -> None: get_name(Person("foo")) get_name(Display(Person)) # get_name(object())
goでもこういうinterface(pythonでのProtocolは概ねgoのinterface)を定義したいのだよなー。例えば自動生成系のやつなどでstructに値を持つ形で済ませられれば最高なのだけれど、メソッドを強制されたりするので。
$ mypy --strict 05protoco.py
やりましたね。手元で動かしたい人の為のgist