AI・機械学習チームブログリレー13日目の記事を三浦 (@mamo3gr) がお送りします。 Pythonで型ヒントを補足するためのサードパーティのスタブライブラリにコントリビュートしました。 それを通して入門したスタブファイルの作り方を紹介します。
便利なPythonの型ヒント
Pythonで型ヒント、付けてますよね? 型ヒントは変数・クラス属性・関数のパラメータや戻り値の期待される型を明示するためのラベルです。 実行時の影響は無く、一方でIDEが属性やパラメータを補完できるようになったり、mypyなどの静的解析ツールの型チェックに利用できたりします。 コーディングのスピードアップ、コードの可読性の向上、バグの早期発見など、幅広く有用です。
自分で書くコードはもちろん、サードパーティのライブラリでも型ヒントが提供されているとありがたいですが、すべてがそうではありません。 オープンソースでかつ気軽にコントリビュートできるなら自らPull Requestを送って型ヒントを追加できますが、プロプライエタリなライブラリや、クローズドソースの企業内ライブラリではそうもいきません。 また、自動生成される類のライブラリについても同様で、例えばGoogleが提供するPythonクライアントライブラリは、プロトコルバッファから自動生成されるためか、型ヒントが提供されないものもあります*1。
型ヒントを補助するスタブライブラリ
これに対して、有志がサードパーティのスタブライブラリを作成・公開しています。
スタブファイルは、型ヒントとともにPEP 484で定義されています。
拡張子は通常 .pyi
で、同名の .py
ファイル(モジュール)に対して型ヒントを補足します(実際の実行可能なコードは含みません)。
特定のライブラリに対応するスタブファイルの集合をスタブライブラリと呼びます。
例えば henribru/google-ads-stubs (GitHub) は、Google広告APIのPythonクライアントライブラリに対応するスタブライブラリです。
スタブファイルの作り方入門
前述したスタブライブラリは、著者が本家ライブラリを使わなくなったことから、本家のアップデートに追従できなくなりつつありました。 そこで、著者に依頼して生成用スクリプト群を公開していただき (henribru/google-ads-stubs #49), 今後の更新にコントリビュートすることにしました。 なお、早速、2025年6月にリリースされた本家v27.0.0へ追従するPull Requestを提出し、無事マージ&リリースされています (henribru/google-ads-stubs #54)。 これからテストの充実や自動化などの整備に取り組んでいくつもりですが、現状の生成手順を読み解きながら、スタブファイルの作り方を学んでいきます。
stubgenで下地をつくる
mypyではstubgenというツールを提供しており、任意のモジュールやパッケージに対してそれらのスタブファイルを自動生成できます。
いま、次のようなソースコード foo.py
があるとします*2。
from other_module import dynamic BORDER_WIDTH = 15 class Window: parent = dynamic() def __init__(self, width, height): self.width = width self.height = height def create_empty() -> Window: return Window(0, 0)
これをstubgen foo.py
すると、次のようなスタブファイル foo.pyi
が生成されます。
from _typeshed import Incomplete BORDER_WIDTH: int class Window: parent: Incomplete width: Incomplete height: Incomplete def __init__(self, width, height) -> None: ... def create_empty() -> Window: ...
スタブファイルは元のクラスや関数のシグネチャをそのまま記述したものになっています。
それらの実装は ...
で省略されている一方で、型ヒントが付与されており、静的解析ツールやIDEはこの型情報を参照・利用できます。
ご覧のように、stubgen
は実行するだけでドラフトレベルのスタブファイルを生成してくれます。
あとは、生成元のライブラリやユースケースに合わせて、適宜手直しを入れていくことになります。
例えば前述の例だと、 width
や height
は実際は int
なのでそのように変更する、といった修正をします。
ちなみに Incomplete
は typing.Any
のエイリアスで、stubgen
が型推論できなかったり、そもそもランタイムでないと不明だったりする場合に付与されるようです。
pathlib,importlib,inspectを組み合わせたモジュール・クラスの列挙と書き換え
小規模なライブラリであれば人間による手直しでも対応できますが、大規模な場合には機械的な対応が必要になります。
google-ads-stubs
では、元のライブラリに対してpathlibとimportlibを組み合わせて各モジュールをインポートしつつ、inspectモジュールで書き換え対象となるクラスを洗い出していました。
次のコードは、そのような処理をしているコード*3の一部を抜粋・改変したものです。
for path in Path("./google/ads/googleads/").glob("**/types/*.py"): # 各.pyファイルから、モジュールのパス名を取得して module_path = ".".join([*path.parent.parts[1:], path.stem]) # モジュールをインポート module = importlib.import_module(module_path) # proto.Messageを継承しているクラスとクラス名を取得 for cls_name, cls in inspect.getmembers( module, lambda x: issubclass(x, proto.Message), ): process(cls_name, cls) # この関数で解析・加工する
モジュール、クラスごとの解析や加工は多岐にわたりますが、そのひとつがデータ型の変換です。
例えば、広告グループに対応するエンティティ AdGroup
は次のようになっています*4 。
単に stubgen
すると、name
など各フィールドが Incomplete
で型付けされてしまいます。
class AdGroup(proto.Message): name = proto.Field( proto.STRING, number=35, optional=True, ) status = proto.Field( proto.ENUM, number=5, enum=ad_group_status.AdGroupStatusEnum.AdGroupStatus, ) cpc_bid_micros = proto.Field( proto.INT64, number=39, optional=True, )
そこで、プロトコルバッファのフィールドを表す proto.Field
の内容を参照して、プロトコルバッファのデータ型をPythonのデータ型に変換します*5。
def process(cls_name, cls): for field_name, field in cls.meta.fields.items(): if field.proto_type in [proto.DOUBLE, proto.FLOAT]: _type = "float" elif field.proto_type in [proto.INT64, proto.UINT64]: _type = "int" elif field.proto_type == proto.BOOL: _type = "bool" elif field.proto_type == proto.STRING: _type = "str" # ... 省略。実際はENUMなどより複雑な型・クラス、repeated fieldに対応している # # あらかじめ対応する .pyi を open しておき、 # ここでフィールドごとにファイルへ書き出す #
__init__ とフィールドの追加
広告グループをはじめとするエンティティは、プロトコルバッファのメッセージを表す proto.Message
を継承したクラスとして定義されています。
継承元のメソッド __init__
の引数 **kwargs
を通じて任意のフィールドに値をセットできますが*6、実際にクラスが想定するフィールドを引数とする __init__
を定義すると便利です。
例えば次のようにします。
class AdGroup(proto.Message): # もともと定義されていなかった __init__ を、 # 継承元のproto.Messageからコピーしてくる def __init__( # ここから self: _M, mapping: _M | Mapping | google.protobuf.message.Message | None = ..., *, ignore_unknown_fields: bool = ..., # ここまでは継承元の__init__の引数そのまま。 # これ以降に、もとは**kwargsだった引数を # 実際のAdGroupのフィールドで置き換える resource_name: str = ..., id: int = ..., name: str = ..., status: AdGroupStatusEnum.AdGroupStatus = ..., type_: AdGroupTypeEnum.AdGroupType = ..., # ... ) -> None: ...
これも、前述したフィールドの解析結果をもとに、文字列の操作とファイルへの書き出しを泥臭くやることで実現しています*7。
その他、便利そうなツール
stubdefaulterというツールを利用して、 __init__
などメソッドの引数にデフォルト値を設定できます。
静的解析ツールでは活用が難しいものの、IDEからの参照で役に立ちそうです。
例えば前述の AdGroup
の例では、継承元の proto.Message
からコピーしてきた引数に対して mapping = None
や ignore_unknown_fields = False
を付与することで、ユーザーが引数を省略しても問題ないことが分かりやすくなります。
また、google-ads-stubs
では未利用ですが、mypyはstubtestというツールを提供しており、型ヒントを提供するスタブファイルと実際の実装とが乖離していないかを検査できるようです。
スタブライブラリのリリース前のチェックにぜひ利用したいですね。
実際どれくらい便利になるか
スタブライブラリでどれくらい快適になるか見てみましょう。
Before
Google広告APIのPythonクライアントライブラリによるサンプルコードは次のようになります。
このサンプルコードでは、広告を管理する最大の単位であるキャンペーンに対して、一階層下の単位である広告グループを追加するメソッドを記載しています*8。
ここで、コード内の customer_id
は広告アカウントID(例えば1234567890のような10桁の数字)、campaign_id
は追加先のキャンペーンIDです(同様に10桁の数字です)。
from google.ads.googleads.client import GoogleAdsClient def add_ad_group(customer_id, campaign_id): # Google広告APIのクライアント client = GoogleAdsClient.load_from_storage() # 広告グループを扱うサービスのクライアント ad_group_service = client.get_service("AdGroupService") # キャンペーンを扱うサービスのクライアント campaign_service = client.get_service("CampaignService") # 広告グループの操作に対応するエンティティ ad_group_operation = client.get_type("AdGroupOperation") ad_group = ad_group_operation.create # 新規作成する ad_group.name = "New Ad Group" ad_group.status = client.enums.AdGroupStatusEnum.ENABLED ad_group.campaign = campaign_service.campaign_path( customer_id, campaign_id ) ad_group.type_ = client.enums.AdGroupTypeEnum.SEARCH_STANDARD ad_group.cpc_bid_micros = 10000000 # リクエストを送る response = ad_group_service.mutate_ad_groups( customer_id=customer_id, operations=[ad_group_operation] )
Google広告APIは機能に応じてサービスという単位で別れており、ルートクライアントである GoogleAdsClient
のメソッド get_service
を使って ad_group_service
や campaign_service
といった各サービスのクライアントをそれぞれ生成します。
広告のエンティティやその操作はリソースと呼ばれ、前述のコードでは ad_group_operation
や、そこに含まれる ad_group
が該当します。
リソースもサービスと同様に get_type
メソッドを使ってインスタンスを生成します。
このインスタンスに所望の設定値を設定した後で、サービスのクライアントのメソッドを呼び出す、というのが一般的な流れになります*9。
基本的なユースケースでは前述のようなサンプルコードが提供されているものの、それを発展させたりサンプルコードがカバーしていなかったりするユースケースでは、どのようなパラメータにどのような型のデータを渡す必要があるのかリファレンスとにらめっこしながら書き進めていくことになります。
After
スタブライブラリによる型ヒントを活用するには、先ほどのサンプルコードを次のように書き換えます*10。
from google.ads.googleads.v20.enums import ( AdGroupTypeEnum, AdGroupStatusEnum, ) from google.ads.googleads.v20.resources import AdGroup from google.ads.googleads.v20.services import AdGroupOperation from google.ads.googleads.v20.services.services.campaign_service import ( CampaignServiceClient, ) from google.ads.googleads.v20.services.services.ad_group_service import ( AdGroupServiceClient, ) def add_ad_group(customer_id, campaign_id): # 広告グループを扱うサービスのクライアント ad_group_service = AdGroupServiceClient() # キャンペーンを扱うサービスのクライアント campaign_service = CampaignServiceClient() # 広告グループの操作に対応するエンティティ ad_group_operation = AdGroupOperation( create=AdGroup( name="New Ad Group", status=AdGroupStatusEnum.AdGroupStatus.ENABLED, campaign=campaign_service.campaign_path( customer_id, campaign_id ), type_=AdGroupTypeEnum.AdGroupType.SEARCH_STANDARD, cpc_bid_micros=10000000, ) ) # リクエストを送る response = ad_group_service.mutate_ad_groups( customer_id=customer_id, operations=[ad_group_operation] )
具体的には、AdGroupOperation
や AdGroup
などの生成時に渡せるパラメータを確認できるようになったり、サービスのクライアントについて ad_group_service.
とタイプした時点で利用できるメソッドが一覧できるようになったりしました。
ちなみに、「ついでに customer_id
や campaign_id
に : int
と型ヒントを書いておくか」と追記したところ、これら引数の利用箇所で静的解析ツールから警告を受けました。
実はこれら引数は str
型で、10桁の数字からなるIDを文字列として渡す必要がありました(!)。
このように、スタブライブラリを導入することでIDEや静的解析ツールの恩恵を受け、快適で堅牢なプログラミングを満喫できます。
まとめ
Google広告APIのPythonクライアントライブラリを題材として、型ヒントを提供するスタブファイルの作り方に入門しました。
下地はmypy付属の stubgen
で自動生成できますが、そこからの手直しはライブラリに合わせて最適解を見定めていく必要があり、場合によってはimportlibやinspectモジュールを駆使した自動化が有効そうです。
なお余談ですが、ユーザーからの長年の要望*11やスタブライブラリの存在を受けてか、型ヒントの提供が始まりつつあります *12 。 本記事で取り上げたスタブライブラリも、それ自体の更新から将来的には本家に合流していくことになるかもしれません。 どちらにしろ、型ヒントによる快適で堅牢な開発環境の実現に向けて、今後も取り組んでいきたいと思います。
We are hiring !!
エムスリーAI・機械学習チームでは、実際のプロダクト開発だけでなく、そこで活用できるOSSへのコントリビューションにも意欲的な仲間を歓迎しています。 新卒・中途それぞれの採用だけでなく、カジュアル面談やインターンも常時募集しています。 次のリンクからぜひご応募ください!
エンジニア採用ページはこちら
カジュアル面談もお気軽にどうぞ
インターンも常時募集しています
*1:ユーザーからの要望を受けて、型ヒントの付与が始まりつつあるライブラリもあるようです
*2:https://mypy.readthedocs.io/en/stable/stubgen.html から引用
*3:https://github.com/henribru/google-ads-stubs/blob/86a1333aed3abbbee4681c510d61092fc80b73ef/create_type_stubs.py#L121-L141
*4:説明のため、過去のバージョン https://github.com/googleads/google-ads-python/blob/19.0.0/google/ads/googleads/v12/resources/types/ad_group.py から一部抜粋。繰り返しになりますが、最近ではPython側の対応する型ヒントが付きつつあります。ここで紹介するのは過去の涙ぐましい努力です
*5:https://github.com/henribru/google-ads-stubs/blob/86a1333aed3abbbee4681c510d61092fc80b73ef/create_type_stubs.py#L60-L105 を改変
*6:https://github.com/googleapis/proto-plus-python/blob/v1.26.1/proto/message.py#L662-L668
*7:https://github.com/henribru/google-ads-stubs/blob/86a1333aed3abbbee4681c510d61092fc80b73ef/create_type_stubs.py#L11-L38
*8:https://github.com/googleads/google-ads-python/blob/27.0.0/examples/basic_operations/update_ad_group.py をもとにしています
*9:このようにサービス(のクライアント)やリソースを動的に生成・取得するのは、APIのバージョン間におけるインポートパスのずれを吸収するためと考えられます
*10:ここではAPIのバージョン指定をしており、前述した「APIバージョン間のインポートパスのずれをインスタンス生成メソッドで吸収する」メリットは享受できなくなっています。ただし個人的には、バージョン間でパスが変更される頻度は低く、かつ検知できるため、問題になる可能性は低いと考えています
*11:2019年から型ヒントの提供を望むissueが立っていました。例えば https://github.com/googleads/google-ads-python/issues/97