概要
私はPure PythonでWindowsのCOMとやり取りするためのパッケージであるcomtypesをメンテナンスしています。
このパッケージはPythonの標準ライブラリctypesに依存しています。
Python 3.13からctypesに変更が加えられ、comtypesはPython 3.12まで動いていた実装では動かなくなってしまいました。
comtypesのコード変更により、Pythonバージョン間の互換性を保って動くようにすることができましたが、ctypesの変更によってまたこの実装が壊れてしまうことが予想されました。
そこでリグレッションを防止することを目的に、Pythonの本家であるcpythonリポジトリにテストを追加するコントリビュートを行いました。
この記事ではこの顛末と、私がコードを追加したときに何を考えていたか記録するために書きました。
発覚までの経緯と原因
2024年の9月なかばになり、もうすぐPython 3.13 finalがリリースされることに気が付いた私は、Python 3.13サポートの作業に着手することをコミュニティに周知するべくissueを投稿しました。
このとき私は、Python 3.12をサポートする時と同様にCIパイプラインにテスト対象となるPythonを追加するだけの単純なものになると思っていました。
しかし、Anacondaのパッケージマネージャをメンテナンスしている人から、「comtypesがPython 3.13では動かない」ことを報告されました。
原因を調べたところ、cpythonリポジトリのメンテナーによりctypesに変更が加えられていたことによるものらしいことがわかりました。
この変更によってIUnknown、CoClass、SAFEARRAYを実装するために用いられているメタクラスが動かなくなったため、comtypesはモジュールをロードしただけでエラーが発生するようになってしまいました。
comtypesコードベース変更による一時的な解決までの流れ
どこかに解決のヒントがないかと思い、エラーメッセージなどをキーワードとしてGitHub内を検索すると、comtypesと同じくメタクラスを用いてCOMインターフェースを定義するpygletというプロジェクトを見つけました。
そのパッケージはcomtypesより一足早くPython 3.13をサポートしていました。
pygletはメタクラスの初期化処理を__new__だけでなく一部を__init__で行うようにすることで、Python 3.13でも動作するようにしていました。
comtypesでも同様にコードを変更しましたが、pygletとは違ってその実装ではPython 3.13より下のバージョンではうまく動きませんでした。
そこでバージョンで互換性を持たせるためsys.version_infoを用いたバージョンブリッジを入れたところ、この問題が解決しうまく動くようになりました。
cpythonへのissue投稿とメンテナーによるドキュメンテーションの追加
このctypesの変更は"What's New In Python"に記載されておらず、またそれに対応するためバージョンブリッジを用いることがctypesを利用するプロジェクトとして正しいのかがわかりませんでした。
そこでcpython側にissueを立てて、どのようにすればいいかをメンテナーから意見をうかがうことにしました。
issueに対してメンテナーからリアクションがあり、"What's New In Python 3.13"にctypesへの変更に言及する記載が追加されました。
最終的なcomtypesコードベースの変更内容
ctypesのメンテナーからはバージョンブリッジを使うよりも、エラーの原因となっている無限再起呼び出しや未定義の名前参照を止めるように早期リターンを使う実装を提案されました。
メンテナーから提案されたコードをリファクタリングしてcomtypesにコミットし、バージョン1.4.8としてリリースしました。
cpythonへテストを追加するコンリビュートまでの流れ
それでも今後のcpythonの変更によって、また上述したメタクラスの実装が壊れてしまうかもしれないことが想定されました。
cpythonやctypesのメンテナーに負担をかけず、comtypesで用いられているメタクラスの定義時にエラーが送出されるようなリグレッションをこれ以上発生させないためには何が有効かを考えました。
メンテナーに対してコード変更のたびに依存しているパッケージの動作をすべて確認してもらうということはかなりの負担となります。
最新のcpythonのmainブランチをビルドしてテストを行うことをGitHub ActionsなどのCIサービスで定時実行することも、リグレッションの早期発見にはつながりますが発生防止には効果はありません。
いろいろと考えた結果、comtypesで用いられているメタクラス(をシンプルにしたもの)が異常を起こさないかのテストをcpythonに追加することがもっとも低コストで効果があることだと思いつき、issueを立てました。
テスト追加について合意が取れたのでPRを行い、レビューを受けてapproveされmergeされました。
このテストはバグフィックスステータスバージョンである3.13と3.12にもバックポートされるため、それらのバグ修正時にもリグレッションが発生する可能性を低下させることができると考えています。
まとめ
OSSメンテナーの立場として今回の事象を振り返ってみました。
メンテナーは機能追加や変更を行った際、依存するプロジェクトが壊れないかを恐れています。
そんなときに心強いのが、ユースケースが動作することを保証しているテストです。
しかし既存のテストはこの世のすべてのユースケースを網羅しているわけではないので、今回のように変更によって壊れるプロジェクトも生じてきます。
もしプロジェクトが壊れる前にユースケースの動作を担保するテストが追加されたり、壊れてしまった後でも新しいテストが追加されることでさらなるリグレッションを防止できるフローがコミュニティに確立されていれば、メンテナーは新しいバージョンのパッケージを恐れなくリリース/デプロイすることができます。
もし自分のプロジェクトが依存しているOSSに機能追加などでコントリビュートしたいのであれば、自分のユースケースを守るためのテストをまず追加することを行えば、メンテナーもその後の提案を受け入れやすくなるのではないかと思いました。