45395 - シコウサクゴ -

裸のexceptを全件スキャンして段階移行する:Pythonレガシーコード整理の実装

2026-04-17
AI駆動開発
AI駆動開発
Python
リファクタリング
例外処理
ruff
Last updated:2026-04-21
8 Minutes
1512 Words

本番稼働しているPythonコードベースで、裸のexcept:(例外クラスを指定しない)やexcept Exception:(広すぎる捕捉)が散在していました。全部一気に書き換えるとリグレッションのリスクが高すぎます。

そこで、正規表現で全件スキャン → スコープ別に分類 → 優先度順に段階移行というワークフローで整理した記録です。

問題:裸のexceptが生む2つの害

害1: バグが握りつぶされる

1
def fetch_data(url: str) -> dict:
2
try:
3
resp = requests.get(url, timeout=5)
4
return resp.json()
5
except:
6
return {} # どんなエラーでも空辞書を返す

このコードは、ネットワークエラーもJSONパースエラーもプログラムのバグ(TypeError、AttributeError)もすべて吸収します。呼び出し側は「空辞書が返った=データがなかった」と解釈し、実はバグだったと気づかないまま運用されます。

害2: KeyboardInterruptとSystemExitも止める

1
try:
2
long_running_task()
3
except:
4
pass # Ctrl+Cも効かない

裸のexcept:BaseExceptionを捕捉します。KeyboardInterruptSystemExitまで握りつぶされ、ジョブが止められなくなります。

ステップ1: 全件スキャンで棚卸しする

スキャンスクリプト

1
import re
2
from pathlib import Path
3
4
PATTERNS = {
5
"bare_except": re.compile(r"^\s*except\s*:\s*$"),
6
"except_exception": re.compile(r"^\s*except\s+Exception\s*(?:as\s+\w+)?\s*:\s*$"),
7
"except_baseexception": re.compile(r"^\s*except\s+BaseException\s*(?:as\s+\w+)?\s*:\s*$"),
8
}
9
10
def scan_repo(root: Path) -> list[dict]:
11
hits = []
12
for py_file in root.rglob("*.py"):
13
if "venv" in py_file.parts or ".git" in py_file.parts:
14
continue
15
try:
14 collapsed lines
16
lines = py_file.read_text(encoding="utf-8").splitlines()
17
except UnicodeDecodeError:
18
continue
19
for lineno, line in enumerate(lines, 1):
20
for pattern_name, pattern in PATTERNS.items():
21
if pattern.match(line):
22
hits.append({
23
"file": str(py_file.relative_to(root)),
24
"line": lineno,
25
"pattern": pattern_name,
26
"snippet": line.rstrip(),
27
})
28
break
29
return hits

出力例:

1
src/services/auth.py:47 bare_except except:
2
src/services/auth.py:112 except_exception except Exception as e:
3
src/clients/api.py:23 bare_except except:

スキャン結果をCSVで棚卸し

1
import csv
2
3
def write_csv(hits: list[dict], out_path: Path):
4
with out_path.open("w", newline="") as f:
5
writer = csv.DictWriter(f, fieldnames=["file", "line", "pattern", "snippet", "scope", "priority"])
6
writer.writeheader()
7
for hit in hits:
8
hit["scope"] = classify_scope(hit["file"])
9
hit["priority"] = priority_of(hit["pattern"], hit["scope"])
10
writer.writerow(hit)

CSVにすることで、PdMやレビュワーと共有しやすくなります。

ステップ2: スコープ別に分類する

すべてのexcept:が同じ危険度ではありません。どの層のコードかで対処の優先度を決めます。

スコープ危険度対処優先度
エントリーポイントmain(), ジョブのトップレベル中(意図的な場合もある)
外部API呼び出しHTTPクライアント、DBクエリ
内部関数ビジネスロジック内部高(バグ握りつぶしの温床)最高
テストコードtests/ 以下
クリーンアップfinallyの代替

スコープ判定は、パスのパターンマッチで自動化できます。

1
def classify_scope(file_path: str) -> str:
2
if file_path.startswith("tests/"):
3
return "test"
4
if file_path.endswith("main.py") or "entrypoint" in file_path:
5
return "entrypoint"
6
if "clients" in file_path or "api" in file_path:
7
return "external_io"
8
return "internal"

ステップ3: 段階的に移行する

優先度の決定

1
PRIORITY = {
2
("bare_except", "internal"): 1, # 最優先:バグ握りつぶしの温床
3
("bare_except", "external_io"): 2, # 次点:特定例外に絞れる
4
("except_exception", "internal"): 3, # 3番目:広すぎる捕捉
5
("bare_except", "entrypoint"): 4, # 意図的な場合もあるので要レビュー
6
("except_exception", "external_io"): 5,
7
("bare_except", "test"): 99, # テストは後回し
8
}

置き換えパターン

BeforeAfter
except:except SpecificError: または except Exception: + ログ
except Exception: passexcept Exception as e: logger.exception(...); raise
except: return Noneexcept SpecificError: return None
except: log.error(...)except Exception as e: log.exception(...)

log.error()ではなくlog.exception()を使うのがポイントです。exception()はスタックトレースを自動で出力します。

1
# Before
2
try:
3
data = fetch()
4
except:
5
logger.error("fetch失敗")
6
return None
7
8
# After
9
try:
10
data = fetch()
11
except (requests.RequestException, json.JSONDecodeError) as e:
12
logger.exception("fetch失敗")
13
return None

1PRあたりの粒度

ファイル単位・スコープ単位でPRを分けます。1PRで50箇所変更するとレビューできません。

1
PR #1: src/services/auth.py の bare_except 6箇所(internalスコープ)
2
PR #2: src/services/payment.py の bare_except 4箇所
3
PR #3: src/clients/*.py の bare_except(external_ioスコープ)

各PRで単体テストを動かし、振る舞いが変わっていないことを確認します。

ステップ4: CIで新規流入を止める

せっかく整理しても、新しいexcept:が追加されたら意味がありません。CIで検出します。

flake8 + プラグイン

.flake8
1
[flake8]
2
select = E722 # bare except

E722は標準で裸のexcept:を検出します。

ruffなら一発

pyproject.toml
1
[tool.ruff.lint]
2
select = ["E722", "BLE001"] # BLE001: blind-except (except Exception)

BLE001except Exception:も検出します。段階移行中は# noqa: BLE001で一時除外しつつ、CIで新規追加を止められます。

pre-commit hookで早期検出

.pre-commit-config.yaml
1
repos:
2
- repo: https://github.com/astral-sh/ruff-pre-commit
3
rev: v0.5.0
4
hooks:
5
- id: ruff
6
args: [--select, E722,BLE001]

ステップ5: AIに置き換えを任せる

大量の箇所をAIに書き換えてもらう場合、スコープ情報を必ず渡すのがコツです。

1
以下のexcept:を改善してください。
2
ファイル: src/clients/payment_api.py (external_io)
3
行47: except:
4
文脈: HTTP POSTリクエストの結果をJSONパースする処理
5
6
方針:
7
- external_ioなので、具体的な例外(requests.RequestException, json.JSONDecodeError)に絞る
8
- 捕捉後は logger.exception() でログを残す
9
- 戻り値は None(既存の挙動を維持)

スコープと方針をプロンプトに含めないと、AIは「とりあえずException」と書いてしまいがちです。

実践で学んだこと

1. 「意図的な広い捕捉」は明示コメントで残す

1
try:
2
plugin.run()
3
except Exception: # noqa: BLE001 - プラグイン実行中の任意例外を握る(システムは継続させる)
4
logger.exception("プラグイン %s が失敗", plugin.name)

エントリーポイントやプラグイン機構では「何が起きても継続させる」のが正しい設計の場合があります。その意図をコメントで残し、# noqaで明示的に除外します。

2. スキャン結果を月次で追跡する

1
2026-01: bare_except = 142
2
2026-02: bare_except = 98
3
2026-03: bare_except = 54
4
2026-04: bare_except = 12

減少を可視化するとモチベーションが続きます。逆に増えていたら、CIチェックが漏れている証拠です。

3. テストは一番最後でよい

テストコード内のexcept:は、バグを握りつぶしても影響範囲が限定的です。リソース配分を間違えないようにします。

まとめ

ステップ所要時間(5万行想定)
全件スキャン+CSV化30分
スコープ分類+優先度付け1時間
段階的にPR化1〜2ヶ月
CI/pre-commit設定30分

裸のexcept:の一括置換はしてはいけません。挙動が変わって本番事故につながります。「スキャンして棚卸し→スコープ分類→優先度順にPR→CIで新規を止める」の4段階で、計画的に減らしていきます。

そして、AIに置き換えを任せるなら、スコープと方針を必ずプロンプトに含めること。これだけで生成コードの質が大きく変わります。

Article title:裸のexceptを全件スキャンして段階移行する:Pythonレガシーコード整理の実装
Article author:45395
Release time:2026-04-17

記事へのご質問・ご感想をお聞かせください

フィードバックを送る