Python 3.12 まで動いていたコードを 3.13 で実行すると、次のエラーで落ちることがあります。
Traceback (most recent call last):
File "app.py", line 1, in <module>
import cgi
ModuleNotFoundError: No module named 'cgi'
cgi は標準ライブラリだったモジュールです。PEP 594(通称 dead batteries)によって、Python 3.13 が標準ライブラリから削除しました。
同じ理由で消えたモジュールは他にもあります。エラーの名前が違うだけで、原因も対処も同じです。
No module named 'cgi'/No module named 'cgitb'No module named 'crypt'No module named 'telnetlib'No module named 'imghdr'No module named 'pipes'No module named 'nntplib'No module named 'audioop'(pydubなどが使っています)No module named 'mailcap'/'aifc'/'chunk'/'sndhdr'/'sunau'/'uu'/'xdrlib'/'ossaudiodev'/'spwd'
PEP 594 が 3.13 で削除したモジュールは 19 個あります。上に無い名前でも、標準ライブラリのモジュールなら PEP 594 の一覧を確認してください。
pip install cgi は存在しません
最初に、いちばん多い空振りを潰しておきます。
$ pip install cgi
ERROR: Could not find a version that satisfies the requirement cgi (from versions: none)
ERROR: No matching distribution found for cgi
モジュール名と同じ名前の pip パッケージはありません。 バックポートは別の名前で公開されていて、しかも接頭辞が揃っていません。
| モジュール | インストールするパッケージ |
|---|---|
cgi | legacy-cgi(cgitb も入ります)/standard-cgi |
crypt | standard-crypt |
telnetlib | standard-telnetlib |
imghdr | standard-imghdr |
pipes | standard-pipes |
nntplib | standard-nntplib |
audioop | audioop-lts |
接頭辞は揃っていません。 多くは standard- ですが、audioop の受け皿は audioop-lts で、standard- でも legacy- でもありません(standard-audioop は存在しません)。crypt も legacy-crypt は存在せず standard-crypt です。名前を当てずっぽうで打つと外します。 PyPI で確認してから入れてください。
ただし、多くの場合はバックポートを入れずに直せます。
まず、import cgi しているのは誰かを見る
トレースバックの File の行を読みます。自分のコードか、依存ライブラリの中かで、やることが変わります。
File "/app/main.py", line 1, in <module>
import oldlib
File "/usr/local/lib/python3.13/site-packages/oldlib/parser.py", line 3, in <module>
import cgi
ModuleNotFoundError: No module named 'cgi'
import cgi の 1 つ上の行が site-packages の中なら、cgi を呼んでいるのは依存ライブラリです。自分のファイルなら、書いたのはあなたです。
site-packagesの中で import されている → 依存ライブラリが原因です。まずそのライブラリを上げてください。 3.13 に対応した版が出ていることが多く、それがいちばん確実です。上げられない事情があるときだけ、上のバックポートを入れます。- 自分のコードで import している → 書き直せます。次の節へ。
自分のコードの cgi を書き換える
cgi の用途で圧倒的に多いのは、parse_header() で Content-Type を解釈することです。これは email.message で同じことができます。追加のインストールは要りません。
# Before(Python 3.13 で落ちる)
import cgi
header = "multipart/form-data; boundary=----abc123; charset=utf-8"
mimetype, params = cgi.parse_header(header)
print(mimetype) # multipart/form-data
print(params["boundary"]) # ----abc123
# After(標準ライブラリのまま。3.13 で動く)
from email.message import EmailMessage
header = "multipart/form-data; boundary=----abc123; charset=utf-8"
msg = EmailMessage()
msg["content-type"] = header
mimetype = msg.get_content_type()
params = dict(msg["content-type"].params)
print(mimetype) # multipart/form-data
print(params["boundary"]) # ----abc123
mimetype・boundary・charset のいずれも、cgi.parse_header() と同じ値になります。
ただし、ヘッダの種類に合わせて入れる場所を変えてください
cgi.parse_header() は、渡された文字列がどのヘッダのものかを知りません。値; key=value; ... という形を機械的に分解するだけの、種類を問わないパーサです。
email.message は違います。ヘッダ名で意味づけをします。 だから、Content-Disposition の値を content-type に入れると、例外も警告も出さずに間違った値が返ります。
# アップロードされたファイル名を取りたい(Content-Disposition)
header = 'attachment; filename="report.pdf"'
msg = EmailMessage()
msg["content-type"] = header # 種類が違う
print(msg.get_content_type()) # text/plain ('attachment' ではない)
attachment は MIME タイプとして解釈できないので、email.message は既定値の text/plain を返します。エラーにならないぶん、そのまま通ります。
正しくは、そのヘッダ名のフィールドに入れて、対応するアクセサで読みます。
header = 'attachment; filename="report.pdf"'
msg = EmailMessage()
msg["content-disposition"] = header
disposition = msg.get_content_disposition() # attachment
params = dict(msg["content-disposition"].params) # {'filename': 'report.pdf'}
これで cgi.parse_header() と同じ値になります。Content-Type なら content-type と get_content_type()、Content-Disposition なら content-disposition と get_content_disposition() です。アップロードのファイル名を取り出す用途は Content-Disposition なので、get_content_type() を使い回さないでください。
RFC 2231 のパラメータは、返り方が変わります
もう 1 つ、値が変わる箇所があります。filename*= や charset*= のような RFC 2231 のエンコード済みパラメータです。
header = "text/plain; charset*=utf-8''rfc2231%20encoded"
# cgi.parse_header
# {'charset*': "utf-8''rfc2231%20encoded"} キーは charset*、値は生のまま
# email.message
# {'charset': 'rfc2231 encoded'} キーは charset、値はデコード済み
キーも値も違います。 email.message のほうが RFC どおりにデコードしてくれるので結果としては望ましいのですが、params["charset*"] や params["filename*"] を直接読んでいるコードは、キーが存在せず KeyError になります。星付きのキーを触っている箇所は、書き換えのときに確認してください。
cgi.FieldStorage を使っている場合
cgi.FieldStorage(multipart のリクエスト本体そのものを解析する機能)は、email.message では置き換えられません。 そちらを使っているなら、Web フレームワーク側のリクエスト解析(Django や Flask が持っています)か、multipart などの専用パッケージに寄せることになります。email.message で置き換えられるのは、ヘッダの解釈までです。
「警告なんて出ていなかった」の正体
このエラーで理不尽に感じるのは、予告が無かったように見えることです。実際には予告されていました。
Python 3.11 と 3.12 で import cgi すると、こう出ます。
DeprecationWarning: 'cgi' is deprecated and slated for removal in Python 3.13
削除される版まで名指ししています。 それでも見えなかったのは、Python の既定の警告フィルタが DeprecationWarning を隠すからです。既定では、あなた自身のトップレベルのスクリプトから出たものしか表示されません。
- 自分のコードが
import cgiしている → 3.12 で警告が見える。 - 依存ライブラリの中が
import cgiしている → 3.12 では警告が 1 行も出ない。 何事もなく動きます。
そして 3.13 に上げた瞬間、いきなり ModuleNotFoundError になります。 警告は出ていましたが、既定のフィルタに隠されて画面に届いていませんでした。
3.12 のうちに、まとめて見つける
上げてから 1 つずつ踏むより、3.12 の環境で隠れている予告をあぶり出すほうが安全です。警告フィルタを明示すれば、依存ライブラリの中のものまで出ます。見つかるのは、そのコマンドで実際に import される経路の警告だけなので、テストが通らないコードパスは漏れます。
# 隠れている DeprecationWarning を全部表示する
python -W always::DeprecationWarning main.py
/usr/local/lib/python3.12/site-packages/oldlib/parser.py:3: DeprecationWarning:
'cgi' is deprecated and slated for removal in Python 3.13
import cgi
CI に組み込むなら、警告をエラーに昇格させて落とします。
python -W error::DeprecationWarning -m pytest
こうすると終了コードが 0 以外になり、トレースバックが import の連鎖(main.py → 依存ライブラリ → cgi)を示します。3.13 に上げる前に、テストが通る範囲で壊れる箇所が見つかります。
どのモジュールがどの版で消えたか
「3.13 で消えた」で一括りにすると外します。削除は 3.12 と 3.13 の 2 回に分かれています。
| モジュール | 3.12 | 3.13 | 根拠 |
|---|---|---|---|
distutils | 消えている | 消えている | PEP 632 |
asynchat / asyncore / smtpd | 消えている | 消えている | PEP 594 |
cgi / crypt / telnetlib / audioop / ほか | 残っている(警告あり) | 消えている | PEP 594 |
distutils は PEP 632 という別の PEP による削除で、PEP 594 とは根拠が違います。一方 PEP 594 自身も 3.12 で 3 つ削除しています(asynchat / asyncore / smtpd)。
「3.12 に上げたときは cgi で何も起きなかった」のは正常です。3.12 の番と 3.13 の番が違うだけで、同じ PEP 594 の続きです。
切り分け
pip install cgiがNo matching distribution foundになる → 正常です。そのパッケージは存在しません。legacy-cgiを入れるか、email.messageに書き換えてください。- バックポートを入れたのに直らない → パッケージ名を取り違えている可能性があります。
cryptはlegacy-cryptではなくstandard-crypt、audioopはstandard-audioopではなくaudioop-ltsです。 ModuleNotFoundError: No module named 'distutils'→ 同じ「標準ライブラリから消えた」形ですが、消えたのは 3.12 で、根拠の PEP も対処も違います。No module named ‘distutils’ の直し方 を参照してください。- 手元では動くのに CI やコンテナでだけ落ちる → Python の版が違います。
python -Vを両方で確認してください。3.13 のイメージに上げた直後に出るのが典型です。 - 書き換えたら、ファイル名は取れるのに MIME タイプが
text/plainになった →Content-Dispositionの値をcontent-typeフィールドに入れています。content-dispositionに入れてget_content_disposition()で読んでください。例外は出ません。 params["filename*"]がKeyErrorになった →email.messageは RFC 2231 のパラメータをデコードして、星の無いキー(filename)で返します。星付きのキーは存在しません。cgiではなくcgitb→ こちらも同時に削除されています。cgitbはlegacy-cgiに含まれます。- エラーが
ImportError: cannot import name ...→ モジュール自体はあるので、これは削除ではなく、そのモジュールの中身が変わったときの形です。別件です。
検証環境
python:3.13.14-slim、ネットワーク無し- 再現:
cgi.parse_header()でContent-Typeを解釈するスクリプトを実行し、ModuleNotFoundError: No module named 'cgi'が出て終了コード 1 - 修正:同じ処理を
email.message.EmailMessageで書き直すだけで終了コード 0・シグネチャ消滅。バックポートは入れていません(ネットワーク無しで通ります)。mimetype/boundary/charsetの 3 点が再現側と同じ値になることも確認しました - 削除されたモジュールの一覧(
crypt/cgi/telnetlib/imghdr/pipes/nntplibが 3.12 では import でき、3.13 ではできない。distutilsだけ 3.12 で既に消えている)は、python:3.12-slimとpython:3.13-slimでそれぞれ確認しました - バックポートの表(
legacy-cgi/standard-cgi/standard-crypt/standard-telnetlib/standard-imghdr/standard-pipes/standard-nntplib/audioop-lts)は、python:3.13-slimで 1 つずつpip installしてからimportが通ることを確認しました。pip install cgi/legacy-crypt/standard-audioopが存在しないことも確認しています - 削除の版が 3.12 と 3.13 に分かれていること(
asynchat/asyncore/smtpd/distutilsは 3.12、それ以外の PEP 594 対象は 3.13)は、python:3.11-slim/3.12-slim/3.13-slimでimportを回して確認しました - 「3.11 / 3.12 では
DeprecationWarningが出る」「依存ライブラリの中からの import では既定では表示されない」「-W always::DeprecationWarningで見える」「-W error::DeprecationWarningで終了コード 1 になる」も、それぞれ実行して確認しました cgi.parse_header()とemail.messageの食い違い(Content-Dispositionをcontent-typeに入れるとtext/plainが返る/content-dispositionに入れてget_content_disposition()で読めば一致する/RFC 2231 のパラメータはcharset*の生値ではなくcharsetのデコード済みの値になる)は、python:3.12-slimで両者に同じヘッダを渡して 1 件ずつ突き合わせて確認しました
cgi.FieldStorage の置き換え先(Web フレームワークのリクエスト解析や multipart パッケージ)は確認していません。依存ライブラリを上げれば直るかどうかも、ライブラリごとに異なるため確認していません。crypt などバックポート以外の代替も、この記事では確認していません(実測したのは cgi.parse_header の置き換えのみです)。