10月2日に Python 3.15.0rc3 が出ました。3.15 の変更点の一覧を眺めていて、私が手を止めたのは新しい構文ではなく、「encoding を書かない入出力は、OS の設定に関係なく UTF-8 になる」という一行です(PEP 686)。
社内には、小さな Python スクリプトがたくさんあります。チケットの操作、図の書き出し、DB 定義の照合、検証用の使い捨て。そして、そのほとんどは誰かの日本語 Windows の上で動いています。日本語版 Windows の既定の文字コードは cp932 で、3.14 までの Python は encoding を省くとこれに従っていました。つまり 3.15 に上げた日に、同じスクリプトが別の文字コードで読み書きを始めることになります。
正式版が出てから慌てるより、RC のうちに何が変わるかを数えておこう。そう思って、RC3 が出たのを機に私の開発機で試しました。
最初のアプローチ: grep で探す
まず考えたのは、「encoding を書いていない open( を探せばいい」でした。正直なところ、これで十分だと思っていたんですよね。チケット操作や作図の補助、検証用など、社内で使っている自作スクリプト 25 本に対して、こう流しました。
# encoding を指定していない open( を探す(最初のアプローチ)
grep -n "open(" $(cat filelist.txt) | grep -v "encoding" | wc -l
# 実行結果: 11
# 内訳: urllib の urlopen( が 4 行、"rb" のバイナリ読み込みが 5 行、Pillow の Image.open( が 2 行
11 行出て、中身を見たら 11 行とも関係ありませんでした。urlopen( は文字列に open( を含むだけですし、"rb" で開いているものは文字コードと無関係です。
逆に、本当に影響を受ける書き方は 1 つも拾えていませんでした。後で分かったのですが、25 本の中で影響を受けるのは subprocess.run(..., text=True) の 1 か所で、この行には open( という文字がそもそも出てきません。Path.read_text() や write_text() も同じで、文字列検索では見えない書き方が、既定の文字コードに頼る入り口として残っていました。
改善後のアプローチ: 構文木で探す
そこで、文字列ではなく構文木(ast モジュール)で呼び出しを数えることにしました。見る対象は、encoding= を付けずに呼んでいる open()、.open()、read_text() / write_text()、それに text=True 付きの subprocess です。
import ast
import sys
SUBPROCESS_FUNCS = {"run", "Popen", "check_output", "call", "check_call"}
def has_kw(call, name):
return any(k.arg == name for k in call.keywords)
def kw_is_true(call, name):
return any(k.arg == name and getattr(k.value, "value", None) is True for k in call.keywords)
def text_mode(call, pos):
# mode が文字列リテラルで 'b' を含むときだけバイナリとみなす
args = [k.value for k in call.keywords if k.arg == "mode"] or call.args[pos:pos + 1]
mode = getattr(args[0], "value", "r") if args else "r"
return not (isinstance(mode, str) and "b" in mode)
def suspects(path):
tree = ast.parse(open(path, encoding="utf-8-sig").read())
for node in ast.walk(tree):
if not isinstance(node, ast.Call) or has_kw(node, "encoding"):
continue
fn = node.func
if isinstance(fn, ast.Name) and fn.id == "open" and text_mode(node, 1):
yield node.lineno, "open()"
elif isinstance(fn, ast.Attribute):
if fn.attr == "open" and text_mode(node, 0):
yield node.lineno, ".open()"
elif fn.attr in ("read_text", "write_text"):
yield node.lineno, fn.attr + "()"
elif fn.attr in SUBPROCESS_FUNCS and (kw_is_true(node, "text") or kw_is_true(node, "universal_newlines")):
yield node.lineno, "subprocess text=True"
for path in sys.argv[1:]:
for lineno, kind in suspects(path):
print(f"{path}:{lineno}: {kind}")
25 本に流した結果は 3 件でした。うち 2 件は Pillow の Image.open() で、メソッド名だけで判定しているので誤って拾っています。残る 1 件が、外部コマンド(作図ツールの書き出し)を text=True で呼んでいる箇所でした。
このスクリプトには改善の余地があります。型を見ていないので Image.open() や webbrowser.open() も拾いますし、open() を包んだ自作関数の先や、mode を変数で渡している箇所は追えません。ここは好みが分かれるところですが、私は「誤検知は目で落とせる、見逃しは落とせない」と割り切って、拾いすぎる側に倒しています。
手元では何も変わらなかった
対象が 1 か所なら話は早い、と思って 3.15.0rc3 で実際に走らせてみたら、拍子抜けするほど何も起きませんでした。
理由を探して、しばらく悩みました。原因は私の開発機のユーザー環境変数に PYTHONUTF8=1 が入っていたことです。これは 3.14 以前でも UTF-8 モードを有効にする設定で、3.15 の既定と同じ状態です。いつ、なぜ入れたのかは記録が残っていませんでした。今思えば、どこかで cp932 の UnicodeEncodeError を避けるために入れたのだと思います。
つまり私の机の上では、何年も前から 3.15 と同じ挙動で動いていたことになります。差が出るのは、この変数が入っていない場所です。同僚の PC、新しく用意した PC、環境変数を引き継がない形で起動されるジョブ。ここを再現するため、以降の計測ではこの変数を外し、Python 公式の Windows 向け埋め込み版(3.14.8 と 3.15.0rc3)を並べて同じスクリプトを動かしました。OS は Windows 11 の日本語版(コードページ 932)、PowerShell は 5.1、Excel は 16.0 です。
3.14 と 3.15 を並べて測った
| 試したこと | 3.14.8 | 3.15.0rc3 |
|---|---|---|
locale.getpreferredencoding(False) | cp932 | utf-8 |
locale.getencoding() | cp932 | cp932(OS の設定はそのまま) |
sys.getdefaultencoding() | utf-8 | utf-8(どちらも同じなので手掛かりにならない) |
Excel が保存した CSV(cp932)を open() で読む | 読める | UnicodeDecodeError(先頭バイト 0x8c) |
UTF-8 で保存した「請求書の宛名」を open() で読む | 例外なしで「隲区アよ嶌縺ョ螳帛錐」になる | 読める |
自社サイトの記事ファイル 83 本を open() で読む | 83 本とも UnicodeDecodeError | 76 本は正しく読める。7 本は先頭に BOM(U+FEFF)が残る |
open(p, “w”) で書いた CSV を Excel で開く | A1 に「顧客名」 | A1 に「鬘ァ螳「蜷・驥鷹。・」。2 列のはずが 1 列に潰れる |
print(”✓ done”) をファイルへリダイレクト | UnicodeEncodeError で終了コード 1 | UTF-8 で書ける |
PowerShell 5.1 で $s = python -c … と出力を受ける | 「日本語」 | 「譌・譛ャ隱・」 |
subprocess.run(text=True)で cmd の echo を受ける | 「日本語」 | stdout が None。終了コードは 0 |
| 同じく、UTF-8 で出力する node を受ける | stdout が None | 「日本語」 |
表を作ってみて、「3.15 で壊れる」と一言では言えないことがよく分かりました。UTF-8 のファイルを読む、✓ を含む文字列を書き出す、node の出力を受ける。この 3 つは 3.14 のほうが壊れていて、3.15 で直ります。壊れるのは、相手が cp932 の場面です。Excel が保存した CSV、Excel に渡す CSV、cmd や PowerShell 5.1 とのやり取り。日本語版 Windows の業務スクリプトにとって、いちばん出番の多い場面ばかりなんですよね。
Excel の行は、見た目の化け以上に厄介でした。UTF-8 の「名」の最後のバイトと、続くカンマが cp932 の 2 バイト文字として一緒に読まれ、区切りが消えています。見出しが化けるだけなら人が気付きますが、列がずれた CSV を誰かがそのまま取り込むと、気付くのはずっと後になります。
想定外だったこと1: 例外ではなく None が返ってくる
表の中で、私がいちばん驚いたのは subprocess の行です。文字コードが合わなければ UnicodeDecodeError で止まる、と思っていました。ところが Windows では、止まりません。
import subprocess
# cmd.exe は日本語をコードページ932で書き出す
r = subprocess.run(["cmd", "/c", "echo", "日本語"], capture_output=True, text=True)
print("returncode:", r.returncode)
print("stdout:", repr(r.stdout))
# 実行結果(3.15.0rc3):
# Exception in thread Thread-1 (_readerthread):
# ...
# UnicodeDecodeError: 'utf-8' codec can't decode byte 0x93 in position 0: invalid start byte
# returncode: 0
# stdout: None
Windows 版の subprocess は、標準出力と標準エラーを別スレッドで読みます。文字コードの変換に失敗するのはそのスレッドの中なので、例外はスレッドの中で終わり、標準エラーに一度表示されるだけです。呼び出し側には、終了コード 0 と、stdout が None の結果が返ってきます。
ドキュメントには「text=True なら文字列で返る」と書いてあるけど、失敗したときに None になることまでは読み取れませんでした。念のため Linux(コンテナの python:3.14-slim)で同じ壊れ方をさせると、こちらは呼び出し側に UnicodeDecodeError が届きました。Windows で動かしたときだけの振る舞いです。
そして、grep では見えなかった社内の 1 か所が、まさにこの形でした。外部コマンドの標準エラーを r.stderr or "" で受けていたので、変換に失敗すると None が空文字に変わり、「失敗した理由」が空欄で報告されます。例外にもならず、ログにも理由が残らない。いちばん気付きにくい壊れ方です。
想定外だったこと2: 直ったはずの読み込みに BOM が残る
もう一つは、良い方向の変化の中にありました。自社サイトの記事ファイル 83 本を encoding なしで読むと、3.14 では 83 本とも UnicodeDecodeError で、3.15 では 83 本とも読めます。ここだけ見ると、3.15 で直ったように見えます。
ただ、83 本のうち 7 本は、先頭に BOM が付いています。3.15 の UTF-8 モードが使うのは utf-8 で、BOM を取り除く utf-8-sig ではありません。そのため 7 本は先頭に U+FEFF が残り、^--- で始まりを探すフロントマターの判定に 7 本とも失敗しました。私たちのチームでは、以前にも記事の集計で同じ取りこぼしをしたことがあります。3.15 にしても、この落とし穴は残ります。
ちなみに、「UTF-8 で読んでみて、失敗したら cp932 で読む」という自動判定は、今回は採りませんでした。表の「請求書の宛名」の行のとおり、短い UTF-8 の文字列は cp932 として例外なく読めてしまうことがあるからです。逆向きの順番なら防げる場面も多いのですが、最後は「誰が書いたファイルか」で決めるしかありません。
境界ごとに文字コードを決める
結局、3.15 の変更そのものより、「encoding を書かなければ誰かが決めてくれる」という前提を外すことが本題でした。書き方は、データの境界ごとに、文字コードを決めているのは誰かで分けています。
| 境界 | 文字コードを決めているのは | 書き方 |
|---|---|---|
| Excel が保存した CSV を読む | 保存した Excel(日本語版は cp932) | encoding=“cp932” |
| Excel に渡す CSV を書く | 受け取る Excel | encoding=“utf-8-sig” |
| 自分たちの UTF-8 ファイルを読む | 自分たち(BOM の有無が混ざる) | encoding=“utf-8-sig” |
| cmd や PowerShell 5.1 の出力を受ける | その PC の設定 | encoding=“locale” |
| node など UTF-8 で出すコマンドを受ける | コマンド側 | encoding=“utf-8” |
| Python の出力を PowerShell 5.1 で受ける | 呼ぶ側と呼ばれる側の両方 | 呼ぶ側で PYTHONIOENCODING と出力の文字コードを揃える |
Python 側は、こう書き直しました。encoding="locale" は 3.10 からある指定で、3.15 の UTF-8 モードの中でも OS の文字コード(日本語版なら cp932)を返します。「3.14 までの振る舞い」を明示的に書く方法として使えます。
import csv
import subprocess
# 1. Excel が保存した CSV は、作った側の文字コードで読む
with open("excel_cp932.csv", encoding="cp932", newline="") as f:
rows = list(csv.reader(f))
# 2. Excel に渡す CSV は BOM 付き UTF-8 で書く
with open("for_excel.csv", "w", encoding="utf-8-sig", newline="") as f:
csv.writer(f).writerows(rows)
# 3. 自分たちの UTF-8 ファイルは、BOM が付いていても読めるようにする
with open("bom_utf8.txt", encoding="utf-8-sig") as f:
head = f.read(3)
# 4. Windows 標準のコマンドは、その PC の文字コード(日本語版なら cp932)で受ける
r = subprocess.run(["cmd", "/c", "echo", "日本語"], capture_output=True, encoding="locale")
print(rows[1][2], repr(head), r.stdout.strip())
# 実行結果(3.14.8 と 3.15.0rc3 で同じ): 髙橋様(①案件) '---' 日本語
PowerShell 5.1 から呼ぶ側は、最初は受け取り側の [Console]::OutputEncoding を UTF-8 に変えるだけで済ませようとしました。ところがそうすると、今度は 3.14 の出力が化けます。呼ばれる Python の版によって、呼ぶ側の正解が入れ替わってしまうんですね。そこで、出力の文字コードは呼ぶ側が決めて、Python にも伝える形にしました。
# PowerShell 5.1 の既定(日本語版)では、出力を shift_jis として受け取る
$s = & $py315 -c "print('日本語')" # 3.15.0rc3 だと「譌・譛ャ隱・」
# 受け取り側だけ UTF-8 にすると、今度は 3.14.8 の出力が化ける
[Console]::OutputEncoding = [Text.Encoding]::UTF8
# 呼ぶ側で決めて、Python にも同じ文字コードで書かせる
$env:PYTHONIOENCODING = 'utf-8'
$s = & $py -c "print('日本語')" # 3.14.8 でも 3.15.0rc3 でも「日本語」
洗い出しには、構文木の走査と合わせて -X warn_default_encoding(3.10 から)も使いました。encoding を省いた open() だけでなく、text=True の subprocess も呼び出し元の行番号付きで EncodingWarning が出ます。こちらは実際に通った行しか出ないので、構文木の走査で全体を、警告で実際の通り道を、という分担にしています。PYTHONUTF8=0 や -X utf8=0 で 3.15 でも元の振る舞いに戻せることは確かめましたが、これは上げるのを先送りする手段で、直す手段ではないと考えています。
振り返って
今回いちばん効いたのは、自分の机の上の結果を疑ったことでした。手元で何も起きなかったのは、ユーザー環境変数に入っていた 1 行のおかげで、その 1 行は誰の記録にも残っていませんでした。3.15 の変更は、言ってみれば「全員の PC にその 1 行を配る」のと同じです。配られる前に、配られていない場所で試す必要がありました。
今後は、社内スクリプトの点検に構文木の走査を入れて、encoding の無い入出力が増えたら気付けるようにしていくつもりです。あわせて、試すときの文字列には ✓ のような cp932 に無い文字と、BOM 付きのファイルを必ず混ぜる。日本語だけで試すと、cp932 でも UTF-8 でも通ってしまう場面が多いからです。正式版が出たら、もう一度同じ表を作り直してみます。