pathlib で os.path を置き換える: モダンなファイルパス処理
1. 概要
多くの古い Python コードは os.path.join、os.path.splitext、os.listdir などを使用してパスを処理しています。これらの関数は文字列を受け取り返すため、コードが長くなり、読みにくく、エラーが起こりやすくなります。pathlib モジュール(Python 3.4 以降で利用可能)はパスを Path オブジェクトで表現し、パスの結合、分割、ファイルの読み書き、ディレクトリのトラバースなど、必要なメソッドがすべて揃っています。
この記事を読み終わると、ほとんどの os.path コードを pathlib に移行できるようになり、落とし穴も理解できます。例では Python 3.12 を使用しています。
2. Path オブジェクトの作成
Path はすべての操作の出発点です:
from pathlib import Path
reports = Path("data/reports")
home = Path.home()
cwd = Path.cwd()
print(reports)
print(type(reports))
print(home)
data/reports
<class 'pathlib.PosixPath'>
/home/minh
説明:
Path("data/reports")はパスオブジェクトを作成します。この時点ではディスク操作は行われません。ディレクトリが存在する必要はありません。- Linux/macOS では、
PathはPosixPathを返します。Windows ではWindowsPathを返します。特定のクラスについて心配する必要はありません。 Path.home()とPath.cwd()はos.path.expanduser("~")とos.getcwd()に代わります。
実際のファイルに触れずにパス文字列を操作するだけなら、PurePath、PurePosixPath、または PureWindowsPath を使用します。これは Linux で実行中に Windows パスを処理するのに便利です。
3. / 演算子を使用したパスの結合
これは最も明白な変更です。os.path.join をネストする代わりに、/ 演算子を使用します:
import os
from pathlib import Path
# 古い方法
old = os.path.join("data", "orders", "2026", "order_1001.json")
# 新しい方法
base = Path("data")
new = base / "orders" / "2026" / "order_1001.json"
print(old)
print(new)
print(new == Path(old))
data/orders/2026/order_1001.json
data/orders/2026/order_1001.json
True
/ 演算子は右側に Path と str の両方を受け入れます。結果は常に Path なので、結合を続けることができます。セパレータは OS に応じて自動的に変わります。Linux では /、Windows では \ です。
コンポーネント数が実行時に変わる場合は、joinpath を使用します:
parts = ["orders", "2026", "order_1001.json"]
path = Path("data").joinpath(*parts)
print(path) # data/orders/2026/order_1001.json
4. パスのコンポーネントを分割する
os.path ではこれに多くの関数が必要です:basename、dirname、splitext。pathlib では、すべてが属性です:
from pathlib import Path
invoice = Path("/srv/shop/invoices/2026/invoice_042.final.pdf")
print(invoice.name) # 完全なファイル名
print(invoice.stem) # 最後の拡張子を除いた名前
print(invoice.suffix) # 最後の拡張子
print(invoice.suffixes) # すべての拡張子
print(invoice.parent) # 親ディレクトリ
print(invoice.parents[1])
print(invoice.parts)
invoice_042.final.pdf
invoice_042.final
.pdf
['.final', '.pdf']
/srv/shop/invoices/2026
/srv/shop/invoices
('/', 'srv', 'shop', 'invoices', '2026', 'invoice_042.final.pdf')
説明:
stemとsuffixは 最後の ドットでのみ分割します。.tar.gzファイルの場合はsuffixesを使用する必要があります。parentはos.path.dirnameと同等です。parentsは親ディレクトリのシーケンスで、ルートまで上がります。partsはパスをタプルに分割します。各レベルを比較する必要があるときに便利です。
異なる名前または拡張子を持つ新しいパスを作成するには、with_* メソッドを使用します:
invoice = Path("invoices/invoice_042.pdf")
print(invoice.with_suffix(".xml")) # invoices/invoice_042.xml
print(invoice.with_stem("invoice_043")) # invoices/invoice_043.pdf
print(invoice.with_name("summary.csv")) # invoices/summary.csv
これらのメソッドはディスク上のファイル名を変更しません。Path は不変なので、新しい Path を返すだけです。
5. ファイルの読み書き
小さいファイルの場合、open() は不要です:
from pathlib import Path
config = Path("app_config.txt")
config.write_text("currency=VND\ntimeout=30\n", encoding="utf-8")
content = config.read_text(encoding="utf-8")
print(content.splitlines())
logo = Path("logo.bin")
logo.write_bytes(b"\x89PNG")
print(logo.read_bytes())
['currency=VND', 'timeout=30']
b'\x89PNG'
write_textはファイルを開き、書き込み、閉じます。古いファイルは上書きされます。read_textはすべての内容を文字列として読み込みます。write_bytesとread_bytesはバイナリデータでも同様に機能します。
大きなファイルや行ごとに読み込む必要がある場合は、依然としてコンテキストマネージャを使用します。Path には open メソッドが組み込まれています:
with Path("orders.csv").open("w", encoding="utf-8") as f:
f.write("id,amount\n1001,250000\n1002,99000\n")
with Path("orders.csv").open(encoding="utf-8") as f:
total = sum(int(line.split(",")[1]) for line in f if not line.startswith("id"))
print(total) # 349000
注意: 常に
encoding="utf-8"を渡してください。省略すると、Python はシステムのデフォルトエンコーディングを使用します。Windows では、デフォルトは通常cp1252なので、ベトナム語データは破損します。
6. ディレクトリの確認とトラバース
6.1. ステータスの確認
from pathlib import Path
p = Path("orders.csv")
print(p.exists(), p.is_file(), p.is_dir())
print(p.stat().st_size)
print(p.resolve())
True True False
36
/home/minh/project/orders.csv
resolve() は絶対パスを返し、シンボリックリンクを解決します。os.path.abspath と os.path.realpath に代わります。
6.2. ファイルのリストと検索
from pathlib import Path
root = Path("shop")
(root / "orders" / "2026").mkdir(parents=True, exist_ok=True)
(root / "orders" / "2026" / "o1.json").write_text("{}", encoding="utf-8")
(root / "orders" / "o0.json").write_text("{}", encoding="utf-8")
(root / "readme.md").write_text("# Shop", encoding="utf-8")
print(sorted(p.name for p in root.iterdir()))
print(sorted(str(p) for p in root.glob("*.md")))
print(sorted(str(p) for p in root.rglob("*.json")))
['orders', 'readme.md']
['shop/readme.md']
['shop/orders/2026/o1.json', 'shop/orders/o0.json']
iterdir()はos.listdir()に代わります。ファイル名だけでなく完全なPathオブジェクトを返すため、結合する必要がありません。glob("*.md")は現在のディレクトリだけを検索します。rglob("*.json")は再帰的に検索し、glob("**/*.json")と同等です。
Python 3.12 以降、Path.walk() は os.walk() に代わります:
for dirpath, dirnames, filenames in Path("shop").walk():
print(dirpath, filenames)
7. 作成、名前変更、削除
import shutil
from pathlib import Path
backup = Path("backup/daily")
backup.mkdir(parents=True, exist_ok=True)
src = Path("orders.csv")
dst = src.rename(backup / "orders_2026-10-17.csv")
print(dst)
dst.unlink()
Path("not_there.txt").unlink(missing_ok=True)
backup.rmdir()
shutil.rmtree("backup")
backup/daily/orders_2026-10-17.csv
mkdir(parents=True, exist_ok=True)はos.makedirs(..., exist_ok=True)と同等です。renameは新しいPathを返します。Windows では、対象ファイルが既に存在する場合、renameはエラーを発生させます。すべてのプラットフォームで上書きするには、replaceを使用します。unlinkはファイルを削除します。missing_ok=Trueを使用すると、ファイルが存在しない場合もエラーが発生しません。rmdirは空のディレクトリのみを削除できます。ディレクトリツリー全体を削除するには、依然としてshutil.rmtreeが必要です。この関数は通常のPathオブジェクトを受け入れます。
8. クイックリファレンス表
| os / os.path | pathlib |
|---|---|
os.path.join(a, b) |
Path(a) / b |
os.path.basename(p) |
p.name |
os.path.dirname(p) |
p.parent |
os.path.splitext(p) |
p.stem, p.suffix |
os.path.exists(p) |
p.exists() |
os.path.isfile(p) / isdir |
p.is_file() / p.is_dir() |
os.path.abspath(p) |
p.resolve() または p.absolute() |
os.path.expanduser("~") |
Path.home() または p.expanduser() |
os.getcwd() |
Path.cwd() |
os.listdir(p) |
p.iterdir() |
glob.glob("**/*.py", recursive=True) |
p.rglob("*.py") |
os.makedirs(p, exist_ok=True) |
p.mkdir(parents=True, exist_ok=True) |
os.remove(p) |
p.unlink() |
os.rename(a, b) |
a.rename(b) |
os.path.getsize(p) |
p.stat().st_size |
os.path.relpath(p, start) |
p.relative_to(start) |
9. pytest でテストを書く
pytest の tmp_path フィクスチャは既に Path を返すため、pathlib を使用するテストコードは非常に簡潔です。PDF 請求書ファイルをアーカイブフォルダに収集する関数があるとしましょう:
# archive.py
from pathlib import Path
def archive_invoices(source: Path, target: Path) -> list[Path]:
target.mkdir(parents=True, exist_ok=True)
moved = []
for pdf in sorted(source.rglob("*.pdf")):
moved.append(pdf.rename(target / pdf.name))
return moved
対応するテスト:
# test_archive.py
from pathlib import Path
from archive import archive_invoices
def test_archive_moves_only_pdf_files(tmp_path: Path):
source = tmp_path / "inbox"
(source / "2026").mkdir(parents=True)
(source / "a.pdf").write_bytes(b"%PDF")
(source / "2026" / "b.pdf").write_bytes(b"%PDF")
(source / "note.txt").write_text("skip me", encoding="utf-8")
target = tmp_path / "archive"
moved = archive_invoices(source, target)
assert [p.name for p in moved] == ["b.pdf", "a.pdf"]
assert sorted(p.name for p in target.iterdir()) == ["a.pdf", "b.pdf"]
assert (source / "note.txt").exists()
assert not (source / "a.pdf").exists()
pytest -q test_archive.py
. [100%]
1 passed in 0.02s
moved の順序は a.pdf の前に b.pdf です。理由:sorted は Path オブジェクトを比較し、文字 2 が a より小さいため、inbox/2026/b.pdf が inbox/a.pdf の前に来ます。ファイル名文字列の比較から Path オブジェクトの比較に切り替えるときに、この詳細は見落としやすいです。
10. よくあるエラー
Path を文字列と加える。 Path("data") + "/x.txt" は TypeError を発生させます。+ の代わりに / を使用してください。ファイル名に本当に追加する必要がある場合は、p.with_name(p.name + "_old") を使用してください。
右側が絶対パス。 結果があなたを驚かせるかもしれません:
from pathlib import Path
print(Path("/srv/uploads") / "/etc/passwd") # /etc/passwd
右側の絶対コンポーネントは左側の全体を置き換えます。ファイル名がユーザーから来ている場合は、最初にチェックする必要があります:
base = Path("/srv/uploads").resolve()
candidate = (base / user_input).resolve()
if not candidate.is_relative_to(base):
raise ValueError("無効なパス")
with_suffix にドットがない。 p.with_suffix("pdf") は ValueError を発生させます。".pdf" と書く必要があります。
glob がジェネレータを返すことを忘れる。 一度だけ反復処理できます。再利用する必要がある場合や len() を呼び出す場合は、list(...) でラップしてください。結果の順序も保証されないため、順序が重要な場合は sorted が必要です。
隠しファイルと stem。 Path(".env") では、suffix は空の文字列で stem は ".env" です。すべてのファイルに拡張子があると仮定しないでください。
古いライブラリは str のみを受け入れます。 ほとんどの標準 API は Python 3.6 以降 os.PathLike を受け入れます。古いライブラリに遭遇した場合は、str(p) または os.fspath(p) を使用してください。
注意:
pathlibはosとshutilを完全には置き換えません。ファイルをコピーするには、依然としてshutil.copy2を使用します(Python 3.14 ではPath.copyが追加されました)。環境変数は依然としてos.environを介して読み込まれます。複雑なパーミッションは依然としてos.chmodまたはos.accessが必要です。
いつ変更する必要がないのか? ディスクに触れずに何百万ものパス文字列を処理するループの場合、Path オブジェクトの作成は純粋な文字列操作よりも遅いです。これはまれなケースであり、最適化する前に測定する必要があります。古いコードが安定して実行されている場合、すべてを一度に変更する必要はありません。各モジュールに触れるときに徐々にマイグレーションしてください。
11. 結論
pathlib は os、os.path、glob に分散した操作を 1 つのオブジェクトに統合します。コードはより短く、読みやすく、文字列連結エラーが少なくなります。/ でパスを結合し、name、stem、suffix、parent でコンポーネントを分割し、read_text と write_text でファイルの読み書きをし、iterdir、glob、rglob、walk でディレクトリをトラバースする方法、および pytest の tmp_path でテストする方法を学びました。
3 つのポイントを覚えておく必要があります。常に encoding を渡してください。外部入力からの絶対パスと結合するときは注意してください。コピーとディレクトリツリーの削除などの操作には依然として shutil を使用してください。新しいコードでは、デフォルトで Path を使用し、特定の理由がある場合のみ os.path に戻ってください。