pathlibはいいぞ

21468 ワード

こんにちわ alivelimb です。
Pythonista の皆さん、標準ライブラリに導入されているpathlibは使っていますでしょうか。
本記事では pathlib の魅力をお伝えします。

/の有無を意識する必要がない

ファイルパスを指定する時、どのように書いていますか?

一番最初に思いつくのは

filepath = "/path/to/file"

といった文字列形式でしょうか。
では以下のようなディレクトリ構成を考えてみます。
main.py からは data ディレクトリの csv ファイルを読込処理します。
csv ファイルは日別で作成されており、スキーマは毎日同じものとします。

root
├── data
│   ├── 20220101.json
│   └── 20220102.json
└── src
    └── main.py

この場合はどうファイルパスを指定するでしょうか。

target_date = "20220101"
filepath = f"/root/data/{target_date}.json"

これも悪くないですね。
しかし、現状 20220101.json だけだと何のデータか分からないため
/root/data/20220101/diary.json/root/data/2022/01/01/diary.jsonに変えることもあるでしょう。そこでディレクトリの方を変数化してみましょうか。

datadir = "/root/data/"
datedir = f"{year}/{month}/{day}/"
filepath = dirpath + "diary.json"

はい、ここです
/は親ディレクトリ側にいれますか、子ディレクトリ側に入れますか?
この辺りは人によって答えが別れるところじゃないでしょうか。
こんな時に pathlib を使うとスラッシュは関係がどちらについてていても問題ありません

from pathlib import Path

datadir = "/root/data" # 左端に/をつけていない
datedir = f"{year}/{month}/{day}/" # 左端に/をつけている
filepath = Path(datadir).joinpath(datedir).joinpath("dairy.json")

# 可変長引数を使った書き方(nslenderさんコメントありがとうございます!)
filepath = Path(datadir, datedir, "dairy.json")

# 演算子オーバーロードを用いた書き方(eduidlさんコメントありがとうございます!)
filepath = Path(datadir) / datedir / "dairy.json"

でもこれではos.path.joinと変わりませんが、
もちろん pathlib の魅力はこれだけではありません。

ファイルの読み書きが出来る

ファイルの読み書きをする時、どのように書いていますか?

一番最初に思いつくのは

# 読み取り
with open("/path/to/read_file") as f:
    data = f.read()

# 書き込み
with open("path/to/write_file", "w") as f:
    data = "write!"
    f.write()

といった with 句を用いた形式でしょうか。
これでも特に問題ないですが、pathlib を使うと短く書けます

# 読み取り
Path("/path/to/read_file").read_text()

# 書き込み
data = "write!"
Path("/path/to/write_file").write_text(data)

with 句で書きたい時もあるでしょう。これにも対応しています。

with Path("/path/to/read_file").open() as f:
    data = f.read()

「短くかけるだけかよ」と思うかもしれませんが、pathlib を使うことでファイルパスに関わる操作を全てメソッドにまとめることが出来るというのが大きなメリットです。これはos.pathの様々な関数を使わず、Path インスタンスを渡すだけであらゆる操作が出来るということです。

glob によるワイルドカード検索が出来る

ワイルドカードを用いてファイル検索をする時、どのように書いていますか?

一番最初に思いつくのは

import glob

filepaths = glob.glob("/path/to/*")

といった glob を用いた形式でしょうか。
os.pathだけではワイルドカード検索はできないため、globを import する必要があります。
pathlib を使えばglobを import する必要はありません。

filepaths = [p for p in Path("/path/to").glob("*")]

「おいおい、記述量増えてんじゃねーか」と思うかもしれませんが、pathlib の glob は generator を返すのでリスト形式に直すために記述量が増えているだけです。実際は検索結果を for 分で利用することが多いと思うのでデメリットにはならないでしょう。

Type Hints と組み合わせることでより安全な開発が出来る

私が Python で開発する際は Type Hints を積極的に書くようにしています。
Type Hints 自体の説明は他に良記事がたくさんあるため説明は他に譲りますが、ざっくり言うと本来動的型付け言語である Python に型を明示することで可読性・開発効率の向上に役立ちます。ただし、あくまで開発時に役立つだけで実行時に型が強制されるわけではないので注意が必要です。

Type Hints を活用して json ファイルから Diary クラスを作成する例を以下に示します。

import json

from dataclasses import dataclass
from datetime import datetime, date
from pathlib import Path
from typing import Any, Dict, Mapping

@dataclass
class Diary:
    title: str
    content: str
    author: str
    created_at: date
    updated_at: date

def json_parse(json_map: Dict[str, Any]) -> Mapping[str, Any]:
    for k, v in json_map.items():
        if k in ("created_at", "updated_at"):
            json_map[k] = datetime.strptime(v, "%Y-%m-%d").date()

    return json_map

def load_diary(datadir: Path, date: datetime) -> Diary:
    ymd = date.strftime("%Y%m%d")
    filepath = datadir.joinpath(f"{ymd}/dairy.json")
    data = json.loads(filepath.read_text(), object_hook=json_parse)
    return Diary(**data)

関数load_diaryで引数を str ではなく Path や datetime と明記することで以下のようなメリットがあります。

  • 表記揺れを防ぐことが出来る
    • Path であれば/の有無
    • datetime であれば2022-01-012022/01/01のような区切り文字の違い
  • IDE に補完が効くようになる

また、今回は Dairy クラスの定義にdataclassを使っているため、json 読み込み時に明示的に str から date 型へ変換処理を入れていますが、pydanticを使うと変換処理もいい感じにやってくれます。Type Hints も便利だなーと思ったら dateclass や pydantic の導入も検討してみて下さい(dataclass は標準ライブラリに導入されていますが、pydantic はインストールが必要です)

pathlib のデメリットはないの?

使っている上で気になるのは 1 点です。ファイルに出力する際に正しく出力されない場合がある
具体的には以下のようにjson.dumpでファイル出力する時です。

import json

from pathlib import Path

data = {
    "filepath": Path("/path/to/file")
}

with open("dumped.json", "w") as f:
    json.dump(data, f) # ここでエラー

これを実行するとTypeError: Object of type PosixPath is not JSON serializableが発生します。Path オブジェクト をシリアライズできない(ざっくり言うと json ファイルに書き込む際に文字列にするのか、数値にするのか判断できない)ためエラーになります。

これに対する対処法としては以下の 2 つが挙げられます。

json.dumpする前に str に変換する

data = {
    "filepath": Path("/path/to/file").as_posix() # as_posix()を追加
}

dump時にdefaultまたはclsを指定することでエンコード方式を指定する

json.dump ではよく行う方法です。
Path 以外にも datetime などのシリアライズにも利用できます。

関数で指定する場合

def json_encode(obj):
    if isinstance(obj, Path):
        return obj.as_posix()

with open("dumped.json", "w") as f:
    json.dump(data, f, default=json_encode)

クラスで指定する場合

class JSONEncoder(json.JSONEncoder):
    def default(self, obj):
        if isinstance(obj, Path):
            return obj.as_posix()

with open("dumped.json", "w") as f:
    json.dump(data, f, cls=JSONEncoder)

json.dumpだけでなく str 形式で必要な場合は str に変換する必要があります。これはデメリットと言えますが、他のメリットを加味した上で私は pathlib を使うようにしています。

これまで os.join を使いこなしてた方へ

そういった方も想定して、公式ドキュメントには対応関係が記載してあります。私自身os.joinを利用していましたが、pathlib に魅了され対応関係を参考にしつつ移行しました。この記事を読んで良さそうだと思った方は是非使ってみて下さい。

まとめ

pathlib の魅力について紹介しました。この他にも拡張子の取得や、拡張子を除いた部分の取得、ファイルの存在確認、空ディレクトリの作成など様々なメソッドが用意されています。詳しくは公式ドキュメントを確認してみて下さい。