エムスリーテックブログ

エムスリー(m3)のエンジニア・開発メンバーによる技術ブログです

Pythonのスタブライブラリを生成して、型ヒントのないライブラリも快適で堅牢に利用する

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 は実行するだけでドラフトレベルのスタブファイルを生成してくれます。 あとは、生成元のライブラリやユースケースに合わせて、適宜手直しを入れていくことになります。 例えば前述の例だと、 widthheightは実際は int なのでそのように変更する、といった修正をします。 ちなみに Incompletetyping.Any のエイリアスで、stubgen が型推論できなかったり、そもそもランタイムでないと不明だったりする場合に付与されるようです。

pathlib,importlib,inspectを組み合わせたモジュール・クラスの列挙と書き換え

小規模なライブラリであれば人間による手直しでも対応できますが、大規模な場合には機械的な対応が必要になります。 google-ads-stubs では、元のライブラリに対してpathlibimportlibを組み合わせて各モジュールをインポートしつつ、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 = Noneignore_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_servicecampaign_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]
    )

具体的には、AdGroupOperationAdGroup などの生成時に渡せるパラメータを確認できるようになったり、サービスのクライアントについて ad_group_service. とタイプした時点で利用できるメソッドが一覧できるようになったりしました。 ちなみに、「ついでに customer_idcampaign_id: int と型ヒントを書いておくか」と追記したところ、これら引数の利用箇所で静的解析ツールから警告を受けました。 実はこれら引数は str 型で、10桁の数字からなるIDを文字列として渡す必要がありました(!)。 このように、スタブライブラリを導入することでIDEや静的解析ツールの恩恵を受け、快適で堅牢なプログラミングを満喫できます。

まとめ

Google広告APIのPythonクライアントライブラリを題材として、型ヒントを提供するスタブファイルの作り方に入門しました。 下地はmypy付属の stubgen で自動生成できますが、そこからの手直しはライブラリに合わせて最適解を見定めていく必要があり、場合によってはimportlibやinspectモジュールを駆使した自動化が有効そうです。

なお余談ですが、ユーザーからの長年の要望*11やスタブライブラリの存在を受けてか、型ヒントの提供が始まりつつあります *12 。 本記事で取り上げたスタブライブラリも、それ自体の更新から将来的には本家に合流していくことになるかもしれません。 どちらにしろ、型ヒントによる快適で堅牢な開発環境の実現に向けて、今後も取り組んでいきたいと思います。

We are hiring !!

エムスリーAI・機械学習チームでは、実際のプロダクト開発だけでなく、そこで活用できるOSSへのコントリビューションにも意欲的な仲間を歓迎しています。 新卒・中途それぞれの採用だけでなく、カジュアル面談やインターンも常時募集しています。 次のリンクからぜひご応募ください!

エンジニア採用ページはこちら

jobs.m3.com

カジュアル面談もお気軽にどうぞ

jobs.m3.com

インターンも常時募集しています

open.talentio.com

*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

*12:例えば googleads/google-ads-python #695 (GitHub)