JEPA4Japan · チュートリアル

ファイル、パス、CSV、JSON

2,257文字 7分で読めます #Python

pathlib、with、標準ライブラリのコンテキストマネージャー、CSV、JSONでローカルデータを安全に扱います。

コース進捗 コース目次 24レッスン中 24件を公開中

値に安全な棚を用意しよう

メモリ上の値は、プログラムが終わると消えます。ファイルなら次の実行でも見つけられますが、ファイルはプログラムの外にあります。存在しない、形が壊れている、書き込みが途中で止まる、といったことが起こります。

  1. Path 棚がある場所
  2. with 開く、使う、閉じる
  3. フォーマット 保存テキストの形
パスで場所を探し、withで管理し、フォーマットで内容を読みます。

保存場所は境界です。データをデコードして検証してから、ほかの処理に信頼させます。

パスを作り、ファイルを閉じよう

pathlib.Pathは、実行中のOSに合った規則でパスを結合します。

from pathlib import Path

path = Path("data") / "sessions.json"
print(path)
print(path.name)
print(path.parent)
print(path.is_absolute())

macOSやLinuxでの一般的な出力:

data/sessions.json
sessions.json
data
False

Windowsでは通常バックスラッシュになります。相対パスはプロセスの現在の作業ディレクトリを基準にし、そこはスクリプトの場所とは限りません。基準パスを引数で受け取るか、明確な規則から作りましょう。

exists()、is_file()、is_dir()は別々の質問に答えます。事前確認は動作を選ぶ助けになりますが、その後のopenの成功までは保証しません。二つの操作の間にファイルシステムが変わることもあります。

withを使うと、例外でブロックを出た場合もファイルが閉じます。文字コードも必ず明示します。

path = Path("plan.txt")

with path.open("w", encoding="utf-8") as file:
    file.write("Python:30分\n")

with path.open("r", encoding="utf-8") as file:
    print(file.read(), end="")

"r"は読み込み、"w"は古い内容を最初に空にして置き換え、"a"は末尾へ追記します。追記は独立したレコードに向きます。完全なJSON文書をもう1つ追記しても、1つの正しいJSON文書にはなりません。

CSVとJSONの規則に任せよう

CSVのフィールドにはカンマも入るため、split(",")で解析してはいけません。実際のCSVはnewline=""で開き、引用符と改行をcsvへ任せます。

import csv

with Path("sessions.csv").open("w", encoding="utf-8", newline="") as file:
    writer = csv.DictWriter(file, fieldnames=["topic", "minutes"])
    writer.writeheader()
    writer.writerow({"topic": "Data, files", "minutes": 40})

データ行は"Data, files",40になります。csv.DictReaderが返すフィールド値は文字列なので、数値へ変換し、検証する必要があります。

  1. dict / list Pythonの入れ物
  2. json.dump JSONテキストへ変換
  3. json.load Pythonの値へ戻す
  4. 検証 約束した形を確認
JSON構文が正しいだけでは半分です。戻した値の形も確認します。

JSONのobjectは辞書、arrayはリスト、nullはNone、booleanはTrueまたはFalseになります。文字列と数値も対応します。タプルはarrayとして保存され、リストとして戻ります。Path、集合、任意のオブジェクトは既定では保存できません。

デコード後に検証し、境界だけで回復しよう

json.load()は"hello"も正しくデコードできますが、プログラムが必要としているのはセッション辞書のリストかもしれません。最上位と各要素を確認します。

def validate_session(value):
    if not isinstance(value, dict) or set(value) != {"topic", "minutes"}:
        raise ValueError("a session needs topic and minutes")

    topic = value["topic"]
    minutes = value["minutes"]
    if not isinstance(topic, str) or not topic.strip():
        raise ValueError("topic must be non-empty text")
    if isinstance(minutes, bool) or not isinstance(minutes, int) or minutes < 0:
        raise ValueError("minutes must be a non-negative integer")

    return {"topic": topic.strip(), "minutes": minutes}

boolはintのサブクラスなので、別に確認します。きれいな新しい辞書を返せば、以降は1つの信頼できる形だけを扱えます。

  1. ファイル不在 空のデータで開始できる場合
  2. データ破損 行番号などを加えて報告
  3. 予想外 権限不足やバグを隠さない
失敗を理解し、次の動きを選べる境界だけで回復します。

ファイル不在が通常の初期状態なら、FileNotFoundErrorを捕捉できます。必要ならjson.JSONDecodeErrorへ分かりやすい文脈を加えます。すべてのExceptionを捕捉して空データを返すと、権限不足やバグまで隠してしまいます。

永続化する学習リポジトリを作ろう

完全な標準ライブラリのプロジェクトです。study_repository.pyへ保存します。

import csv
import json
from pathlib import Path
from tempfile import TemporaryDirectory


def validate_session(value):
    if not isinstance(value, dict) or set(value) != {"topic", "minutes"}:
        raise ValueError("a session needs topic and minutes")
    topic = value["topic"]
    minutes = value["minutes"]
    if not isinstance(topic, str) or not topic.strip():
        raise ValueError("topic must be non-empty text")
    if isinstance(minutes, bool) or not isinstance(minutes, int) or minutes < 0:
        raise ValueError("minutes must be a non-negative integer")
    return {"topic": topic.strip(), "minutes": minutes}


def load_sessions(path):
    try:
        with path.open("r", encoding="utf-8") as file:
            data = json.load(file)
    except FileNotFoundError:
        return []
    except json.JSONDecodeError as error:
        raise ValueError(f"invalid JSON at line {error.lineno}") from error

    if not isinstance(data, list):
        raise ValueError("session data must be a list")
    return [validate_session(item) for item in data]


def save_sessions(path, sessions):
    cleaned = [validate_session(item) for item in sessions]
    path.parent.mkdir(parents=True, exist_ok=True)
    temporary = path.with_suffix(path.suffix + ".tmp")
    try:
        with temporary.open("w", encoding="utf-8") as file:
            json.dump(cleaned, file, ensure_ascii=False, indent=2)
            file.write("\n")
        temporary.replace(path)
    finally:
        temporary.unlink(missing_ok=True)


def export_csv(path, sessions):
    path.parent.mkdir(parents=True, exist_ok=True)
    with path.open("w", encoding="utf-8", newline="") as file:
        writer = csv.DictWriter(file, fieldnames=["topic", "minutes"])
        writer.writeheader()
        writer.writerows(sessions)


def main():
    with TemporaryDirectory() as directory:
        base = Path(directory)
        json_path = base / "sessions.json"
        csv_path = base / "sessions.csv"
        source = [
            {"topic": " Python ", "minutes": 45},
            {"topic": "Data, files", "minutes": 25},
        ]

        assert load_sessions(json_path) == []
        save_sessions(json_path, source)
        restored = load_sessions(json_path)
        export_csv(csv_path, restored)

        assert restored[0]["topic"] == "Python"
        assert '"Data, files",25' in csv_path.read_text(encoding="utf-8")
        print(f"往復成功:{len(restored)}件、70分")
        print("テストに合格しました。")


if __name__ == "__main__":
    main()

出力:

往復成功:2件、70分
テストに合格しました。

書く前に検証しています。隣の作業用ファイルから置き換えると、不完全な保存先が残る危険を大きく減らせます。ただし、あらゆるクラッシュ、永続性、権限、複数の書き手に対する万能な保証ではありません。

3つの小さなチャレンジと次への準備

  1. 追記する日記。 "a"で独立した2行を書き、2回実行して4行になることを確認します。1つのJSON文書に使えない理由も説明します。
  2. CSVの読み込み。 csv.DictReaderでtopicとminutesだけを要求し、分数を変換して各行を検証します。カンマを含むテーマでも試します。
  3. 古いファイルを守る。 正しいJSONを保存してから負の分数を保存しようとします。ValueErrorの後も、古い保存先を読めることを確認します。

注意点:"w"はすぐ古い内容を空にし、相対パスは作業ディレクトリに依存します。デコード済みJSONも形が違うことがあり、広すぎる例外処理は本当の失敗を隠します。

  • Pathでパスを結合し、調べられる
  • with、UTF-8、正しいファイルモードを使える
  • 実際のCSVでnewline=""を使う理由が分かる
  • JSONの値を対応付け、デコード後の構造を検証できる
  • 境界で想定内の失敗からだけ回復できる
  • 一時ディレクトリのプロジェクトで往復保存できる
  • 3つの小さなチャレンジを終えた

次章では、正しい状態と関係する振る舞いを、クラスとオブジェクトの中へまとめます。