postednote

Pythonにおけるシンボリックリンク経由のパス解決

Gitのpre-commitフックで、ファイル実体を別の場所に置き、シンボリックリンク経由でスクリプトを実行する構成としたところ、ファイルのパス解決に関する問題に遭遇しました。

本記事では、その解決策と、背景にあるパス操作におけるメンタルモデルについてまとめます。

検証環境

問題の概要

プロジェクトのルートにある tools/script.py を、.git/hooks/pre-commit からシンボリックリンクを貼って実行する構成をとりました。

スクリプト内で、自身のディレクトリにある設定ファイル(./config.json)を読み込もうとしたところ、FileNotFoundError が発生しました。調査の結果、スクリプトが参照していたパスが tools/ ではなく .git/hooks/ 起点になっていたことが判明しました。

原因

Pythonの __file__ 属性には、スクリプトがロードされた際のパスが保持されます。今回のようにシンボリックリンク経由で実行された場合、ここにはリンク自体のパス(.git/hooks/pre-commit)が格納されます。

私はこれまで os.path.abspath(__file__) を「ファイルの絶対パス」を取得する関数として捉えていましたが、これはシンボリックリンクの実体解決を行いません。そのため、後続処理で基準となるディレクトリがズレてしまっていました。

解決策

シンボリックリンクから実体を取得するには、pathlib.Path.resolve または os.path.realpath を使用します。これらはファイルシステム上のリンクを追跡してパスを解決します。

修正前

import os

# git commit 時はリンクの場所 (.git/hooks/pre-commit) が基準になってしまう
BASE_DIR = os.path.dirname(os.path.abspath(__file__))

修正後(推奨されるコード) Python 3.4 以降

from pathlib import Path

# リンクを解決し、実体 (tools/) のパスを取得する
SCRIPT_PATH = Path(__file__).resolve()
BASE_DIR = SCRIPT_PATH.parent

os.path を使用する場合は以下のようになります。

import os
BASE_DIR = os.path.dirname(os.path.realpath(__file__))

パス操作におけるメンタルモデル

なぜ abspath で動くと誤解してしまったのか、振り返るとパス操作に対するメンタルモデルに不正確な点がありました。

1. 文字列操作 vs ファイルシステム操作

私は abspath という関数名から、「そのファイルが存在する絶対パス」を返してくれる挙動を期待していました。しかし、実際には動作原理と計算コストにおいて違いがあります。

「パスを扱う」という行為が、単なる文字列の正規化なのか、実体の特定なのかを区別する必要がありました。 (※具体的な実装の違いについては付録に追加予定)

2. 実行コンテキストとリソースコンテキストの分離

通常、スクリプトは配置場所と実行場所が一致することが多いため意識していませんでしたが、Git Hookのようなケースではこれが分離します。

スクリプトに付随するリソース(設定ファイルなど)を読み込む場合、実行時のパス(__file__ の初期値)に依存するとこのズレの影響を受けます。resolve を使用することで、起動時の文脈から離れ、リソースが存在する実体の場所へと基準を移すことができます。

3. os.path と pathlib

この問題を通じて、Python標準ライブラリの変遷も感じられました。

Python 3.4で導入された pathlib は、パスを文字列ではなくオブジェクトとして扱う設計になっています。文字列操作によるパス処理の煩雑さと誤りやすさについての議論に基づいているようです。

abspath とは異なり、メソッド名が .resolve() となっている点は、裏側でファイルシステムへの問い合わせが走る動的なパス処理であることを伝えているように感じます。

結論

CLIツールやHookスクリプトなど、シンボリックリンク経由で実行される可能性があるスクリプトを記述する場合、自身の場所を特定するには pathlib.Path.resolve() を使用するのが安全です。

1行のパス修正を通して言語処理系がどのようにファイルシステムを扱っているか、OSとの境界線を意識する良い機会になりました。


付録

A. __file__ の仕様

Python 3.14 のドキュメントには以下のように記載されています。

__file__ indicates the pathname of the file from which the module was loaded (if loaded from a file), or the pathname of the shared library file for extension modules loaded dynamically from a shared library.

ここでの「ファイルのパス名」とは、インタプリタに渡されたパスそのものを指します。シンボリックリンク経由で渡された場合、自動的に実体に解決されることはありません。

B. CPythonにおけるabspath(), realpath(), pathlib.Path.resolve()の内部実装

調査後追加する

自分用メモ