JEPA4Japan · 教程

文件、路径、CSV 与 JSON

2,011字 6分钟阅读 #Python

使用 pathlib、with、标准库上下文管理器、CSV 和 JSON,安全地读写本地数据。

课程进度 课程大纲 已发布 24/24 课

给值一个安全的架子

程序停止后,内存中的值就会消失。文件能让程序在下次运行时再次找到这些值——但文件存在于程序的控制范围之外。它们可能不存在、格式有误,或者只写入了一部分。

  1. 路径 架子在哪里
  2. with 打开、使用,然后关闭
  3. 格式 存储的文本采用什么结构
路径用来找到文件,上下文负责管理文件,格式则说明如何理解文件。

把存储视为一道边界:在程序的其他部分信任数据之前,先解码并验证数据。

构建路径并关闭文件

pathlib.Path 会按照当前操作系统的规则连接路径的各个部分:

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() 回答的是不同的问题。检查结果可以帮助你做出选择,但无法保证之后打开文件一定会成功;在检查和打开这两个操作之间,文件系统可能已经发生变化。

使用 with,这样即使异常导致程序离开代码块,已打开的文件也会被关闭。一定要明确指定文本编码:

path = Path("plan.txt")

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

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

"r" 用于读取,"w" 用于替换内容,并且会先截断原有内容,"a" 则用于追加。追加模式适合相互独立的记录;但在一个完整的 JSON 文档后面再追加第二个完整的 JSON 文档,不会形成一个有效的 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 对象会变成字典,数组会变成列表,null 会变成 None,布尔值会变成 True 或 False。字符串和数字可以自然对应。元组会被编码为数组,并在解码后变成列表;默认情况下不支持 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 很重要,因为 bool 是 int 的子类。返回一个干净的字典,这样后面的代码就能收到一种可信且一致的结构。

  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"Round trip: {len(restored)} sessions, 70 minutes")
        print("Tests passed.")


if __name__ == "__main__":
    main()

输出:

Round trip: 2 sessions, 70 minutes
Tests passed.

验证会在写入之前进行。使用同一目录中的临时文件,可以大幅降低目标文件只写入一半的风险,但它并不能对崩溃、持久性、权限问题或多个写入者做出普遍保证。

三个小任务与本章交接

  1. 追加日记。 使用模式 "a" 写入两行相互独立的内容;运行两次,并确认共有四行。说明为什么这种设计不适用于单个 JSON 文档。
  2. 导入 CSV。 使用 csv.DictReader,要求字段必须恰好是 topic 和 minutes,转换分钟数,并验证每一行。测试一个包含逗号的主题。
  3. 保留旧文件。 保存有效的 JSON,然后尝试保存负数分钟。确认发生 ValueError,并且旧的目标文件仍然可以加载。

容易踩坑的地方:"w" 会立即截断文件,相对路径取决于工作目录,解码后的 JSON 可能具有错误的结构,而过于宽泛的异常处理可能掩盖真正的故障。

  • 我可以使用 Path 组合并检查路径。
  • 我可以使用 with、UTF-8 和正确的文件模式。
  • 我知道为什么真正的 CSV 文件要使用 newline=""。
  • 我可以对应 JSON 值,并验证解码后的结构。
  • 我只在边界处处理预期内的故障。
  • 我的临时目录项目完成了完整的往返过程。
  • 我完成了三个小任务。

接下来,你将把有效状态及其相关行为一起放入类和对象中。