手元のプロジェクトの .venv/lib/python3.14/site-packages を覗くと、.pth ファイルは _virtualenv.pth が1つだけ入っていて、中身は import _virtualenv という1行でした。この行は Python の起動時に exec() へ渡されて実行されます。3.15 でこの仕組みが非推奨になり、置き換え先として .start ファイルが入りました。
.pthで非推奨になるのはimport行だけ
PEP 829 「Package Startup Configuration Files」は .pth ファイルを丸ごと廃止する提案ではありません。.pth が担っている機能は2つあり、消えるのは片方だけ。
- sys.path の拡張: ディレクトリ名を書いた行。絶対パスはそのまま、相対パスは
.pthの置き場所を基準に解決されます。この機能は変わりません - コード実行:
importで始まる行をexec()に渡して実行する機能。こちらが非推奨になりました
site-packages にパスを通す用途で .pth を書いているなら、3.15 でも 3.20 でも触る必要はありません。
site-packagesの.pthを読んでみる
virtualenv と coverage はどちらもこのコード実行に依存しています。3.15.0rc2 の venv に pytest-cov を入れて、できた .pth を読みました。
$ cat .venv/lib/python3.15/site-packages/a1_coverage.pth
import sys; exec('import os\n\nif os.getenv("COVERAGE_PROCESS_START") or os.getenv("COVERAGE_PROCESS_CONFIG"):\n try:\n import coverage\n except:\n pass\n else:\n coverage.process_startup(slug="pth")')
1行ですが、exec() の文字列に9行分のコードが折り畳まれています。ファイル名の a1_ も意図があって、.pth はファイル名のアルファベット順に処理されるため、他のパッケージより先に走らせたい coverage が名前で順番を取りに行っています。
pip install -e . が作る.pthは構成で分かれる
自作パッケージの editable インストールも .pth を置きます。ここは構成次第で中身が変わりました。src レイアウトで pip install -e . した場合はパス1行だけ。--config-settings editable_mode=strict を付けた場合も同じくパス1行です。
$ cat .venv/lib/python3.15/site-packages/__editable__.mypkg-0.1.0.pth
/private/tmp/edittest/src
一方、[tool.setuptools.package-dir] でパッケージを別ディレクトリへ割り当てると、setuptools は静的なパス1行で表現できず、独自の finder を仕込む形に切り替わります。
[tool.setuptools.package-dir]
mypkg3 = "lib/mypkg3"
$ cat .venv/lib/python3.15/site-packages/__editable__.mypkg3-0.1.0.pth
import __editable___mypkg3_0_1_0_finder; __editable___mypkg3_0_1_0_finder.install()
これは import 行なので、3.15 では -v を付けると非推奨の診断が出ます。
$ python -v -c "import mypkg3" 2>&1 | grep "are deprecated"
import lines in /private/tmp/edittest3/.venv/lib/python3.15/site-packages/__editable__.mypkg3-0.1.0.pth are deprecated, use entry points in a /private/tmp/edittest3/.venv/lib/python3.15/site-packages/__editable__.mypkg3-0.1.0.start file instead.
自分の pyproject.toml が前者と後者のどちらに落ちるかは、書いてみて cat するまで分かりません。対応は setuptools 側の仕事なので待てばよいものの、3.18 で import 行が黙って無視されるまでに動く必要があるのは、パッケージ作者ではなくビルドバックエンド側になります。
exec()に渡る1行が問題になった理由
PEP 829 の “Motivation” セクションは import 行の問題を3点挙げていて、1点目がまさにこの書き方を指しています。
Lines that start with
importcan be extended by separating multiple statements with a semicolon. As long as all the code to be executed appears on the same line, it all gets executed when the.pthfile is processed.
セミコロンで文を繋げば1行に何でも詰め込める。実行されるのはユーザーコードの1行目より前で、しかも exec() 経由なので静的解析にも乗らない。PEP は “Security Implications” で、site ディレクトリのファイル一覧を見ただけでコード実行の発生箇所が特定できる状態を目標に置いています。
非推奨は5年かけて3段階で進む
import 行が 3.15 で即座に動かなくなるわけではありません。PEP 829 の “Abstract” は段階をこう区切っています。
| Python バージョン | .pth の import 行 | 診断メッセージ |
|---|---|---|
| 3.15 / 3.16 / 3.17 | 実行される(同名の .start がある場合を除く) | -v を付けたときだけ |
| 3.18 / 3.19 | 黙って無視される | 出ない |
| 3.20 以降 | 無視される | 警告が出る |
3.18 と 3.19 の2年間は、import 行が実行されないのに診断メッセージも出ません。既存の .pth が静かに効かなくなるのはこの区間です。
.startの書式はコロン形式が必須
置き場所は .pth と同じ site-packages で、ファイル名は <name>.start。<name> の部分はパッケージ名と一致させる必要すらなく、インタプリタ側は何の制約も課しません(PEP の “File Naming and Discovery” は、揃えたほうが分かりやすいので推奨はする、という書き方)。書式は site モジュールのドキュメントの “Startup entry points (.start files)” セクションが定めています。
Each non-blank line that does not begin with
#must contain an entry point reference in the formpkg.mod:callable. The colon and callable portion are mandatory. Each callable is invoked with no arguments, and any return value is discarded.
コロンを省くと起動のたびにトレースバックが出る
3.14 以前の pkgutil.resolve_name() はコロン無しの pkg.mod 形式も受け付けます。.start ではこれが通りません。
# NG: コロンと callable を省いた書き方
demo.hook
この .start を置いたまま、何でもない1行を実行してみます。
$ python -c "print('booted')"
Invalid entry point syntax in /tmp/pep829test/.venv/lib/python3.15/site-packages/demo.start: 'demo.hook'
Traceback (most recent call last):
File "<frozen site>", line 557, in _execute_start_entrypoints
File "/tmp/py315rc2/python/lib/python3.15/pkgutil.py", line 463, in resolve_name
raise ValueError(f'invalid format: {name!r}')
ValueError: invalid format: 'demo.hook'
booted
booted が出ているとおりインタプリタは止まらず、終了コードも 0 のままです。ここで引っかかったのが -v の扱い。PEP 829 の “Error Handling” は不正なエントリポイント指定を「パース時にスキップされ、-v を付けたときだけ報告される」側に分類していますが、rc2 の実装では書式チェックが pkgutil.resolve_name() の内側、つまり実行フェーズで走ります。結果として -v なしでも stderr に出ます。書式を1文字間違えた .start を site-packages に置くと、その venv を使う全コマンドがトレースバックを吐き続けることになる。
コロンと callable を付けた形に直すと黙って通ります。
demo.hook:install
$ python -c "print('booted')"
[demo] install
booted
コメント行・空行・BOMの扱い
# で始まる行と空行は無視されます。行頭に空白があっても、最初の非空白文字が # ならコメント扱い。
# コメント行は無視される
# 行頭が空白でもコメント
demo.hook:install
demo.hook:install
$ python -c "pass"
[demo] install
[demo] install
同じエントリポイントを2行書くと2回呼ばれます。sys.path の重複は排除されるのに、エントリポイントは排除されない。PEP の “Rationale” が「複数回の呼び出しを実際に望むユーザーがいるかもしれない」ことと、独立に書かれた .start 同士での重複判定の複雑さを理由に、意図してそうしています。エンコーディングは utf-8-sig 指定で、BOM を付けて保存しても正しく読めました。
同名の.startを置くとimport行が止まる
demo.pth と demo.start が同じディレクトリに並ぶと、demo.pth の import 行は実行されません。効くのはパス行だけ。移行期間の挙動を決めているのはこの1点です。
出力が変わらないので気づかない
demo.pth に import demo.hook; demo.hook.install()、demo.start に demo.hook:install を書いて起動します。install() が呼ばれるのは1回で、標準エラー出力に出る内容も移行前と変わらない。どちらの経路で実行されたかは -v でしか分かりません。
$ python -v -c "pass" 2>&1 | grep -E "suppressed|Executing entry point"
import lines in .../site-packages/demo.pth are suppressed due to matching .../site-packages/demo.start file.
Executing entry point: demo.hook:install from .../site-packages/demo.start
.start を消すとメッセージが are deprecated, use entry points in a .../demo.start file instead. に変わります。移行できているかの判定は、この2つの文字列の差を見るのが確実です。
古いPythonと新しいPythonを同時に相手にする書き方
site モジュールのドキュメントには “Migrating from import lines in .pth files to .start files” というセクションがあり、両対応の書き方が載っています。.pth の import 行を、.start と同じ callable を呼ぶ形に揃えます。
# pkgx.pth
import pkgx.mod; pkgx.mod.callable_()
# pkgx.start
pkgx.mod:callable_
同じ site-packages を 3.14.6 と 3.15.0rc2 の両方で起動しました。
--- Python 3.14.6 ---
[pkgx] entry point / import line
[pkgx] entry point / import line
--- Python 3.15.0rc2 ---
[pkgx] entry point / import line
3.14 は .start を知らないので .pth の import 行を実行し、3.15 は .pth を抑止して .start のエントリポイントを呼ぶ。狙ったとおりに動いています。3.14 側が2回になっているのは別の理由で、これは後述の StartupState の節で触れます。
実行順はパス行 → 旧import行 → エントリポイント
demo.pth にパス行と import 行、aaa.start と zzz.start にエントリポイントを置いて -v で起動すると、3フェーズがそのまま出てきます。
Extending sys.path with /tmp/pep829test/.venv/lib/python3.15/site-packages/extra_libs from /tmp/pep829test/.venv/lib/python3.15/site-packages/demo.pth
Exec'ing from .../site-packages/demo.pth: import demo.hook; demo.hook.install()
Executing entry point: demo.other:boot from .../site-packages/aaa.start
Executing entry point: demo.hook:install from .../site-packages/zzz.start
旧 import 行は、ファイル名に関係なく全ての .start より先に走ります。.start に移した側は実行順が後ろにずれます。まだ .pth のままのパッケージと初期化順で噛み合っているなら、移行そのものが順序を変えてしまう。.start 同士の順番はファイル名のアルファベット順で、.pth のときと同じ規則です。
エントリポイントは無条件に呼ばれる
.pth の import 行は「1行に何でも書ける」ので、条件分岐を行の中に持てました。coverage の例がそれで、COVERAGE_PROCESS_START か COVERAGE_PROCESS_CONFIG が設定されていなければ import coverage 自体に到達しません。
.start のエントリポイントには条件を書く場所がない。ドキュメントにあるとおり「invoked with no arguments」で、毎回呼ばれます。
起動が9.3msから36.0msになった
coverage の a1_coverage.pth をそのまま a1_coverage.start に移してみました。中身は coverage.control:process_startup の1行。環境変数を設定しない状態で sys.modules を覗きました。
$ # .pth の import 行のみ
$ python -c "import sys; print('coverage in sys.modules:', 'coverage' in sys.modules)"
coverage in sys.modules: False
$ # a1_coverage.start を置いた場合
$ python -c "import sys; print('coverage in sys.modules:', 'coverage' in sys.modules)"
coverage in sys.modules: True
環境変数が無いので coverage は何も計測しません。それでもモジュールは読み込まれる。python -c pass を30回まわして測りました。
| 構成 | 30回の実時間 | 1回あたり |
|---|---|---|
.pth の import 行のみ | 0.28s | 9.3ms |
.start のエントリポイント | 1.08s | 36.0ms |
1起動あたり 26.7ms の増加。2試行とも同じ数値でした。coverage.process_startup() は環境変数が無ければ早期に返るので、機能面の差は出ません。増えたぶんは丸ごと import coverage のコスト。pytest を1回叩くだけなら体感では分かりません。この venv の python を1,000回起動する CI なら27秒。Python 3.15のlazy importを実測—PEP 810の構文と制約で見た起動時間の削り方と、ちょうど逆を向いた変化になります。
条件判定をcallableの中に移す
重い import は callable の内側に移します。エントリポイントから参照されるモジュール自体は、os だけ読んで判定して帰れる程度に軽くしておきます。
# mypkg/startup.py
import os
def install():
if not os.getenv("MYPKG_ENABLE"):
return
from mypkg import heavy # 条件を満たしたときだけ読む
heavy.setup()
$ python -X importtime -c "pass" 2>&1 | grep -E "mypkg.heavy|xml.etree.ElementTree"
$ echo $?
1
$ MYPKG_ENABLE=1 python -X importtime -c "pass" 2>&1 | grep -E "mypkg.heavy|xml.etree.ElementTree"
import time: 278 | 1123 | xml.etree.ElementTree
import time: 38 | 1741 | mypkg.heavy
環境変数が無いときは mypkg.heavy も xml.etree.ElementTree も importtime に現れません。条件を満たしたときだけ累計 1741μs を払います。判定の置き場所が、1行の中からモジュール側の関数に移った。PEP 829 の “Backwards Compatibility” が示す移行戦略も同じ形で、コードを callable に移して .start から名前で呼べ、と書かれています。
書式ミスの検出をどこでやるか
前述のとおり .start の書式エラーは起動を止めず、終了コードにも出ません。CI で落とすなら、-v 出力を grep して自分で終了コードを立てます。
$ python -v -c "pass" 2>&1 | grep -E "Invalid entry point syntax|Error resolving entry point" && exit 1
Error resolving entry point demo.hook:missing from .../site-packages/demo.start
Error resolving entry point は callable が見つからないケースで、原因は AttributeError: module 'demo.hook' has no attribute 'missing' のように行ごとに出ます。メッセージにファイル名と行の内容が入るので、壊れている .start は特定できます。
site.StartupStateで複数のサイトディレクトリをまとめる
PEP 829 の実装で site モジュールに StartupState クラスが増えました。公開メソッドは addsitedir()、addsitepackages()、addusersitepackages()、process() の4つ。「What’s New In Python 3.15」の “PEP 829: Package startup configuration files” セクションはこう説明しています。
The
sitemodule also providessite.StartupStateto batch startup processing for multiple site directories, ensuring all static path extensions are applied before any startup code is executed.
addsitedirを順に呼ぶと後続のパスが見えない
検証はサイトディレクトリ2つで組みました。siteA の .start から呼ばれる callable が、siteB の .pth で通したパス上のモジュールを import します。
import site
site.addsitedir("/tmp/ssdemo/siteA")
site.addsitedir("/tmp/ssdemo/siteB")
[starter] late が import できない
siteA を処理した時点で siteB のパスはまだ sys.path に無いので、エントリポイントが ModuleNotFoundError を踏みます。StartupState を挟むと、パス拡張が全部終わってから process() でエントリポイントが走ります。
import site
st = site.StartupState()
st.addsitedir("/tmp/ssdemo/siteA")
st.addsitedir("/tmp/ssdemo/siteB")
st.process()
[starter] late.NAME=late-module
3.14で2回走っていたimport行が1回になる
3.14 で import 行が2回実行された件に戻ります。-v で数えると Processing .pth file の行が同じパスに対して2本出ていて、venv の site-packages が2回スキャンされたのが原因。3.15 で1本に減るのは、StartupState が _processed_sitedirs を持ち、_record_sitedir() で処理済みのディレクトリを弾いているからです。クラスの docstring にも切り分けが書かれています。
methods (_record_sitedir(), _read_pth_file(), _read_start_file()) are
private to the site module; the public surface is addsitedir(),
addusersitepackages(), addsitepackages(), and process().
副作用のある import 行を持つパッケージは、この venv 構成では 3.15 に上げるだけで実行回数が2回から1回に変わります。-S を付ければ両方とも止まる点は、.pth の時代と変わりません。
いま手を打つべきこと
3.15 時点で .pth を書き換える必要があるのは、import 行を使っているパッケージの作者だけです。使う側は 3.18 までは何も起きません。
- パス行だけの
.pthは対象外。ただしpip install -e .は構成次第でimport行を書きます。package-dirでパッケージを別ディレクトリに割り当てていると finder 呼び出しの1行が入る - 自分の環境の棚卸しは
-vで1回。python -v -c pass 2>&1 | grep "are deprecated"で、import行を持つ.pthが列挙されます - 移行で変わるのは挙動の3点: 実行順が
.start群の中へ後ろ倒しになる、条件分岐が書けないので毎回呼ばれる、書式ミスが起動のたびに stderr へ出る
PEP の “Rejected Ideas” には <name>.site.toml という統合案が載っていて、TOML のパースコストに見合わないという反対で落ちています。結果として残ったのが、パスとコードを2ファイルに分けるだけの形。パッケージング周りの仕様が TOML に寄っていく流れ(pylock.tomlとuv.lockの違い—PEP 751をpipとuvで実測する)の中では、少し珍しい決着です。