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 に戻ってください。

pythonpathlibos.pathfile-iopython3