🐍

Pythonの型ヒントと共に進化するコード(#23: py.typed と型情報の公開)

に公開
これまでの連載記事


前回は printlogging に置き換え、ログ出力を型安全に整備しました。Literal でログレベルをタイポから守り、Annotated で型に意図を埋め込む。連載を通じて磨き上げてきたこのコードは型ヒントの力によって堅牢な実装へと進化しました。

今の状態であれば他のプロジェクトでも安全に利用できる品質といえそうです。
ただし、このままパッケージ化して配布するだけだと利用者のエディタや mypy は型ヒントを無視してしまいます。

今回はこの問題を解決し、型情報をパッケージの一部として正しく届けるための仕上げを行います。

今回の課題:消えてしまう型情報

この郵便番号検索ライブラリのコードを address_search というパッケージとして PyPI に公開したとしましょう。

address_search/
├── __init__.py
├── models.py       # Address, ZipCode など
├── http_client.py  # HttpClient, RequestsHttpClient, FetchError など
└── result.py       # Ok, Err, Result

これは便利そうだと思った別の開発者がこのパッケージをインストールして使い始めます。

# 別の開発者のコード
from address_search.models import Address, ZipCode
from address_search.result import Result, Ok, Err, is_ok, is_err

zipcode: ZipCode = ZipCode("1000001")
...

しかし、mypyPyrightで型チェックを実行すると思わぬエラーに遭遇することがあります。

error: Skipping analyzing 'address_search': module is installed, but missing library stubs or py.typed marker

??

何が起きているのでしょうか?

型チェッカーの疑い深さ

型チェッカーは、サードパーティのパッケージに対してデフォルトで型情報を持っていないと見なします。PEP 561 で定められたこの挙動は、Python エコシステムの歴史的な経緯によるものです。多くの古いパッケージは型ヒントを持っておらず、型チェッカーがそれらを解析しようとすると誤検出が頻発してしまいます。

型チェッカーは、明示的な宣言がない限り外部パッケージの型情報を無視します。

これまで丁寧に書いた型ヒントも、この挙動によって利用者には見えなくなってしまいます。

処方箋:py.typedマーカーファイル

この問題を解決するのが py.typedマーカーファイル です。

PEP 561 で定義されたこの仕組みは非常にシンプルです。配布対象のパッケージディレクトリ(address_search/)直下にpy.typedという空のファイルを配置するだけで、このパッケージは型情報を含んでいると宣言できます。

address_search/
├── __init__.py
├── py.typed        # 👉 空のマーカーファイルを追加
├── models.py
├── http_client.py
└── result.py

たったこれだけで型チェッカーはこのパッケージを型付きパッケージとして認識し、利用者のコードでも型情報が正しく解析されるようになります。

py.typedの作成方法

py.typedファイルの内容はで構いません。ファイルの存在自体でマーカーとして機能します。

# パッケージディレクトリ内で
touch py.typed

またはエディタで空のファイルを作成しても構いません。

パッケージ配布時の設定

py.typedファイルがパッケージと一緒に配布されるように、ビルド設定ファイルに明示的に含める必要があります。

pyproject.tomlを使う場合(推奨)

# pyproject.toml
[build-system]
requires = ["setuptools>=61.0"]
build-backend = "setuptools.build_meta"

[project]
name = "address_search"
version = "1.0.0"
# ...

[tool.setuptools.package-data]
address_search = ["py.typed"]

setup.pyを使う場合

# setup.py
from setuptools import setup

setup(
    name="address_search",
    version="1.0.0",
    packages=["address_search"],
    package_data={"address_search": ["py.typed"]},
    # ...
)

上記の定義により、pip installでインストールされた際にもpy.typedファイルが含まれるようになります。

インライン型とスタブファイル

py.typedを配置する方法は インライン型 と呼ばれるアプローチです。
このアプローチは型ヒントをソースコード内に直接書く方法であり、この連載で一貫して採用してきた方法です。

一方で スタブファイル.pyiファイル)という別のアプローチもあります。

スタブファイルとは

スタブファイルは型情報だけを記述した別ファイルです。実行時のコードには影響を与えず、型チェッカーだけが参照します。

# models.pyi (スタブファイル)
from typing import Any, Mapping, NewType
from dataclasses import dataclass

ZipCode = NewType("ZipCode", str)

@dataclass(frozen=True, slots=True)
class Address:
    zipcode: str
    prefecture: str
    # ... 実装は書かない、型シグネチャのみ

    @classmethod
    def from_api(cls, payload: Mapping[str, Any]) -> Address: ...

スタブファイルは以下のような場合に有用です。

  • 型ヒントを持たない既存のライブラリに型情報を追加したい場合
  • C 拡張モジュールや Rust(PyO3)で書かれたバインディングなど、Python ソースコードが存在しない場合
  • 実行時のオーバーヘッドをゼロにしたい場合

どちらを選ぶべきか

インライン型(py.typed

  • コードと型情報が一箇所にまとまり、保守しやすい
  • 型と実装の乖離が起きにくい
  • 現代的な Python 開発の推奨アプローチ

スタブファイル(.pyi

  • 既存のコードベースに後から型を追加する場合に便利
  • C 拡張や動的に生成されるコードに対応できる
  • 実行時のパフォーマンスへの影響がゼロ

新規のパッケージ開発であれば、インライン型 + py.typed のアプローチが最もシンプルで推奨されます。

コードの進化:パッケージとしての仕上げ

このプロジェクトを正式なパッケージとして公開する準備をしましょう。

ディレクトリ構造

address_search/
├── __init__.py
├── py.typed          # 👉 マーカーファイル
├── models.py
├── http_client.py
├── result.py
├── validator.py
├── logger.py
└── measure_time.py

パッケージ化に伴い、モジュール間のインポートも相対インポート(from . import ...)に変更します。

address_search/http_client.py

# ...
from .models import Headers  # 👉 相対インポートに変更
from .measure_time import measure_time
# ...

address_search/main.py

パッケージ内のメインロジックとして main.py を配置します。

# address_search/main.py
# ...
# 👉 相対インポート
from .models import ZipCode, Headers, Address, AddressFormatter
from .http_client import HttpClient, FetchError, FetchErrorType, to_error_type
from .result import Result, Ok, Err, is_ok, is_err
from .logger import setup_logger
# ...

__init__.pyで公開 API を定義

# address_search/__init__.py
"""郵便番号から住所を検索するライブラリ"""

from .models import (
    ZipCode,
    Headers,
    Address,
    FormattedAddressDict,
    FormattedAddressWithKanaDict,
    AddressFormatter,
)
from .http_client import (
    HttpClient,
    RequestsHttpClient,
    FetchError,
    FetchErrorType,
    to_error_type,
)
from .result import Result, Ok, Err, is_ok, is_err
from .validator import ValidationError, validate_args
from .logger import setup_logger, LogLevel, LoggerName

__all__ = [
    # 型
    "ZipCode",
    "Headers",
    "Address",
    "FormattedAddressDict",
    "FormattedAddressWithKanaDict",
    "AddressFormatter",
    # HTTP
    "HttpClient",
    "RequestsHttpClient",
    "FetchError",
    "FetchErrorType",
    "to_error_type",
    # Result
    "Result",
    "Ok",
    "Err",
    "is_ok",
    "is_err",
    # Validator
    "ValidationError",
    "validate_args",
    # Logger
    "setup_logger",
    "LogLevel",
    "LoggerName",
]

利用者のコード

パッケージを公開すれば、他の開発者は以下のように使えます。

# 利用者のコード
from address_search import (
    ZipCode,
    Address,
    RequestsHttpClient,
    Result,
    FetchError,
)

def search_address(zipcode_str: str) -> Result[Address, FetchError]:
    zipcode = ZipCode(zipcode_str)
    client = RequestsHttpClient()
    # ...

py.typedがあることで、利用者の型チェッカーはZipCodeNewTypeであること、ResultがジェネリックなOk | Errであること、すべてを正しく認識します。

得られたもの:完成した型安全なライブラリ

py.typedマーカーファイルを含めたことで、このパッケージは 型情報を含む正式な Python パッケージ として公開する準備が整いました。

py.typedマーカーファイルは小さな一歩ですが、その効果は絶大です。

  • 利用者の IDE で正確な補完が効く
  • 利用者のコードで型エラーが検出される
  • API の使い方が型シグネチャから明確に伝わる

これまで費やした型ヒントへの労力はパッケージを使うすべての開発者に還元されるのです。

次回予告

次回は最後のコラム回として、mypy の strict モードの深堀りと、strict モードをプロジェクトに段階的に導入するための Tips を紹介しようと思います。

👉 24 日目: mypy strict モードを深掘りする

処方後のコードはこちら

py.typed(新規作成)

(空ファイル)

address_search/__init__.py(新規作成)

# address_search/__init__.py
"""郵便番号から住所を検索するライブラリ"""

from .models import (
    ZipCode,
    Headers,
    Address,
    FormattedAddressDict,
    FormattedAddressWithKanaDict,
    AddressFormatter,
)
from .http_client import (
    HttpClient,
    RequestsHttpClient,
    FetchError,
    FetchErrorType,
    to_error_type,
)
from .result import Result, Ok, Err, is_ok, is_err
from .validator import ValidationError, validate_args
from .logger import setup_logger, LogLevel, LoggerName

__all__ = [
    # 型
    "ZipCode",
    "Headers",
    "Address",
    "FormattedAddressDict",
    "FormattedAddressWithKanaDict",
    "AddressFormatter",
    # HTTP
    "HttpClient",
    "RequestsHttpClient",
    "FetchError",
    "FetchErrorType",
    "to_error_type",
    # Result
    "Result",
    "Ok",
    "Err",
    "is_ok",
    "is_err",
    # Validator
    "ValidationError",
    "validate_args",
    # Logger
    "setup_logger",
    "LogLevel",
    "LoggerName",
]

address_search/validator.py

# address_search/validator.py
from __future__ import annotations

import inspect
from dataclasses import dataclass
from functools import wraps
from typing import Annotated, Any, Callable, get_args, get_origin, get_type_hints

from .result import Err, Ok, Result, is_err


@dataclass(frozen=True, slots=True)
class EmptyStringError:
    """空文字列エラー"""

    message: str = "空文字列は許可されていません"


@dataclass(frozen=True, slots=True)
class ValidationError:
    """バリデーション失敗時のエラー"""

    field: str
    message: str


def non_empty(value: str) -> Result[str, EmptyStringError]:
    """空文字列を許可しない"""
    if not value or value.isspace():
        return Err(EmptyStringError())
    return Ok(value)


def validate_args[**P, R](
    func: Callable[P, Result[R, ValidationError]],
) -> Callable[P, Result[R, ValidationError]]:
    """Annotated のメタデータに callable があれば適用し、Result で返す"""
    hints = get_type_hints(func, include_extras=True)
    sig = inspect.signature(func)

    @wraps(func)
    def wrapper(*args: P.args, **kwargs: P.kwargs) -> Result[R, ValidationError]:
        bound = sig.bind(*args, **kwargs)
        bound.apply_defaults()

        for name, value in bound.arguments.items():
            hint = hints.get(name)
            if hint is not None and get_origin(hint) is Annotated:
                type_args = get_args(hint)
                for meta in type_args[1:]:
                    if callable(meta):
                        result = meta(value)
                        if is_err(result):
                            return Err(
                                ValidationError(field=name, message=result.error.message)
                            )
                        value = result.value
            bound.arguments[name] = value

        return func(*bound.args, **bound.kwargs)

    return wrapper

address_search/logger.py

# address_search/logger.py
from __future__ import annotations

import logging
from typing import Annotated, Literal

from .result import Result, Ok
from .validator import ValidationError, non_empty, validate_args

# Literal でログレベルを型安全に
LogLevel = Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]

# Annotated でバリデーション関数を付与
LoggerName = Annotated[str, non_empty]

@validate_args
def setup_logger(
    name: LoggerName,
    level: LogLevel = "INFO",
) -> Result[logging.Logger, ValidationError]:
    """指定された名前とレベルでロガーを設定する"""
    logger = logging.getLogger(name)
    logger.setLevel(level)
    # ハンドラの追加は呼び出し側に委ねる
    return Ok(logger)

address_search/result.py

# address_search/result.py
from __future__ import annotations

from dataclasses import dataclass
from typing import TypeIs, final  # TypeIs は 3.13 以降


@final
@dataclass(frozen=True, slots=True)
class Ok[T]:
    """成功を表すコンテナ"""
    value: T


@final
@dataclass(frozen=True, slots=True)
class Err[E]:
    """失敗を表すコンテナ"""
    error: E


type Result[T, E] = Ok[T] | Err[E]


def is_ok[T, E](result: Result[T, E]) -> TypeIs[Ok[T]]:
    """成功かどうかを判定し、型を絞り込む"""
    return isinstance(result, Ok)


def is_err[T, E](result: Result[T, E]) -> TypeIs[Err[E]]:
    """失敗かどうかを判定し、型を絞り込む"""
    return isinstance(result, Err)

address_search/models.py

# address_search/models.py
from __future__ import annotations

from collections.abc import Mapping
from dataclasses import dataclass, replace
from typing import Any, ClassVar, Final, NewType, ReadOnly, Self, TypedDict, TypeIs

ZipCode = NewType("ZipCode", str)
type Headers = dict[str, str]

@dataclass(frozen=True, slots=True)
class ApiError:
    error_code: str
    message: str

    @classmethod
    def unmarshal_payload(cls, payload: Mapping[str, Any]) -> ApiError:
        return cls(
            error_code=str(payload["error_code"]),
            message=str(payload["message"]),
        )

@dataclass(frozen=True, slots=True)
class Address:
    API_PATH: ClassVar[Final[str]] = "/v1/address"

    zipcode: str
    prefecture: str
    prefecture_kana: str
    city: str
    city_kana: str
    town: str
    town_kana: str

    def full_address(self) -> str:
        """都道府県・市区町村・町域を結合したフル住所を返す"""
        return self.prefecture + self.city + self.town

    def full_address_kana(self) -> str:
        """フル住所のカナ表記を返す"""
        return self.prefecture_kana + self.city_kana + self.town_kana

    @classmethod
    def unmarshal_payload(cls, payload: Mapping[str, Any]) -> Address:
        """APIレスポンスからAddressオブジェクトを生成する"""
        return cls(
            zipcode=str(payload["zipcode"]),
            prefecture=str(payload["prefecture"]),
            prefecture_kana=str(payload["prefecture_kana"]),
            city=str(payload["city"]),
            city_kana=str(payload["city_kana"]),
            town=str(payload["town"]),
            town_kana=str(payload["town_kana"]),
        )

class FormattedAddressDict(TypedDict):
    zipcode: ReadOnly[str]
    full_address: ReadOnly[str]
    prefecture: ReadOnly[str]
    city: ReadOnly[str]
    town: ReadOnly[str]

class FormattedAddressWithKanaDict(FormattedAddressDict):
    full_address_kana: ReadOnly[str]

@dataclass(frozen=True, slots=True)
class AddressFormatter:
    _address: Address | None = None
    _include_kana: bool = False

    def with_address(self, address: Address) -> Self:
        return replace(self, _address=address)

    def with_kana(self, include: bool = True) -> Self:
        return replace(self, _include_kana=include)

    def build(self) -> FormattedAddressDict | FormattedAddressWithKanaDict:
        if self._address is None:
            raise ValueError("Address must be set before building.")

        base: FormattedAddressDict = {
            "zipcode": self._address.zipcode,
            "full_address": self._address.full_address(),
            "prefecture": self._address.prefecture,
            "city": self._address.city,
            "town": self._address.town,
        }

        if self._include_kana:
            with_kana: FormattedAddressWithKanaDict = {
                **base,
                "full_address_kana": self._address.full_address_kana(),
            }
            return with_kana

        return base

type ApiResponse = Address | ApiError

def is_error_response(response: ApiResponse) -> TypeIs[ApiError]:
    """ApiError かどうかを判定する"""
    return isinstance(response, ApiError)

address_search/measure_time.py

# address_search/measure_time.py
from __future__ import annotations

import time
from functools import wraps
from typing import Callable

def measure_time[**P, R](func: Callable[P, R]) -> Callable[P, R]:
    """関数の実行時間を計測し、標準出力に表示するデコレータ"""
    @wraps(func)
    def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
        start_time = time.perf_counter()
        result = func(*args, **kwargs)
        end_time = time.perf_counter()
        print(f"Finished '{func.__name__}' in {end_time - start_time:.4f} secs")
        return result
    return wrapper

address_search/typings.py

# address_search/typings.py
from __future__ import annotations

from collections.abc import Mapping, Sequence


def first[T](items: Sequence[T]) -> T | None:
    """シーケンスの最初の要素を返す。空なら None を返す。"""
    return items[0] if items else None


def get_or[K, V](d: Mapping[K, V], key: K, default: V) -> V:
    """マッピングから値を取得する。キーがなければデフォルト値を返す。"""
    return d.get(key, default)

address_search/http_client.py

# address_search/http_client.py
from __future__ import annotations

import requests as requests_lib
from dataclasses import dataclass
from enum import Enum, auto
from typing import Protocol

from .measure_time import measure_time
from .models import Headers

type JsonObject = dict[str, object]

class FetchErrorType(Enum):
    NETWORK_ERROR = auto()
    NOT_FOUND = auto()
    CLIENT_ERROR = auto()
    SERVER_ERROR = auto()
    API_ERROR = auto()

@dataclass(frozen=True, slots=True)
class FetchError:
    type: FetchErrorType
    message: str


class HttpResponse(Protocol):
    @property
    def status_code(self) -> int: ...
    def json(self) -> object: ...


class HttpClient(Protocol):
    def post(self, url: str, json: JsonObject, headers: Headers | None = None) -> HttpResponse: ...


class RequestsResponse:
    def __init__(self, response: requests_lib.Response) -> None:
        self._response = response

    @property
    def status_code(self) -> int:
        return self._response.status_code

    def json(self) -> object:
        return self._response.json()


class RequestsHttpClient:
    def __init__(self) -> None:
        self._session = requests_lib.Session()

    @measure_time
    def post(self, url: str, json: JsonObject, headers: Headers | None = None) -> RequestsResponse:
        response = self._session.post(url, json=json, headers=headers)
        return RequestsResponse(response)


def to_error_type(status_code: int) -> FetchErrorType:
    """HTTPステータスコードをFetchErrorTypeに変換する"""
    match status_code:
        case 404:
            return FetchErrorType.NOT_FOUND
        case num if 400 <= num < 500:
            return FetchErrorType.CLIENT_ERROR
        case _:
            return FetchErrorType.SERVER_ERROR

address_search/main.py

# address_search/main.py
from __future__ import annotations

import json
from collections.abc import Mapping
from typing import Any, assert_never, cast, Final

from .models import ZipCode, Headers, Address, AddressFormatter
from .http_client import HttpClient, FetchError, FetchErrorType, to_error_type
from .result import Result, Ok, Err, is_err
from .logger import setup_logger

# 定数
BASE_URL: Final[str] = "https://api.zipcode-jp.example"
HTTP_OK: Final[int] = 200

# モジュールレベルでロガーを取得
# 簡略化のため import 時に初期化しているが、実運用では関数内で初期化するかエントリポイントで初期化することを推奨
_logger_result = setup_logger(name=__name__)
if is_err(_logger_result):
    raise RuntimeError(f"ロガーの初期化に失敗しました: {_logger_result.error}")
_logger = _logger_result.value


def handle_fetch_error(error: FetchError) -> None:
    """エラーの種類に応じてログを出力する"""
    match error.type:
        case FetchErrorType.NETWORK_ERROR:
            _logger.error("ネットワークエラー: %s", error.message)
        case FetchErrorType.NOT_FOUND:
            _logger.warning("郵便番号が見つかりません: %s", error.message)
        case FetchErrorType.CLIENT_ERROR:
            _logger.error("リクエストエラー: %s", error.message)
        case FetchErrorType.SERVER_ERROR:
            _logger.error("サーバーエラー: %s", error.message)
        case FetchErrorType.API_ERROR:
            _logger.error("APIエラー: %s", error.message)
        case _:
            assert_never(error.type)


def fetch_and_format_address(
    zipcode: ZipCode,
    include_kana: bool,
    http_client: HttpClient,
    headers: Headers | None = None,
) -> Result[str, FetchError]:
    """郵便番号から住所を取得し、整形して返す"""

    api_url = f"{BASE_URL}{Address.API_PATH}"
    _logger.info("住所検索を開始: zipcode=%s", zipcode)

    try:
        response = http_client.post(api_url, json={"zipcode": zipcode}, headers=headers)
        if response.status_code != HTTP_OK:
            error_type = to_error_type(response.status_code)
            _logger.warning(
                "API がエラーを返却: status=%d, zipcode=%s",
                response.status_code,
                zipcode,
            )
            return Err(FetchError(
                type=error_type,
                message=f"住所情報の取得に失敗しました: {response.status_code}",
            ))

        payload = cast(Mapping[str, Any], response.json())
        address = Address.unmarshal_payload(payload)
        formatter = AddressFormatter()
        result = formatter.with_address(address).with_kana(include_kana).build()

        _logger.info("住所検索が完了: zipcode=%s", zipcode)
        return Ok(json.dumps(result, indent=2, ensure_ascii=False))

    except Exception as e:
        _logger.exception("予期せぬエラーが発生")
        return Err(FetchError(type=FetchErrorType.NETWORK_ERROR, message=str(e)))

利用者側のコード例

# example.py(パッケージの外に配置)
from address_search import ZipCode, RequestsHttpClient, is_err
from address_search.main import fetch_and_format_address, handle_fetch_error

http_client = RequestsHttpClient()
zipcode = ZipCode("1000001")
result = fetch_and_format_address(
    zipcode, include_kana=True, http_client=http_client
)

if is_err(result):
    handle_fetch_error(result.error)
else:
    print(result.value)

Discussion