【Python】メンテナンスしているプロジェクトが壊れないように`cpython`にテストをコントリビュートしたお話

## 概要
私はPure PythonでWindowsのCOMとやり取りするためのパッケージである[`comtypes`](https://pypi.org/project/comtypes/)をメンテナンスしています。
このパッケージはPythonの標準ライブラリ[`ctypes`](https://docs.python.org/ja/3/library/ctypes.html)に依存しています。
Python 3.13から`ctypes`に変更が加えられ、`comtypes`はPython 3.12まで動いていた実装では動かなくなってしまいました。
`comtypes`のコード変更により、Pythonバージョン間の互換性を保って動くようにすることができましたが、`ctypes`の変更によってまたこの実装が壊れてしまうことが予想されました。
そこでリグレッションを防止することを目的に、Pythonの本家である[`cpython`リポジトリ](https://github.com/python/cpython)にテストを追加するコントリビュートを行いました。
この記事ではこの顛末と、私がコードを追加したときに何を考えていたか記録するために書きました。

## 発覚までの経緯と原因
2024年の9月なかばになり、もうすぐPython 3.13 finalがリリースされることに気が付いた私は、Python 3.13サポートの作業に着手することをコミュニティに周知するべくissueを投稿しました。

https://github.com/enthought/comtypes/issues/618

このとき私は、Python 3.12をサポートする時と同様にCIパイプラインにテスト対象となるPythonを追加するだけの単純なものになると思っていました。

しかし、[Anacondaのパッケージマネージャをメンテナンスしている人から、「`comtypes`がPython 3.13では動かない」ことを報告](https://github.com/enthought/comtypes/issues/618#issuecomment-2366905288)されました。

原因を調べたところ、`cpython`リポジトリのメンテナーにより`ctypes`に変更が加えられていたことによるものらしいことがわかりました。

https://github.com/python/cpython/issues/114314

この変更によって[`IUnknown`](https://learn.microsoft.com/ja-jp/windows/win32/api/unknwn/nn-unknwn-iunknown)、[`CoClass`](https://learn.microsoft.com/ja-jp/cpp/windows/attributes/coclass?view=msvc-170)、[`SAFEARRAY`](https://learn.microsoft.com/ja-jp/windows/win32/api/oaidl/ns-oaidl-safearray)を実装するために用いられているメタクラスが動かなくなったため、`comtypes`はモジュールをロードしただけでエラーが発生するようになってしまいました。

## `comtypes`コードベース変更による一時的な解決までの流れ

どこかに解決のヒントがないかと思い、エラーメッセージなどをキーワードとしてGitHub内を検索すると、`comtypes`と同じくメタクラスを用いてCOMインターフェースを定義する[`pyglet`](https://github.com/pyglet/pyglet/)というプロジェクトを見つけました。

そのパッケージは`comtypes`より一足早くPython 3.13をサポートしていました。
`pyglet`は[メタクラスの初期化処理を`__new__`だけでなく一部を`__init__`で行うようにすることで、Python 3.13でも動作するようにして](https://github.com/pyglet/pyglet/pull/1199)いました。
`comtypes`でも同様にコードを変更しましたが、`pyglet`とは違ってその実装ではPython 3.13より下のバージョンではうまく動きませんでした。
そこでバージョンで互換性を持たせるため[`sys.version_info`](https://docs.python.org/ja/3/library/sys.html#sys.version_info)を用いた[バージョンブリッジを入れた](https://github.com/enthought/comtypes/compare/981d4b4..6f036d4)ところ、この問題が解決しうまく動くようになりました。

## `cpython`へのissue投稿とメンテナーによるドキュメンテーションの追加
この`ctypes`の変更は"What's New In Python"に記載されておらず、またそれに対応するためバージョンブリッジを用いることが`ctypes`を利用するプロジェクトとして正しいのかがわかりませんでした。
そこで`cpython`側にissueを立てて、どのようにすればいいかをメンテナーから意見をうかがうことにしました。

https://github.com/python/cpython/issues/124520

issueに対してメンテナーからリアクションがあり、"What's New In Python 3.13"に`ctypes`への変更に言及する記載が追加されました。

https://github.com/python/cpython/pull/124546

https://docs.python.org/3.13/whatsnew/3.13.html#ctypes

## 最終的な`comtypes`コードベースの変更内容
`ctypes`のメンテナーからはバージョンブリッジを使うよりも、エラーの原因となっている無限再起呼び出しや未定義の名前参照を止めるように早期リターンを使う実装を提案されました。
メンテナーから提案されたコードをリファクタリングして`comtypes`にコミットし、バージョン1.4.8としてリリースしました。

https://github.com/enthought/comtypes/pull/636

https://github.com/enthought/comtypes/releases/tag/1.4.8

## `cpython`へテストを追加するコンリビュートまでの流れ
それでも今後の`cpython`の変更によって、また上述したメタクラスの実装が壊れてしまうかもしれないことが想定されました。

`cpython`や`ctypes`のメンテナーに負担をかけず、`comtypes`で用いられているメタクラスの定義時にエラーが送出されるようなリグレッションをこれ以上発生させないためには何が有効かを考えました。

メンテナーに対してコード変更のたびに依存しているパッケージの動作をすべて確認してもらうということはかなりの負担となります。
最新の`cpython`の`main`ブランチをビルドしてテストを行うことをGitHub ActionsなどのCIサービスで定時実行することも、リグレッションの早期発見にはつながりますが発生防止には効果はありません。

いろいろと考えた結果、`comtypes`で用いられているメタクラス(をシンプルにしたもの)が異常を起こさないかのテストを`cpython`に追加することがもっとも低コストで効果があることだと思いつき、issueを立てました。

https://github.com/python/cpython/issues/125783

テスト追加について合意が取れたのでPRを行い、レビューを受けてapproveされmergeされました。

https://github.com/python/cpython/pull/125881

このテストはバグフィックスステータスバージョンである[`3.13`](https://github.com/python/cpython/pull/125987)と[`3.12`](https://github.com/python/cpython/pull/125988)にもバックポートされるため、それらのバグ修正時にもリグレッションが発生する可能性を低下させることができると考えています。

## まとめ
OSSメンテナーの立場として今回の事象を振り返ってみました。

メンテナーは機能追加や変更を行った際、依存するプロジェクトが壊れないかを恐れています。
そんなときに心強いのが、ユースケースが動作することを保証しているテストです。
しかし既存のテストはこの世のすべてのユースケースを網羅しているわけではないので、今回のように変更によって壊れるプロジェクトも生じてきます。

もしプロジェクトが壊れる前にユースケースの動作を担保するテストが追加されたり、壊れてしまった後でも新しいテストが追加されることでさらなるリグレッションを防止できるフローがコミュニティに確立されていれば、メンテナーは新しいバージョンのパッケージを恐れなくリリース/デプロイすることができます。

もし自分のプロジェクトが依存しているOSSに機能追加などでコントリビュートしたいのであれば、自分のユースケースを守るためのテストをまず追加することを行えば、メンテナーもその後の提案を受け入れやすくなるのではないかと思いました。