python-conventions について
python-conventionsは、個人開発やエージェントが保守するPythonプロジェクト向けの設計・スタイル指針です。コード作成やレビューで使う既定案と注意点を示し、リンターや型チェッカーの設定は扱いません。
主な機能
- 01Pythonのデータモデリング既定案
- 02日付とタイムゾーンの扱い方
- 03Pydantic移行時の落とし穴の解説
- 04設計指針とツール設定の区別
- 05規約を示すコードスニペット
活用シーン
- 01外部データや設定のモデル選定
- 02Pythonコードの日付処理のレビュー
- 03dataclassからPydanticへの移行
- 04エージェント保守プロジェクトでの規約統一
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ theodoread/ agent-skills --skill python-conventionsインストール後、Claude Code で/python-conventionsを入力して読み込まれたか確認します。
同梱ファイル
SKILL.md と一緒にインストールされる参考資料やスクリプト。
references/rationale.mdreferences/snippets/async-fanout.pydata-modeling.pyexceptions.pyguard-clauses.pyhttp-retry.pysettings.py詳細
Python の設計とスタイルのデフォルト
個人用の、エージェントが保守する Python プロジェクト向けです。以下のトピックについて、明示的に「代替案を評価する」よう求められていない限り、新しいコードを書くときや既存のコードをレビューするときに適用します。デフォルトを選び、毎回ゼロから再検討しないでください。指定された例外パターンに本当に当てはまる場合は、異なる選択をして構いません。目的は、特別な理由もなく新しいセッションやモデルがひそかに別の選択をするのを防ぐことであり、判断を禁じることではありません。
2026-08-31 に 2 つのトピックを切り分けました。それぞれ別個にインストールします。テストスイートの規約は
python-testing-conventions に、MCP
サーバーの標準入出力ロギング、ツール境界、ツールの docstring に関するルールは
mcp-python-conventions にあります。切り分ける前に評価を行い、それぞれが実際のリクエストに照らして確認され、各トピックがそれぞれの領域で有効に機能し、互いの領域を侵食しないことを確かめました。
これは設計ガイダンスであり、ツール設定ではありません。 どの型チェッカーやリンターをインストールするか、またその設定方法を指示するものではありません。それは、リポジトリが独自の標準を適用するために使うもの(この作者の場合は basedpyright、ruff、shellcheck/shfmt、dprint、pytest の設定)に委ねられます。このスキルは コードを書くとき に参照するものであり、ツールの設定は セットアップ時に一度だけ リポジトリが適用するものです。
以下の各トピックでは、独自のデフォルトの直感を上書きするのか、それとも単に確認するだけなのかを示しています。 有能なモデルなら、指示がなくてもこの多くを正しく判断できます。早期リターン、実行時の検索における EAFP、妥当な非同期コード、os.path より pathlib を使うことなどです。このスキルは、こうした点を再検討するためのものではありません。放っておくとモデルが判断を揺らしがちな選択肢(どれも同じくらい妥当に見えるデータモデリングの 6 つの選択肢、過剰な抽象化を招く DRY の直感、学習データで直近に見たフレームワークから借りてくる設定パターン)や、一般的な Python の知識ではなく、実際に分かりにくい、またはプロジェクト固有の規約(globals.py のシングルトンの形)を扱います。以下の各 Model default の行には、どちらに当たるかが記されています。すでに自分ならそうすると分かっている項目は読み飛ばし、そうでない項目を重視してください。
データモデリング
- スニペット:
references/snippets/data-modeling.py - デフォルト:外部または信頼できないデータ(API
レスポンス、MCP ツールの引数)や設定を解析するものにはPydantic v2、
frozen=Trueを使います。それ以外はすべて **@dataclass(frozen=True)**を使います。内部の構造化データ、関数の戻り値、レコードが該当します。 - 理由:デフォルトを6つではなく2つにすることで、既存のコードをまねるエージェントが誤ったものを選ぶ余地を減らせます。PydanticのRustコアによってv1との検証速度の差は埋まりましたが、データクラスと比べた単純な属性アクセスの差は埋まっていません。この使い分けは、抽象的にどちらが「高速」かではなく、境界での検証とそれ以外のすべてを区別するためのものです。
- 次の選択肢:Pydanticの検証とシリアライズの一体化を避けつつバリデーターやコンバーターを使うなら
attrs、位置引数タプルとの互換性をそのまま保つ場合や、順序そのものが意味を持つ小さく閉じたレコードならNamedTuple(フィールドは最大でも約3つまで)、Pydanticのオーバーヘッドが計測の結果ボトルネックになったらmsgspec、値をオブジェクトにせず本当にプレーンな辞書のままにする必要があるならTypedDictを使います。 - NamedTupleの条件のうち3つ目、つまり検証も振る舞い上の要件もないことが、落とし穴になります。「約3つより多いフィールド」はより緩い判定基準です。 2フィールドの
Quantity(amount, unit)は教科書どおりのNamedTupleに見えますが、実際にはバグです。タプルの順序比較を継承するため、Quantity(5, "ml")とQuantity(300, "mg")を<で比較すると、例外が発生せずTrueになり、単位が一致しないまま比較が成功してしまいます。比較に前提条件がある値型にとって、制約のないタプル順序比較は便利な機能ではなく危険要因です。 - この危険性とNamedTupleの利点は、同じ特性の表裏です。したがって、問うべきなのは「ここではNamedTupleのほうがよいか」ではなく、何を置き換えるのかです。 素のタプルから
NamedTupleへの置き換えは明確な改善です。名前がなかったところに名前が加わる一方、位置ベースのインターフェースはすでに存在し、すでにアンパックされています。そのため、新たに漏れ出すものはありません。frozen dataclassからNamedTupleへの置き換えは改悪です。インデックスアクセス、アンパック、反復処理、同じ値を持つ任意のプレーンタプルとの等価性が追加されます。これは誰も設計していない2つ目の位置ベースAPIです。フィールドを途中に挿入すると、すべての呼び出し箇所でx[1]の意味が気づかれないまま変わり、どこでもエラーになりません。2026-09-01に標準ライブラリのみを使う3,200行のスクリプトを監査した際に確認しました。名前のないレコード形式のタプル戻り値16個はNamedTupleに置き換えましたが、frozen dataclass 6個は意図的にそのままにしました。 - 依存関係を利用できない場合、つまり標準ライブラリのみのスクリプトやベンダー提供のツールでは、
__post_init__で検証することで、設定をfrozen dataclassのままにできます。 これは標準ライブラリだけで実現する「検証するのではなく、解析する」です。この考え方は、見つけやすくするためにも、そのフレーズで呼ぶ価値があります。設定は一度だけ読み込まれ、それ以降はどの読み手も適切な形式であることを前提にできます。これはNamedTupleの条件にある検証不要の要件を具体化したものでもあります。NamedTupleには__post_init__がないため、同じ保証を得るには__new__のオーバーライドやクラスメソッドのファクトリーが必要です。これは、それより簡単に済ませるために選んだはずのデータクラスよりも手間がかかります。 dictがマッピングなのか、型情報を失ったレコードなのかは、キーがデータなのか、自分で選んだ名前なのかで判断します。 外部から届くキー(設定ファイルやJSONペイロードなど)はデータなので、辞書のままにします。作者がコードに書いたキーはフィールド名です。7か所で文字列リテラルを使ってdirs["store"]を読み取るなら、それはレコードです。名前の付いたフィールドを2つ使えば、タイプミスを実行時のKeyErrorではなく、型チェッカーが検出するエラーにできます。正当な例外はシリアライズ境界です。json.dumpsに渡すために作られたdict[str, object]は辞書のままにするべきです。型を付けると、変換前と変換後の両方で余計な手間がかかります。- プロジェクト全体での別案:プロジェクト内のどこかでPydanticを使うなら、すべてにPydanticを使います。 これは上記の使い分けから逸脱するのではなく、正当な代替案です。逆方向にも同じように適用でき、「Pydanticを一切使わない」ことも実際の選択肢になります。便利なモジュールを追加するたびに依存関係が入り込む事態を避けられます。すでに境界でPydanticを使っていて、そうしなければ2種類のレコードの慣習を併用することになり、エージェントがどちらをまねるべきか判断しなければならないプロジェクトでは、この案を選びます。使い分けの根拠である、単純な属性アクセスのコスト(計測値)と依存関係の重さを手放すことになるため、その判断はモジュールごとではなく、プロジェクトの開始時に一度だけ行ってください。
- この案を選ぶ最大の理由は、一貫性ではありません。 frozen dataclassを約30個移行したプロジェクトで計測したところ、そのコードベースで最も重要な不変条件に関する、手作業で保守していた
object.__setattr__による正規化箇所13か所を、1つのAnnotatedエイリアスに置き換えられました。frozen dataclassでは__post_init__内の複雑な処理でしか表現できない検証こそが、実際に得られるものです。「慣習が1つなら2つよりよい」という理由より、こちらのほうが有力です。 - モデルのデフォルト:オーバーライド。何も指定しないと、コードベースで最後に見かけたものに応じて、Pydantic、dataclass、TypedDict、NamedTupleが一貫性なく混在します。ここには、確認の基準となる強い単一のデフォルトの考え方がありません。
dataclass にはなかった Pydantic の落とし穴
Pydantic 2.13.5 で確認した 4 つの落とし穴です。いずれも、dataclass から Pydantic へ移行するプロジェクトや、上記の全面移行という選択肢を取るプロジェクトに影響します。最後のものは、呼び出し箇所ではなく公開 API を変えてしまいます。
model_copy(update=...)は検証を行いません — 公式の docstring にもそう記載されています。update=を使ってコピーした frozen モデルは、他のすべての構築経路で拒否される naive datetime や、フィールド間の制約違反を受け入れてしまいます。これはdataclasses.replaceからの明確な後退です。dataclasses.replaceは再検証を行います。また、移行時にはreplace()のすべての呼び出し箇所に機械的に影響します。model_copyではなく、スニペットにある再検証用のヘルパーを使ってください。- Lax モードでは
floatがDecimalに変換されます。floatを許容できないという理由でフィールドをDecimalにしている場合、デフォルト設定ではその保証がひそかに失われます。Annotated[Decimal, Strict()]を使えば保証を復元でき、ほかのメタデータと同じ場所にあるアノテーション内に収まります。 - 設定はクラス宣言に記述します。
class Q(BaseModel, frozen=True)はmodel_config = ConfigDict(frozen=True)と同等であることが確認されており、ほかのキーワードとも併用できます。また、データのように見える属性代入ではなく、クラス名を記述する行で設定が確認できます。 - バリデーターの
ValueErrorは捕捉され、pydantic.ValidationErrorとして再送出されます —ValueError以外の例外は、そのまま伝播します。プロジェクト独自の例外階層の基底クラスがValueErrorの場合に問題となります。これは、入力エラーを汎用的に処理する呼び出し側のコードを引き続き機能させるために選ばれる、よくある合理的な設計です。移行後は、その階層に属するすべての例外が呼び出し側まで届かなくなります。MyValidationErrorが発生していた構築処理で、今度はpydantic.ValidationErrorが発生し、サブクラスの違いはメッセージの文面にしか残りません。これは、例外クラスがバリデーションエラーと単位不一致エラーの 2 つあるプロジェクトで確認したものです。両者を区別することこそ、2 つのクラスを設ける目的です。対処するには、基底クラスからValueErrorを外します。また、pydantic.ValidationErrorはValueErrorでもあるため、その影響を明記してください。構築処理で発生しうる例外をすべて捕捉するには、(ProjectError, ValueError)とします。これにより、プロジェクト独自のエラーと、pydantic が独自に発生させる構造上のエラーの両方を捕捉できます。- 逆の選択が適切なのは、パース境界です。意図的に通常の
ValueErrorを発生させれば、pydantic がそれを捕捉し、フィールドの位置情報を付与し、複数のエラーを 1 つのレポートにまとめてくれます。その後、境界でそのレポートをプロジェクト独自のエラー型に変換します。つまり、「ValueErrorをサブクラス化してはいけない」ということではありません。エラーに型を持たせるか、位置情報を持たせるかをこの選択が決めるようになり、その判断をレイヤーごとに行うということです。
- 逆の選択が適切なのは、パース境界です。意図的に通常の
- モデルのデフォルト設定:オーバーライドします。
model_copy(update=...)はreplaceと同等のものとして自然に読めるため、最初に手を伸ばしがちです。呼び出し箇所を見ても、検証が省略されるとは分かりません。ValueErrorの落とし穴も同じ意味でさらに厄介です。基底クラスは移行よりずっと前に一度だけ選ばれ、変更箇所を見てもその選択を思い出す手がかりがありません。
日付、時刻、タイムゾーン
-
デフォルト:タイムゾーン情報を持つ日時のみを受け付け、すべての境界で UTC に正規化します。 タイムゾーン情報のない日時が入力された時点で拒否し、決して強制変換せず、保存または比較する値はすべて
astimezone(UTC)で変換します。ローカルの壁時計時刻への変換は表示時に限ります。タイムゾーンにはzoneinfo(3.9 以降の標準ライブラリ)を使い、pytzは使いません。 -
理由:UTC 以外の同じ
tzinfoを持つタイムゾーン情報付き日時同士の減算や比較では、壁時計時刻が使われ、間にある DST の切り替わりが暗黙に無視されます。 春の前進時には「24 時間」の直近期間が実時間では 23 時間になり、2 回の服薬の最低間隔が 6 時間の場合は 1 時間早く経過したことになります。さらに、重複時間帯の近くでは==と-の結果が食い違います。曖昧な日時と、それと同じ時点を表す UTC の日時は、減算するとちょうどゼロなのに比較すると等しくないため、時点の等価性に基づく分岐は誤りです。入力時に正規化することで、その後のあらゆる比較が経過時間を意味するようになります。2026-08-27 にingestaで実測したところ、両方の不具合が実際に発生しており、コードを読んでも見つかりませんでした。 -
Pydantic を使用するプロジェクトでの標準的な記述方法:
Utc = Annotated[datetime, AwareDatetime, AfterValidator(lambda v: v.astimezone(UTC))]検証済み:モデルのすべての構築経路で、タイムゾーン情報付きの UTC 以外の日時を正規化し、タイムゾーン情報のない日時を拒否します。これは上記のルール全体を再利用可能な注釈ひとつで実現し、凍結データクラスで各フィールドに必要となる、手動で維持する
object.__setattr__による正規化に置き換わります。Pydantic を使わない場合も、同等の処理は__post_init__で両方の手順を明示的に行います。つまり、タイムゾーン情報のない値を拒否してからastimezone(UTC)で変換します。どちらか一方だけではいけません。 -
ローカルの壁時計時刻の解決:
fold(PEP 495)が API のすべてであり、replace(tzinfo=...)の挙動に任せるのではなく、各ケースでポリシーを明示する必要があります。検出には日時ではなくオフセットを比較します。同一ゾーン内の比較ではfoldが無視されるため、どちらの場合も日時は等しいと判定されます。wall.replace(tzinfo=z, fold=0).utcoffset() < ...fold=1...は時刻が 存在しない(春の前進によるギャップ)ことを示し、>は 曖昧(秋の後退)であることを示します。その後は意図的に選択します。ギャップの時刻を UTC 経由で往復変換するとギャップの後に移動し、曖昧な時刻ではfold=0が最初の発生時刻を選びます。 -
テスト:DST の正しさを検証するには、実際の切り替わり時刻で既知の答えを確認するテストが必要です。期待する時点は手計算し、リテラル値でアサートしてください。プロパティテストは実装を言い換えるだけなので、この不具合をそのまま通過します。テスト用ヘルパーでも正規化してください。ゾーン情報付きの値で
anchor - durationを計算するプロパティテスト自体に不具合があると、正しいコードを壊れていると報告します。ヘルパーの修正前にまさにそれが起きていました。 -
避けること:
datetime.utcnow()は使わないでください。タイムゾーン情報のない日時を返し、3.12 以降は非推奨です。代わりにdatetime.now(UTC)を使います。この問題を回避するために浮動小数点のタイムスタンプを保存するのも避けてください。値そのものは UTC のタイムゾーン情報付き日時であり、そのエンコードではありません。ruff のDTZルールセットで自動検出できるのは、タイムゾーン情報のない日時を構築する問題だけで、同じゾーン内の演算に関する問題は検出できません。 -
モデルのデフォルト:変更します。 モデルは単独では
astimezone/ZoneInfoを正しく使用しますが、境界での正規化をデフォルトにしていません。また、切り替わり時だけ誤りとなる同一ゾーン内の演算を、確実に記述してしまいます。これはレビューでは見つからず、DST の境界に当たらないすべてのテストでも見つかりません。
設定とシークレットの管理
- スニペット:
references/snippets/settings.py - デフォルト:
pydantic-settings。本番環境に安全なデフォルト値を設定したベースのSettingsクラスを用意し、本番以外の環境ごとにサブクラスを1つずつ作成して、異なる設定だけを上書きします。環境変数ENVIRONMENTに基づくセレクターをパッケージの__init__.pyの先頭で設定し、モジュールレベルの名前に一度だけ代入します。全体を通してfrozen=Trueを指定します。 - 理由:これは、以下のモジュールシングルトンのパターンを設定に適用したものです。先行構築により、インポート時にすばやく失敗を検出できます。また、通常の(シングルトンではない)クラスにすることで、共有グローバル状態に悩まされることなく、テストで分離されたインスタンスを自由に構築できます。
- 次の段階:
dynaconf— リポジトリが実際に複数環境へのデプロイマトリックス(Vault/Redisを利用した動的なソース、Python以外の運用担当者による設定の手動編集)を必要とするようになった場合に限ります。 - 避けること:実際の FastAPI アプリ以外で、FastAPI の
@lru_cacheでラップした設定ファクトリーを使うこと。その理由(リクエスト間で.envの読み込みを繰り返すコストを償却すること)は、CLI ツールや MCP サーバーには当てはまりません。また、テスト用のオーバーライド機構は FastAPI の依存性注入に特化しており、ここでは役立たないにもかかわらず、インポート時の速やかな失敗検出を犠牲にします。詳しい理由は根拠を説明するドキュメントを参照してください。 - モデルのデフォルト:オーバーライドが必要です。 FastAPI の例で集中的に学習したモデルは、このパターンではなく
@lru_cacheファクトリーのパターンをデフォルトとして出力します。ベースクラス+サブクラス+環境セレクターによる先行構築の形式は、意図して選んだ慣用パターンであり、自然に導かれるものではありません。
早期リターン、ガード節、フェイルファスト、EAFP
- スニペット:
references/snippets/guard-clauses.py - ルール:ガード節は、通常の経路が1つで、例外的な早期終了がまれな、非対称なケースに使います。対等なビジネス上の分岐を分けるためには使わず、通常の
if/elseを使います。ガード節では 呼び出し元との契約(引数の型や範囲)を検証し、EAFP では Python がすでに明確なエラーを出す 実行時 操作(辞書/属性の検索、I/O、ネットワーク)を処理します。 - フェイルファスト:
assertは内部の「起こるはずがない」ことを確かめるためだけに使います(python -Oではコンパイル時に除去されます)。入力検証には決して使わないでください。不正な入力や外部状態によって発生し得るものは、必ず実際の例外をraiseします。失敗時にNone/センチネル/(success, result)を決して返さないでください。偽と評価される値では、「空」と「失敗」を呼び出し元が区別できなくなります。 - 例外階層:
references/snippets/exceptions.py— 少なくともパッケージごとにルート例外を1つ設けます。呼び出し元が例外を区別する必要が生じた場合に限り、より深い階層を追加します(requests/click自体の構造に合わせて、2~3階層とします)。 - モデルのデフォルト:おおむね一致します。 早期リターンのスタイルと、実行時の検索に EAFP を使う方法は、すでにデフォルトの挙動に近いものです。真に補足が必要なのは、ガード節と対等な分岐の違い、および失敗時に
Noneを決して返さないというルールで、モデルが確実に自分で適用するとは限りません。
モジュール性、テスト容易性、DRY、可読性、カプセル化
- デフォルト:早すぎる抽象化より、重複を選ぶ傾向にします(Fowler の「三度目の法則」:1回目は気軽に重複させ、2回目で気になり始め、3回目でリファクタリングする)。誤った抽象化は重複コードよりエージェントが安全に変更しにくく、決して容易にはなりません。
- アーキテクチャ:Functional Core, Imperative Shell は、CLI/データパイプライン型のコードによく適合します。一方、仕事のすべてが I/O のオーケストレーション であるコード(MCP サーバー)には適しにくいため、その場合は Michael Feathers の「seams」という語彙を使います。
- カプセル化:Python に真のプライバシー機能はありません(PEP 8 によれば、単一のアンダースコアは「弱い目印」にすぎません)。内部ヘルパーモジュールはデフォルトで公開状態にします。
__all__とアンダースコアによる規律は、パッケージの公開対象(MCP ツール定義、CLI エントリーポイント)に限って適用します。 - モデルのデフォルト:積極的なオーバーライドが必要です。 モデルは「整理して」と依頼された場合や、機能を実装するだけの場合でも、目に入るとすぐ共有ヘルパーや抽象化を抽出する傾向があります。ここで説明する内容は、少し注意を促す程度ではなく、明示しない場合に最も強く抵抗することになりやすいセクションです。
シングルトンとしてのモジュールと遅延読み込みプロパティ
- スニペット:
references/snippets/settings.py(同じパターンを設定以外にも一般化) - デフォルト:通常のクラスをモジュールレベルで一度だけインスタンス化します(
globals.pyのパターン)。これは標準ライブラリが推奨する方式(「The Global Object Pattern」)であり、GoF のシングルトンではないため、テストでは分離された別のインスタンスを自由に構築できます。遅延読み込みフィールドには@property/cached_propertyを使いますが、ゲッターが冪等で副作用のない場合に限ります。それ以外の場合は、呼び出し箇所でコストを示せないプロパティではなく、明示的な.load()メソッドを使います。 - 留意すべき注意点:
cached_propertyのスレッドセーフ性の保証(並行する初回アクセスで厳密に一度だけ実行)は、Python 3.12 で削除されました。これはゲッターが冪等でない場合、単なるパフォーマンス上の変更ではなく、正しさに関わる変更です。テストのmonkeypatch.setattrは、from config import Xで取り込んだ名前ではなく、モジュール属性そのもの(monkeypatch.setattr(config_module, "X", ...))を対象にする必要があります。 - モデルのデフォルト:上書きされます。 モデルは、モジュールレベルに単独のインスタンスを置く前に、依存性注入、クラスベースのシングルトン、または呼び出しごとのインスタンス化を選びます。このパターンはこのプロジェクト群に固有のもので、一般的なデフォルトではありません。
ステートレス性と不変性
- デフォルト:境界を越えるデータ/値オブジェクト(関数の引数、MCP のペイロード、設定、レコード)には
frozen=Trueを指定します。これは上記のデータモデリング表と同じデフォルトであり、別個の判断ではありません。通常のローカルな変更(ループ内の累積処理、返却前の結果構築)は、慣例どおり変更可能なままにします。変更を避けるために、Python 本来のイディオムを回避する必要はありません。 - 正当な状態保持の例外は、説明して片付けるべきエッジケースではありません。キャッシュ、接続プール、レートリミッターなどが該当します。状態は排除するのではなく、明示的にスコープを定め、(並行処理がある場合は)保護します。
- 不変性の仕組み全般に共通する注意点が一つあります。コンテナーを凍結しても凍結されるのはコンテナーだけで、中身は凍結されません(
listフィールドを持つ凍結済みデータクラスでもobj.items.append(x)は問題なく実行できます)。Final/ClassVarは実行時の強制が一切ありません。実際に動作する型チェッカーがあって初めて効力を持ちます(scaffolding plan の basedpyright 設定を参照)。 - モデルのデフォルト:上書きされます。 モデルはデフォルトで
frozen=Trueを選びません。デフォルトで変更可能なのは Python 自体の言語仕様に沿っているため、これは意図的なオプトインであり、モデルが単独で選ぶことはありません。
コマンドラインインターフェース
- デフォルト:サブコマンド、オプション、または
[project.scripts]エントリーを持つものには Typer を使います。型注釈にメタデータを含める方法は、このスキルがすでにデータモデリングに適用しているルールと同じです。メタデータはデフォルト値の右辺ではなく、型注釈に付与します。 argparseは、本当に標準ライブラリのみという制約がある場合に限ります。制約を推測に任せず、明示してください。該当するケースは限られています。インストール手順なしで素のインタープリター上で実行する必要があるスクリプト、環境が存在する前に実行するブートストラップ、依存関係を同梱できない場所に配布するコード(Pyodide のペイロード)です。「小さなスクリプトにすぎない」は該当しません。- 生の Click より Typer を使います。 Typer は Click を基盤にしているため、両者は対立するものではありません。ただし、生の Click を選ぶプロジェクトは、同じ結果を得るのにより多くの定型コードを書くことになります。Click は基盤としてそのまま利用でき、Typer の制限を回避する手段として使うものであり、最初の選択肢ではありません。
- タスクランナーに関するルールではありません。
invはリポジトリ内の作業向けです(invoke-task-conventionsを参照)。一方、CLI はユーザーがインストールするプログラムです。両者は競合せず、新しいコマンドは実際に該当するほうに属します。 - モデルのデフォルト:上書きされます。 何も指定しなければ、モデルは
argparseを使います。標準ライブラリが提供するものであり、学習データの大半にも見られるためです。
型の健全性
# type: ignore/# pyright: ignoreコメントは、特定のエラーコードを対象にしてください。行全体を無条件に無視するのは避けてください。使い捨てのサンプルコードやスニペットも含め、実際のコードにはすべて型を付けてください。型付けされていないスニペットは、パターンマッチングを行うエージェントに、ほかの箇所でも型付けを省略してよいという許可として受け取られます。- モデルのデフォルト:おおむね確認済みです。 スコープを限定した無視コメントは、すでにデフォルトの動作に近いものです。本当に付け加えるべきなのは「使い捨てのスニペットにも型を付ける」ことです。「ちょっとした例だけ」と言われたとき、モデルが取る近道です。
- 値ではなく型をテストする場合:
assert_typeは宣言された型を 厳密に 比較し、関数型にはパラメーター名も含まれます。デコレートされたタスク本体は(c: Context) -> Noneですが、これを表現できるCallable[[Context], None]はないため、assert_typeが一致することはありません。これはアサーションの失敗ではなく、厳密さが機能していると捉えてください。代わりに、型注釈付きの代入を使ってください(body: Callable[[Context], None] = obj.body)。型がAnyに劣化した場合(reportAny経由)や、別の具体的な callable になった場合には、これでも失敗します。このようなアサーションは型チェッカーによって検査され、実行時には何もしない点に注意してください。そのため、ファイル内にその旨を記載してください。pytest が成功しても、これらのアサーションが検査された証拠にはなりません。 - モデルのデフォルト:指示による上書きが必要です。 「この型が想定どおりであることを証明する」ために、まず
assert_typeを使うのは自然な発想です。また、その厳密さは、正確に機能しているにもかかわらず、アサーションの不具合だと誤解されやすいものです。
パッケージのレイアウト:フラットではなく src/
- デフォルト:このファミリーのインストール可能/インポート可能なパッケージはすべて、リポジトリのルートにフラットに配置する
<pkg_name>/ではなく、src/<pkg_name>/を使います。ただし、リポジトリ独自のtasks.py/tasks/invoke エントリーポイントはパッケージではないため、例外なくリポジトリのルートにフラットに配置します。このファミリーのすべてのリポジトリにそのようなエントリーポイントがあり、インストールも他の場所からのインポートもされません。この慣例が対象とするのは、wheel にビルドされてインポートされるものです。リポジトリ独自のツール用スクリプトではありません。(2026-08-19 にpower-user-linux-setup自体で実機確認済みです。そのtasks/には、リポジトリ固有の invoke タスクモジュールが約25個含まれており、配布可能なライブラリではありません。これをsrc/の下に移動すると、invoke 独自のFilesystemLoaderとも衝突します。このローダーは cwd から上方向にtasks.py/tasks/__init__.pyという名前のファイルを探し、インストール済みコピーは参照しません。) - 理由:フラットレイアウトでは、
pytestやインポート処理が、実際にインストールされたパッケージではなく、インストールされていない cwd 内の コピーを誤って解決することがあります(Python は cwd をインポートパスの先頭に置きます)。その結果、サブパッケージの不足やリソースファイルの未収録といった、実際のパッケージング上の不具合が隠され、実際のユーザーがインストールするまで発覚しません。src/を使うと、プロジェクトのルート自体をインポートできなくなるため、テスト実行も編集可能インストールも、実際のインストールと同じ経路を通る必要があります。PyPA/Hynek Schlawack による引用を含む詳細は、references/rationale.mdの §14 を参照してください。 - 次の段階へ進む基準:なし。これはデフォルトであり、段階を上げるための経路ではありません。他の場所にインストール/インポートする意図のない純粋なスクリプト(単発のノートブックや
tasks.py自体)は、この慣例の対象外であり、例外ではありません。 - モデルのデフォルト:指示による上書きが必要です。 フラットレイアウト(パッケージディレクトリをリポジトリのルートに直接置く形式)は、手早く作るチュートリアルコードの大半で、また指示がない場合の大半のモデルで、デフォルトとして選ばれます。
src/は意図的に選択する形式です。
非同期処理と並行実行
- スニペット:
references/snippets/async-fanout.py - デフォルト:MCP ツール関数には
async defではなく通常のdefを使います。FastMCP は同期ツールをすでにスレッドプール(anyio.to_thread.run_sync)にディスパッチするため、追加の作業なしで実際の並行実行が可能です。また、このファミリーの既存ツールコードは 100% 同期処理です。本格的なファンアウト処理(複数の MCP クライアントを同時に呼び出すオーケストレーター)では、asyncio.gather()ではなくasyncio.TaskGroupをデフォルトにしてください。gather()は捕捉されなかった例外が発生しても兄弟タスクを実行したままにしますが、TaskGroupはその時点で兄弟タスクをキャンセルします。gather(return_exceptions=True)のような部分的なエラー許容が必要な場合は、単独のgatherではなく、TaskGroupの内部で子タスクごとにtry/exceptを使ってください。 - 理由:
gather()では、失敗後も兄弟タスクが実行され続けます。意図的にリクエストを抑制し、セッションを保持する「礼儀正しい」クライアントでは、これは実際のリソースリークにつながります。あるサイトでエラーが発生しても、他のサイトのレート制限対象の呼び出しやブラウザーセッションが、監視されないまま実行され続けるべきではありません。 - 次の段階:ファンアウト層で同時に実行中の呼び出し数を制限するには
asyncio.Semaphoreを使います。これはサイトごとのレートリミッターとは異なる軸(同時実行数とリクエスト間隔)なので、どちらか一方を選ぶのではなく、両方を組み合わせてください。async defのコード内で同期呼び出しを避けられない場合は、標準ライブラリのasyncio.to_thread()を使用できます(FastMCP が内部で同期ツールをディスパッチする際にすでに使っているのと同じ仕組みです)。 - 禁止事項:ブロッキング呼び出し(
time.sleep()、threading.Lockを使ったスロットリング、同期 HTTP 呼び出し)をasync defのコード内で直接実行しないでください。呼び出し元のタスクだけでなく、イベントループ全体が処理の間フリーズします。 - モデルのデフォルト:部分的。モデルは非同期構文を正しく書けますが、
gatherよりTaskGroupを選ぶことや、FastMCP が定める同期/非同期のツール関数の境界は、フレームワークの実際のディスパッチ方法を伝えなければモデルが推測できる内容ではありません。
非同期 API としての AnyIO
非同期処理の範囲が単なるファンアウトを超えるプロジェクト(ストア、ボット、サービスなど)では、asyncio を直接使うのではなく AnyIO を使い、asyncio バックエンドで実行します。この4つのポイントはすべてソースで確認済みです。
- 理由は安全性と構造であり、移植性ではありません。 タスクグループなら、破棄された
create_taskのハンドルのようにタスクが孤立することはありません。キャンセルは呼び出し箇所ごとに再実装するのではなく、キャンセルスコープを通じて伝播します。また、キャンセル時の動作がそれぞれ異なるgather/wait/as_completed/wait_forのどれを選ぶかという問題も、ひとそろいのプリミティブに置き換えられます。移植性の議論は最も弱く、前面に出すべきではありません。標準ライブラリのasyncio.TaskGroupでも、前述のファンアウトには同等の構造をほぼ提供できるため、AnyIO を選ぶ理由はそれ以外の部分にあります。 - 使うべきバックエンドは asyncio です。trio は別の選択肢ですが、ほとんどのプロジェクトでは採用しないほうがよいでしょう。 エコシステムは asyncio を中心に構成されています。SQLAlchemy の非同期サポートは asyncio 固有です(その
util/concurrency.pyはasyncio.Lock/asyncio.Runnerと greenlet を使っており、ライブラリ内のどこにも「trio」は登場しません)。asyncpgと Starlette も同様です。AnyIO の API 全体を asyncio 上で利用できるため、これによるコストはありません。 - pytest プラグインは、インストール済みのバックエンドのみを対象にパラメーター化します。
anyio_backendフィクスチャはget_available_backends()を使用し、実際にインポートできるバックエンドを返します(AnyIO ≥ 4.12)。そのため、trio がインストールされていなければ、各テストは asyncio 上で1回だけ実行されます。フィクスチャを固定するのは、意図を1行で示すだけであり、修正ではありません。ドキュメントの「サポート対象のすべてのバックエンド」という表現は「存在するすべてのバックエンド」とも読めるため、この両方を把握しておくとよいでしょう。 pytest-asyncioを併用してインストールしないでください。 AnyIO のドキュメントでは、自動モードでの競合について明記されています。また、AnyIO のプラグインは AnyIO に同梱されているため、追加でインストールするものはありません。- 同期エントリーポイント(CLI)から非同期レイヤーを呼び出す場合は、コマンドごとに
asyncio.runの処理を手書きするよりも、まさにその用途向けの小さなラッパーであるasyncerのrunnifyを使います。 - モデルのデフォルト:オーバーライドします。 モデルはデフォルトで素の
asyncioを選び、AnyIO を trio 互換ライブラリとして扱いますが、これは実際に AnyIO を採用する理由とは逆です。
HTTP クライアント、セッション、タイムアウト、リトライ/バックオフ
- スニペット:
references/snippets/http-retry.py - デフォルト:新しい通常の HTTP フェッチ処理には
httpxを使います(requests 互換の API、より安全なデフォルトのタイムアウト、非同期処理に対応)。フェッチャーのインスタンスごとにClient/Sessionを 1 つ作成し、一度構築したらそのインスタンスの存続期間中は再利用します。リクエストごとに新しい接続を作ることは避けてください。クライアントの種類を問わず、リクエストごとにサイトに合わせた明示的なタイムアウトを設定します。リトライ/バックオフにはtenacityを使います。ジッター付きの指数バックオフとし、リトライ可能なステータス(429/502/503/504)および一時的なネットワーク例外だけを対象にします。404/403 のような「実際の回答」である通常の 4xx は対象外です。レスポンスにRetry-Afterヘッダーがある場合は、計算した遅延よりも優先して従います。 - 理由:接続の再利用と、
Retry-Afterを尊重した控えめなリトライは、単なるパフォーマンス上の工夫ではありません。これは「礼儀正しく」、レート制限されたクライアントの本来の役目に直結します。積極的にリトライしたり、リクエストごとに新しい接続を開いたりするのは、その役目に反します。 - 切り替え先:ありません。これはエスカレーション先ではなく、デフォルトです。既存の動作するコードが
requestsを使っている場合、具体的な必要性がないのに httpx に置き換える必要はありません(モジュール性における「重複を許容する」方針を参照)。 - 避けること:HTTP クライアント内蔵のリトライが HTTP レベルの状況にも対応すると考えないでください。httpx/requests の内蔵リトライが対象とするのは接続レベルのみです。503 や
Retry-Afterを伴う 429 には、実際のリトライライブラリ、または独自のロジックを追加する必要があります。レートリミッターのスロットル呼び出しがリトライ対象の関数を囲む場合、リトライはスロットル呼び出しの外側ではなく内側で行う必要があります。そうしないと、リトライのバックオフ中の待機によってサイト全体のレート制限ロックが保持されてしまいます。 - モデルのデフォルト:おおむね確認できていますが、実質的な不足が 1 つあります。 モデルはタイムアウトの設定とセッション/クライアントの再利用をデフォルトとしていますが、ジッター付き(単純な線形/指数バックオフではなく)のバックオフや
Retry-Afterの尊重は、確実にはデフォルトとしていません。リトライ対象を絞る具体的な方法が、ここで本当に追加される内容です。
詳細な根拠
references/rationale.md を参照してください。確認した情報源、トピックごとに検討して却下した選択肢、上記の各分岐/エスカレーション経路の理由を含む、引用と根拠の全記録です。各セクションでは元の番号を維持しているため、テストおよび MCP のセクションが 2026-08-31 に個別のスキルへ移動された箇所では番号が欠けています。番号は連番ではなく、安定した参照番号です。§1–8 はもともと plans/2026-08-15-python-conventions.md で調査・執筆され、その計画が idea からこの完成済みスキルに昇格した際にここへ移されました(計画ファイルはその後廃止されています。必要であれば git 履歴を参照してください)。§12 以降(非同期/並行処理、HTTP/リトライ、src/ レイアウト)は、その昇格後に別の計画ファイルの段階を設けず、このスキルに対して直接調査されました。
スタータースニペット
references/snippets/ には、上記の各パターンについて、ruff 準拠の自己完結した実用的な Python ファイルが 1 つずつあります。チュートリアルを応用するのではなく、そのままコピーして貼り付けられます。各トピックの上にある「スニペット:」の行から、対応するファイルへ直接リンクしています。
このスキルの編集
このファイルはインストール時に ~/.agents/skills/python-conventions へコピーされ、シンボリックリンクは作成されません。そのため、インストール先のコピーを編集すると、ローカルでの乖離が生じます。これはまさにこのスキルが防ごうとしていることであり、他のマシンには反映されません。このファイルをインストールしたリポジトリでソースを編集してプッシュし、skills add <that source> --global --skill python-conventions を再実行して、すべてのプロジェクトのコピーを更新してください。自分のフォークではなく他の人のリポジトリからインストールした場合、ソースはその人のものです。代わりに、そこで issue または pull request を作成してください。