关于本文
本文是通过利用生成式 AI 的自动化生成流程创建的。我们参考了 Python 官方文档中的 csv、tempfile 和 os.replace,编写了在发生中途错误时保留原始 CSV 的代码。已在 Python 3.13.5 中执行了示例运行、语法检查以及 3 个单元测试。
验证状态:🧪 已在 Python 3.13.5 中本地运行,3 个单元测试均成功
未验证集成到 WordPress 或业务系统中的操作、Windows 环境以及突然断电的情况。
在 Python 中更新 CSV 时,如果直接写入现有文件,当途中发生异常时,可能会留下残缺不全的文件。
将新内容完全写入临时文件后再进行替换这样就能更好地避免至少因常规写入异常而导致原文件被写到一半就被覆盖的情况。
在这里,我们将仅使用标准库,尝试编写一个在正常结束时替换为新 CSV 且在验证错误时保留原 CSV 的程序。由于保存位置是每次都创建的用于学习的临时目录,因此不会触及业务数据。
这与直接写入的方法有什么不同?
如果像下面这样打开现有的 CSV,文件内容会在写入开始时被截断。
# 注意: 既存のファイルを直接書き換える例
with open("batch.csv", "w", encoding="utf-8", newline="") as fp:
fp.write("id,status\n")
# ここで処理が停止すると、元データは戻りません。
如果想要“仅在写入成功时切换为新内容”,先创建一个单独文件的方法会非常有效。
| 处理 | 直接覆盖 | 使用临时文件的方法 |
|---|---|---|
| 开始写入 | 写入到原文件 | 写入到临时文件 |
| 中途验证错误 | 源文件可能在半途被修改 | 如果在替换前,就可以保留源文件 |
| 完成时 | 直接结束 | 使用 os.replace 替换 |
| 注意事项 | 部分写入 | 针对并发更新或断电的额外防范措施需另行处理 |
在 POSIX 环境中,通过 os.replace 成功替换文件名的操作被定义为原子操作。但是,文件名替换是原子操作与在突发断电后必定能保留新数据是两码事。官方资料请参考Python os.replace。
经过验证的示例
此代码针对 Python 3.10 及以上版本。使用了标准库中的 csv、os、tempfile 和 pathlib。
from __future__ import annotations
import csv
import os
import tempfile
from pathlib import Path
from collections.abc import Iterable, Mapping
COLUMNS = ("id", "status")
def replace_csv(target: Path, rows: Iterable[Mapping[str, str]]) -> None:
"""CSVを書き終えるまで元ファイルを変更しない。"""
target = Path(target)
temporary: Path | None = None
try:
# 置換先と同じフォルダーに一時ファイルを作成する
with tempfile.NamedTemporaryFile(
mode="w", encoding="utf-8", newline="", delete=False,
dir=target.parent, prefix=f".{target.name}.", suffix=".tmp"
) as fp:
temporary = Path(fp.name)
writer = csv.DictWriter(fp, fieldnames=COLUMNS, extrasaction="raise")
writer.writeheader()
for row in rows:
# 入力列の過不足も検出し、失敗したら置換しない
if set(row) != set(COLUMNS):
raise ValueError("CSV row must have exactly id and status")
writer.writerow(row)
# Pythonのバッファを出し、OSにファイル内容の同期を依頼
fp.flush()
os.fsync(fp.fileno())
# 全件を書き終えてから、元CSVの名前へ置き換える
os.replace(temporary, target)
temporary = None
finally:
# 置換前に失敗したら、作った一時ファイルだけを削除
if temporary is not None:
temporary.unlink(missing_ok=True)
if __name__ == "__main__":
# 毎回新しいディレクトリにサンプルCSVを作る
with tempfile.TemporaryDirectory(prefix="csv-atomic-demo-") as folder:
csv_path = Path(folder) / "batch.csv"
csv_path.write_text("id,status\nold,OLD\n", encoding="utf-8")
replace_csv(csv_path, (
{"id": "A-001", "status": "DONE"},
{"id": "A-002", "status": "PENDING"},
))
with csv_path.open(newline="", encoding="utf-8") as fp:
entries = list(csv.DictReader(fp))
print(f"[RESULT] rows={len(entries)} ids={[x['id'] for x in entries]}")
放在 GitHub 上的可执行文件内容相同。
在示例文件夹中,可以从终端执行以下命令。
python3 atomic_csv.py python3 -m unittest -v test_atomic_csv.py
实际验证过的输出
在 Python 3.13.5 的测试环境中,得出了以下结果。
[RESULT] rows=2 ids=['A-001', 'A-002'] Ran 3 tests in 0.002s OK
这三项测试分别针对:首次创建与替换现有文件、发生中途错误时保留原文件,以及当值包含换行符时的 CSV 读取。测试的处理时间会因环境而异。
写入处理的顺序
在相同目录下创建临时文件
写入所有记录
检测中途的列不匹配并作为错误处理
执行 flush 和 fsync
关闭临时文件
使用 os.replace 进行替换
发生错误时清理替换前的临时文件
在相同目录下创建的原因
已将 target.parent 指定给 NamedTemporaryFile 的 dir。
with tempfile.NamedTemporaryFile(
mode="w",
encoding="utf-8",
newline="",
delete=False,
dir=target.parent
) as fp:
...
如果将临时文件创建在系统的默认临时目录中,可能会导致其位于与保存目标不同的文件系统中。由于 os.replace 在不同文件系统之间可能会失败,因此将其创建在同一目录下。
指定了 delete=False 的临时文件在程序关闭时不会被自动删除。因此,当发生异常时,也需要在 finally 中进行删除处理。详细规范可以在Python tempfile中确认。
将 newline 设为空字符串的原因
在 CSV 中,单元格内有时可能包含换行符。Python 的 csv 模块会处理此类值的 CSV 换行符,例如将它们用引号括起来。
with csv_path.open(encoding="utf-8", newline="") as fp:
rows = list(csv.DictReader(fp))
Python 官方的csv 文档但在处理文件时,给出了指定 newline="" 的示例。这是为了避免操作系统自身的换行符转换与 CSV 模块的处理发生冲突。
故意让它失败时,原有的 CSV 文件还在吗?
在接下来的测试中,尝试替换现有的 CSV 文件,但第二条记录缺少了列。
from pathlib import Path
from tempfile import TemporaryDirectory
with TemporaryDirectory() as folder:
path = Path(folder) / "batch.csv"
path.write_text("id,status\nOLD,OK\n", encoding="utf-8")
try:
replace_csv(path, (
{"id": "A", "status": "DONE"},
{"id": "MISSING"},
))
except ValueError as error:
print(f"[EXPECTED_ERROR] {error}")
print(path.read_text(encoding="utf-8"))
预期结果如下。
[EXPECTED_ERROR] CSV row must have exactly id and status id,status OLD,OK
在实际的单元测试中也确认了这一点。由于在替换完成之前就会引发异常,因此原始 CSV 的内容不会发生变化。写入途中产生的中转临时文件会通过 finally 块进行删除。
不过,这并不是一种跳过失败记录继续处理的实现。其设计理念是:一旦出现异常输入,就中止整个 CSV 文件的替换。
实际运维中需要注意的事项
| 常见情况 | 应对思路 |
|---|---|
| 其他程序也在向其写入数据 | 添加文件锁等机制,控制并发竞争 |
| 在 Windows 下,CSV 文件正处于 Excel 的打开状态 | 考虑到替换可能会被拒绝,编写错误处理与重试流程 |
| 磁盘空间不足 | 作为写入错误终止程序,并保护原始数据不被破坏 |
| 发生意外断电 | 需另外评估 fsync、目录同步以及存储系统的容错能力 |
| 现有 CSV 中包含重要数据 | 先进行备份和更新内容验证 |
| 处理大型 CSV | 为临时文件预留足够的磁盘空间 |
此方法适用于单个写入者安全侧更新单个 CSV 文件的用途。它并不保证数据库事务、并发控制或断电后的恢复。
在部署到生产环境时,请完整设计相同目录的权限、文件所有者、现有文件的模式、备份以及回滚。由于使用 os.replace 会使新的临时文件成为被替换后的文件,因此同样重要的是,不要假设原文件的访问权限等属性会被直接继承。
