pathlibの使い方とos.pathとの違い

pathlibはPython 3.4以降で導入されたオブジェクト指向のファイルパス操作ライブラリです。
従来のos.pathよりも直感的で読みやすいコードが書けます。
ファイルパスの扱いは一見単純に見えますが、実際の開発では次のようなトラブルの原因になりがちです。

  • Windows と Linux/Mac でパス区切り文字(\と/)が異なり、環境によって動作が変わってしまう問題
  • 文字列を+やos.path.joinで結合する際の、余分なスラッシュや結合順序の誤り
  • パスを単なる文字列として扱うため、存在チェックや拡張子取得のたびに別関数を呼び出す必要があり、コードが読みにくくなる問題

pathlibはパスを「オブジェクト」として扱うことで、こうした悩みをまとめて解決してくれるライブラリです。この記事では、基本的な使い方から実務での活用例、os.pathとの違いまでを順を追って解説します。

基本的な使い方

1. Pathオブジェクトの作成

from pathlib import Path

# 相対パス
data_dir = Path("./data")
file_path = Path("data/sample.csv")

# 絶対パス
abs_path = Path("/home/user/data")

# 現在のディレクトリ
current_dir = Path.cwd()

# ホームディレクトリ
home_dir = Path.home()

Path()に文字列を渡すだけで、そのパスを表すオブジェクトが作成できます。相対パス(実行中のディレクトリを基準にした場所)と絶対パス(ルートからのフルパス)のどちらも同じ Path()で扱える点がポイントです。

  • Path.cwd() : スクリプトを実行しているカレントディレクトリを取得
  • Path.home() : ログイン中ユーザーのホームディレクトリを取得

この2つは、設定ファイルの保存先や出力先ディレクトリを環境に依存せず決めたいときによく使われます。

2. パスの結合(重要!)

from pathlib import Path

# スラッシュ演算子でパスを結合
base_dir = Path("./data")
file_path = base_dir / "sample.csv"  # data/sample.csv
sub_path = base_dir / "2024" / "01" / "file.csv"  # data/2024/01/file.csv

# 従来の方法との比較
import os
old_way = os.path.join("./data", "sample.csv")  # 従来
new_way = Path("./data") / "sample.csv"  # pathlib(推奨)

pathlib最大の特徴は、この/演算子によるパス結合です。文字列結合と違い、区切り文字を意識する必要がなく、Windows でも Linux/Mac でもそのOSに合った区切り文字で自動的にパスが組み立てられます。

補足:./は自動的に取り除かれます
Path("./data")のように先頭に ./(カレントディレクトリを表す記号)を付けてオブジェクトを作成しても、pathlibは内部でこの冗長な表記を自動的に取り除きます。そのためbase_dirの実体はPath("data")と全く同じになり、print(file_path)などで文字列化した結果にも ./は表示されません。動作上の問題はありませんが、「./を付けたのに出力結果には出てこない」という点は初めて触るときに戸惑いやすいので覚えておいてください。

3. ディレクトリ操作

from pathlib import Path

dir_path = Path("./data")

# ディレクトリの作成
dir_path.mkdir()  # 親ディレクトリがないとエラー
dir_path.mkdir(parents=True)  # 親ディレクトリも作成
dir_path.mkdir(exist_ok=True)  # 既に存在してもエラーにしない

# ディレクトリの削除
dir_path.rmdir()  # 空のディレクトリのみ削除可能

# 再帰的に削除(shutil使用)
import shutil
shutil.rmtree(dir_path)

mkdir()は指定したディレクトリを作成しますが、途中のディレクトリが存在しない場合は FileNotFoundErrorになります。parents=Trueを付けると、存在しない親ディレクトリもまとめて作成してくれるため、深い階層のディレクトリを一度に作りたいときに便利です。また、exist_ok=Trueを付けないと、既に同名のディレクトリが存在する場合に FileExistsError が発生する点にも注意してください。
rmdir()は空のディレクトリしか削除できない安全な仕様になっています。中にファイルが残っているディレクトリごと削除したい場合は、標準ライブラリの shutil.rmtree()を使う必要がありますが、こちらは取り消せない削除なので、本番環境で使う際は削除対象のパスを事前に十分確認してから実行するようにしてください。

4. ファイル操作

from pathlib import Path

file_path = Path("./data/sample.csv")

# ファイルの存在チェック
if file_path.exists():
    print("ファイルが存在します")

# ファイルかディレクトリかの判定
if file_path.is_file():
    print("ファイルです")

if file_path.is_dir():
    print("ディレクトリです")

# ファイルの読み書き
content = file_path.read_text(encoding='utf-8')  # テキスト読み込み
binary = file_path.read_bytes()  # バイナリ読み込み

file_path.write_text("Hello", encoding='utf-8')  # テキスト書き込み
file_path.write_bytes(b"Hello")  # バイナリ書き込み

# ファイルの削除
file_path.unlink()  # ファイル削除
file_path.unlink(missing_ok=True)  # 存在しなくてもエラーにしない

exists()はパスが「存在するかどうか」だけを判定するため、対象がファイルなのかディレクトリなのかまでは分かりません。存在するパスの種類まで区別したい場合はis_file() / is_dir()を併用してください。
read_text() / write_text()はテキストファイル用、read_bytes() / write_bytes()は画像やZIPなどのバイナリファイル用です。テキストを扱う際にencodingを省略すると、実行環境のデフォルトエンコーディングが使われてしまい、文字化けの原因になることがあるため、encoding='utf-8'のように明示的に指定する習慣をつけておくと安全です。
unlink()はファイル削除のメソッドで、対象が存在しない場合は通常 FileNotFoundError が発生します。存在チェックとセットで呼び出すのが面倒な場合は、missing_ok=Trueを付けることでエラーを無視できます。

5. パス情報の取得

from pathlib import Path

file_path = Path("./data/reports/2024/sample.csv")

# ファイル名・拡張子
print(file_path.name)        # sample.csv
print(file_path.stem)        # sample
print(file_path.suffix)      # .csv
print(file_path.suffixes)    # ['.csv'] (複数拡張子対応)

# 親ディレクトリ
print(file_path.parent)      # data/reports/2024
print(file_path.parents[0])  # data/reports/2024
print(file_path.parents[1])  # data/reports
print(file_path.parents[2])  # data

# 絶対パス
print(file_path.absolute())  # /full/path/to/data/reports/2024/sample.csv
print(file_path.resolve())   # シンボリックリンクも解決

# 文字列に変換
print(str(file_path))        # data/reports/2024/sample.csv

先ほど説明した通り、Path("./data/reports/2024/sample.csv")も生成時に./が取り除かれるため、str(file_path)の出力結果には./が含まれません。「入力した文字列」と「実際に保持されているパス」が異なる点は誤解しやすいので、出力結果を確認する際は意識しておきましょう。
各プロパティの意味は次の通りです。

  • name : パスの一番最後の要素(ファイル名+拡張子)
  • stem : 拡張子を除いたファイル名
  • suffix : 拡張子(1つのみ)
  • suffixes : .tar.gzのような複数拡張子をすべてリストで取得
  • parent : 1つ上の親ディレクトリ
  • parents : 親ディレクトリの一覧で、parents[0]が直近の親、番号が大きくなるほどルートに近い階層

absolute()とresolve()はどちらも絶対パスに変換しますが、resolve()はさらにシンボリックリンクの解決や ..(1つ上の階層を表す記号)の正規化まで行う点が異なります。実際のファイルシステム上の場所を正確に取得したい場合はresolve()を使うのが安全です。

6. ファイル・ディレクトリの一覧取得

from pathlib import Path

data_dir = Path("./data")

# すべてのファイル・ディレクトリ
for item in data_dir.iterdir():
    print(item)

# グロブパターンでフィルタ
for csv_file in data_dir.glob("*.csv"):
    print(csv_file)

# 再帰的に検索
for csv_file in data_dir.rglob("*.csv"):
    print(csv_file)

# リストで取得
csv_files = list(data_dir.glob("*.csv"))

iterdir()は指定したディレクトリ直下の要素だけを取得します。特定の拡張子だけに絞り込みたい場合は glob()を使い、"*.csv"のように*(任意の文字列)を含んだパターンを指定します。
rglob()はglob()のサブディレクトリを含めた再帰版で、深い階層まで一括で検索したいときに便利です。ただし、対象ディレクトリの規模が大きい場合は探索に時間がかかることがあるため、大量のファイルを扱う処理では検索範囲を絞る、またはキャッシュを検討するなどの工夫も必要です。
なお、これらのメソッドはリストではなくジェネレータ(値を1つずつ返す仕組み)を返すため、件数を数えたり複数回使い回したりしたい場合はlist()で明示的にリスト化しておく必要があります。

FastAPIでの実践例

from fastapi import APIRouter, File, UploadFile, HTTPException
from pathlib import Path
from datetime import datetime

router = APIRouter()

# ベースディレクトリ
BASE_DIR = Path("./data")
BASE_DIR.mkdir(exist_ok=True)

@router.post("/upload-csv")
async def upload_csv_file(file: UploadFile = File(...)):
    """
    CSVファイルをアップロードして日付別ディレクトリに保存
    """

    if not file.filename.endswith('.csv'):
        raise HTTPException(status_code=400, detail="CSVファイルのみ可能")

    # 日付別ディレクトリを作成
    today = datetime.now().strftime("%Y%m%d")
    date_dir = BASE_DIR / today
    date_dir.mkdir(exist_ok=True)

    # ファイルパス
    file_path = date_dir / file.filename

    # 既存ファイルチェック
    if file_path.exists():
        raise HTTPException(status_code=400, detail="ファイルが既に存在します")

    try:
        contents = await file.read()
        file_path.write_bytes(contents)

        return {
            "message": "アップロード完了",
            "filename": file_path.name,
            "directory": str(file_path.parent),
            "full_path": str(file_path.absolute()),
            "file_size": len(contents)
        }

    except Exception as e:
        raise HTTPException(status_code=500, detail=str(e))
    finally:
        await file.close()

@router.get("/list-csv")
async def list_csv_files():
    """
    保存されているすべてのCSVファイルを一覧表示
    """
    csv_files = []

    for csv_file in BASE_DIR.rglob("*.csv"):
        csv_files.append({
            "filename": csv_file.name,
            "path": str(csv_file.relative_to(BASE_DIR)),
            "size": csv_file.stat().st_size,
            "modified": datetime.fromtimestamp(csv_file.stat().st_mtime).isoformat()
        })

    return {"files": csv_files, "total": len(csv_files)}

この例では、アップロードされたCSVファイルを日付ごとのディレクトリ(data/20240101のような形式)に振り分けて保存しています。BASE_DIR / todayのようにディレクトリ名を動的に組み立てられるのも、パスをオブジェクトとして扱えるpathlibならではの利点です。また relative_to()を使うことで、絶対パスではなくBASE_DIRからの相対パスとして結果を返しており、レスポンスにサーバー内部のフルパスが漏れるのを防いでいます。
実務で導入する際の注意点として、この例では file.filename(アップロード時にクライアントから送られてくるファイル名)をそのままパス結合に使っています。ここに ../../etc/passwdのような文字列が送られてくると、意図しない場所にファイルが書き込まれてしまう「パストラバーサル」と呼ばれる脆弱性につながる恐れがあります。実際にアップロード機能を実装する場合は、Path(file.filename).nameのようにファイル名部分だけを取り出す、または許可する文字種をホワイトリストでチェックするといった対策を併せて行うことを推奨します。

os.path との比較

import os
from pathlib import Path

# パスの結合
os_way = os.path.join("data", "file.csv")
path_way = Path("data") / "file.csv"

# 存在チェック
os_exists = os.path.exists("data/file.csv")
path_exists = Path("data/file.csv").exists()

# ファイル名取得
os_name = os.path.basename("data/file.csv")
path_name = Path("data/file.csv").name

# 拡張子取得
os_ext = os.path.splitext("file.csv")[1]
path_ext = Path("file.csv").suffix

os.pathは文字列を受け取って文字列を返す「関数」の集まりであるのに対し、pathlibはパスそのものを「オブジェクト」として扱い、そのオブジェクトに対してメソッドを呼び出す設計になっています。そのためos.path.join(...) → Path(...) / ...、os.path.basename(...) → Path(file.filename).nameのように、同じ処理でもコードの見た目がすっきりし、パス操作のたびに os.path. を書く必要がなくなります。既存コードをos.pathからpathlibに置き換える際は、この対応表を参考にすると移行しやすくなります。

メリット

  • オブジェクト指向設計 - パスを文字列ではなくオブジェクトとして扱える設計
  • 直感的な結合操作 - /演算子によるシンプルなパス結合
  • クロスプラットフォーム対応 - Windows/Linux/Macで共通して動作するコード
  • 豊富な操作メソッド - ファイル操作を簡潔に記述できるメソッド群
  • IDEとの高い親和性 - 型情報にもとづく入力補完のしやすさ

まとめ

pathlibは現代的なPythonコードでは標準的な選択肢です。
新規プロジェクトではos.pathよりpathlibの使用を推奨します。
パスの結合ミスやOS間の差異といった、地味ながら見落としやすいバグはpathlibを使うことでその多くを未然に防ぐことができます。既存プロジェクトを一度にすべて置き換える必要はありませんが、新しく書くコードから少しずつpathlibに切り替えていくことで、読みやすく壊れにくいファイル操作コードに近づけていけます。

このエントリーをはてなブックマークに追加
にほんブログ村 IT技術ブログへ

コメント

メールアドレスが公開されることはありません。 ※ が付いている欄は必須項目です