Pythonの型ヒントと共に進化するコード(#23: py.typed と型情報の公開)
これまでの連載記事
- 1 日目: なぜ Recustomer が型を語るのか
- 2 日目: イントロダクション
- 3 日目: 脆いコードお披露目
- 4 日目: 辞書にスキーマを与える
TypedDict - 5 日目: 「ないこともある」を表現する
UnionとOptional - 6 日目: ドメインの意図を込める
NewTypeとTypeAlias - 7 日目:
ABCで「契約」を定義する - 8 日目:
Protocolで柔軟性を得る - 9 日目: 責務を分ける
- 10 日目:
dataclassesとClassVar - 11 日目: コレクション抽象型(Mapping)
- 12 日目:
Finalで定数を保護する - 13 日目:
from __future__ import annotations - 14 日目: コラム回:Typer のすゝめ
- 15 日目:
SelfとReadOnly - 16 日目: TypeVar で実現する Generics
- 17 日目: ParamSpec と Callable
- 18 日目: Type Narrowing
- 19 日目: コラム回:reveal_type
- 20 日目: Enum と assert_never
- 21 日目: Result 型を実装する
- 22 日目: Literal と Annotated
前回は print を logging に置き換え、ログ出力を型安全に整備しました。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")
...
しかし、mypyやPyrightで型チェックを実行すると思わぬエラーに遭遇することがあります。
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があることで、利用者の型チェッカーはZipCodeがNewTypeであること、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