【Python】`comtypes==1.3.1`から`GetModule`で生成されるモジュールに静的型ヒントが導入

## 初めに
[2023年11月30日に行われたGuido van Rossum氏インタラクティブ記念講演会(#guidomeetup)](https://pyconjp.connpass.com/event/301716/)で[発表した`comtypes`へのコントリビュートについてのプレゼンテーション](https://docs.google.com/presentation/d/1sZ_tAlDRtgWb6JBG9Oumleu8wG45oGFx1_dRg2WnN7U/edit?pli=1#slide=id.p)内容を`main`ブランチに取り組み[`comtypes==1.3.1`](https://pypi.org/project/comtypes/1.3.1/)としてリリースしたので、その機能のご紹介です。

## 概要
これまで`comtypes.client.GetModule`によって`comtypes.gen`以下に生成されるモジュールにあるCOMインターフェースにはPythonフレンドリーな静的型情報がなく、モダンなPython開発に比べてコーディング体験が低い状態でした。
今回のバージョンアップデートで、それらに静的型情報がつくようになり、IDEが型情報に基づいたサジェストをしたり、実行前に静的解析を行えるようになりました。

## 変わらないこと

ランタイム動作としては以前と変わりません。
例えば、Scripting Runtimeの辞書オブジェクトをPythonの対話モードで使おうとしたときを想定します。
下記の動作はバージョンアップ前後で変わりません。

```py
>>> import comtypes.client
>>> dic0 = comtypes.client.CreateObject('Scripting.Dictionary')
>>> dic0['foo'] = 1
>>> dic0['foo']
1
>>> dic0.Item('foo')
1
>>> dic0  # doctest: +ELLIPSIS
<POINTER(IDictionary) ptr=0x... at ...>
>>> dic1 = comtypes.client.CreateObject('Scripting.Dictionary') 
>>> dic1.CompareMode = 1                                       
>>> dic1['bar'] = 2 
>>> dic1['BAR']     
2
```
## 変わること

下記コードもバージョンアップ前後でランタイムの動作は変わりませんが、型チェッカーによる静的型解析には変化があります。


```py
from comtypes import POINTER  # 実行時は`ctypes.POINTER`と同じ
import comtypes.client
# `Scripting`モジュールを生成/存在を保証して動的importする
comtypes.client.GetModule('scrrun.dll')
# 静的にimportしないと静的に解析されない
from comtypes.gen import Scripting  # noqa: E402

# `interface`を指定すると型チェッカーが型推論を行えるようになる。
# ここでは`dic0`が`IDictionary`インスタンスであると推論される。
dic0 = comtypes.client.CreateObject(
    Scripting.Dictionary, interface=Scripting.IDictionary
)
dic0['foo'] = 1
assert dic0['foo'] == dic0.Item('foo') == 1
# 実行時、実際にはCOMインターフェースのポインタが返されている
assert isinstance(dic0, POINTER(Scripting.IDictionary))
# 実行時、`POINTER(IDictionary)`は`IDictionary`のサブクラスとして振る舞う。
# POINTERファクトリで作られるような動的なサブクラスを表現できる機能は
# 今現在のPython型システムに導入されていない。
assert isinstance(dic0, Scripting.IDictionary)
dic1 = comtypes.client.CreateObject(
    Scripting.Dictionary, interface=Scripting.IDictionary
)
dic1.CompareMode = Scripting.TextCompare
dic1['bar'] = 2
assert dic1['BAR'] == 2
```

一度、`comtypes.client.GetModule('scrrun.dll')`相当が行われると、生み出された`comtypes/gen/Scripting.py`ファイルを型チェッカーが静的型解析します。

`comtypes==1.2.1`では、(実行時に問題はないものの)モジュール内にどのような型シンボルが定義されているかについて静的型解析可能な状態ではなかったため、型チェッカー(スクリーンショットはVSCode + Pylanceのもの)はモジュール内のメンバー参照でエラーを出していました。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/364180/bdec9c19-3ffb-ea05-68f4-78889a8db541.png)

`comtypes==1.3.0`ではモジュール内に定義された型シンボルが静的型解析可能な状態になったため、モジュール内のメンバー参照でエラーを出すことはなくなりました。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/364180/33d9b8b0-aea6-591f-d624-2fabb74c5b63.png)

一方で、クラスがどのようなプロパティやメソッド(`__dunder__`含む)を持っているかは静的解析可能な状態で定義されていなかったので、メソッドやプロパティを使おうとしたときに型チェッカーはエラーを出していました。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/364180/8aee8735-bd64-d069-4a27-5470cc1e1385.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/364180/10a622b7-3c16-abe2-1701-f21a92ebb678.png)

`comtypes==1.3.1`では、クラスがどのようなプロパティやメソッド(`__dunder__`含む)を持っているか静的解析可能な状態で定義されるようになったので、メンバー参照時に型エラーを出すことなく使用できるようになっています。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/364180/4d002f18-bafb-5fd1-7fa6-ace498959e8c.png)

また、一見メソッドに見えるメンバーの呼び出しも、実は特殊なデスクリプタの振る舞い(の実行時の実装を型ヒントで表したもの)であることが、型解析によってわかるようになってもいます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/364180/2ac9be7b-b561-785b-383e-4033e6686353.png)

## あとがき
今後リリースされる機能として、`GetModule`によって生成されるモジュールに標準ライブラリ[`enum`](https://docs.python.org/ja/3/library/enum.html)を用いた列挙体が実装されることを予定しています。
`comtypes`を使用したプロジェクトのロバストネスを高めるため、今後もコントリビュートを予定しています。