用 Python iterparse 流式转换 XML 到 CSV:释放节点,保留完整报告
XML 导出文件变大后,ET.parse() 构建整棵树,内存也跟着上涨;改成 iterparse() 后,如果仍把已完成的节点挂在根节点上,内存问题并没有真正解决。
本文面向初级到中级读者,用 Python 标准库逐条读取本地库存 XML,导出 CSV,并在文件尾部损坏时保留旧报告。只处理来源可信、结构已知、转换期间不再变化的普通本地文件,不接收上传或不可信网络 XML。
1. 方法:在 end 事件处理整条记录
- 输入结构固定为一个
inventory根节点,直接子节点都是item。每条记录需要非空sku属性,以及各一个纯文本name、quantity子节点;数量只接受 ASCII 非负整数字符串。 - 收到
item的end事件后再读字段。写完一行后调用root.remove(node),再清空节点,不积累已处理的兄弟节点。 - CSV 先写到输出目录里的临时文件,完整解析并关闭文件后才替换最终报告。失败返回 1,不替换旧报告;成功会覆盖同名旧报告。
ElementTree 官方说明指出,iterparse() 是阻塞读取,完整元素应在 end 事件处理。只调用 clear() 会清空内容,但父节点仍持有空节点;这里显式移除已完成的直接子节点。保留的数据主要随单条记录与解析缓冲变化,而不是把全部记录收集成列表;这不是固定内存上限,超大单节点仍会占用内存。
若只是检查 Hugo 站点地图中的 URL,可使用已有的 Hugo sitemap 检查器。本文解决的是大量重复 XML 记录的流式转换,不替代站点地图的业务规则。
2. 准备:创建独立演示目录
使用现有 Python 3.9 或更新版本,无第三方依赖。以下命令在 Linux、Python 3.11.4 下验证,可在 Bash 或 zsh 中执行。
- 在同一终端创建新目录;重复练习时换一个目录名,避免覆盖演示文件:
python3 --version
mkdir xml-stream-demo
cd xml-stream-demo
cat > inventory.xml <<'XML'
<?xml version="1.0" encoding="UTF-8"?>
<inventory xmlns="urn:demo:inventory">
<item sku="001"><name>螺母, M6</name><quantity>12</quantity></item>
<item sku="002"><name>Washer & spacer</name><quantity>0</quantity></item>
</inventory>
XML
- 将下一节保存为
xml_to_csv.py。后续命令都在当前目录执行;真实输出目录应由你控制,并且已经存在。
3. 实现:逐条转换,成功后再替换报告
"""Stream trusted local inventory XML into CSV. Python 3.9+."""
import argparse
import csv
import os
from pathlib import Path
import sys
import tempfile
import xml.etree.ElementTree as ET
NS = "{urn:demo:inventory}"
def field(item, name):
matches = item.findall(NS + name)
if len(matches) != 1 or len(matches[0]) != 0:
raise ValueError(f"expected one plain-text {name}")
value = (matches[0].text or "").strip()
if not value:
raise ValueError(f"empty {name}")
return value
def convert(source, target):
writer = csv.writer(target, lineterminator="\n")
writer.writerow(["sku", "name", "quantity"])
root = None
depth = count = 0
for event, node in ET.iterparse(source, events=("start", "end")):
if event == "start":
depth += 1
if depth == 1:
root = node
if root.tag != NS + "inventory":
raise ValueError("expected inventory root with demo namespace")
continue
if depth == 2:
if node.tag != NS + "item":
raise ValueError("expected item directly under inventory")
sku = node.get("sku", "").strip()
if not sku:
raise ValueError("missing sku attribute")
name = field(node, "name")
quantity = field(node, "quantity")
if not quantity.isascii() or not quantity.isdecimal():
raise ValueError("quantity must contain ASCII digits only")
writer.writerow([sku, name, int(quantity)])
count += 1
root.remove(node)
node.clear()
depth -= 1
if count == 0:
raise ValueError("inventory contains no items")
return count
def main():
parser = argparse.ArgumentParser(description=__doc__, allow_abbrev=False)
parser.add_argument("input", type=Path)
parser.add_argument("output", type=Path)
args = parser.parse_args()
temporary = None
try:
if args.input.resolve() == args.output.resolve():
raise ValueError("input and output must be different paths")
with args.input.open("rb") as source:
with tempfile.NamedTemporaryFile(
mode="w", encoding="utf-8", newline="",
dir=args.output.parent, prefix=".xml-csv-", delete=False,
) as target:
temporary = Path(target.name)
count = convert(source, target)
os.replace(temporary, args.output)
except (OSError, ValueError, ET.ParseError) as exc:
print(f"ERROR: {exc}", file=sys.stderr)
return 1
finally:
if temporary is not None:
temporary.unlink(missing_ok=True)
print(f"OK: {count} items")
return 0
if __name__ == "__main__":
raise SystemExit(main())
命名空间使用 {urn:demo:inventory} 展开名,不能直接查找裸 name。文件使用默认命名空间还是不同前缀不影响匹配,URI 必须一致;sku 是不带命名空间的属性。字段规则只覆盖本例需要的内容,额外的 item 子字段会被忽略,不做完整 XML Schema 校验,也不检查 SKU 全局唯一性。
临时文件与目标在同一目录,避免跨文件系统替换。常规解析或字段错误会关闭并删除临时文件;这是发布完整结果的保护,不是断电持久性保证。成功替换后文件采用临时文件的权限,不保留旧报告权限;相关写入原理可参考 Python 原子保存 JSON。
4. 运行:检查帮助、输出与退出码
python3 xml_to_csv.py --help
python3 xml_to_csv.py inventory.xml inventory.csv
成功输出:
OK: 2 items
inventory.csv 内容如下,逗号字段会正确加引号,XML 的 & 已解码:
sku,name,quantity
001,"螺母, M6",12
002,Washer & spacer,0
文件或数据错误返回 1,错误写入 stderr;命令行用法错误返回 2。成功返回 0,仅 stdout 打印数量,不把 CSV 混入日志。不要只看目标文件是否存在:失败时它可能仍是上一轮的报告,调用方必须检查退出码。
5. 验证:文件尾损坏与十万条记录
- 验证 CSV 回读,并故意删除根节点结束标签。这个错误发生在记录写入临时文件之后,仍不应破坏旧报告:
python3 - <<'PY_CHECK'
import csv
from pathlib import Path
import subprocess
import sys
with open("inventory.csv", encoding="utf-8", newline="") as source:
assert list(csv.reader(source)) == [
["sku", "name", "quantity"],
["001", "螺母, M6", "12"],
["002", "Washer & spacer", "0"],
]
before = Path("inventory.csv").read_bytes()
original = Path("inventory.xml").read_bytes()
Path("truncated.xml").write_bytes(original.replace(b"</inventory>", b""))
result = subprocess.run(
[sys.executable, "xml_to_csv.py", "truncated.xml", "inventory.csv"],
capture_output=True, text=True,
)
assert result.returncode == 1, result
assert result.stdout == "" and result.stderr.startswith("ERROR:")
assert Path("inventory.csv").read_bytes() == before
assert Path("inventory.xml").read_bytes() == original
assert not list(Path(".").glob(".xml-csv-*"))
print("PASS: CSV round-trip; truncated XML preserves old output")
PY_CHECK
- 生成十万条小记录,转换后逐条计数,不把 CSV 整体放入列表:
python3 - <<'PY_DATA'
with open("many.xml", "w", encoding="utf-8") as target:
target.write('<inventory xmlns="urn:demo:inventory">\n')
for index in range(100000):
target.write(
f'<item sku="{index:06d}"><name>Part {index}</name>'
'<quantity>1</quantity></item>\n'
)
target.write('</inventory>\n')
PY_DATA
python3 xml_to_csv.py many.xml many.csv
python3 - <<'PY_COUNT'
import csv
with open("many.csv", encoding="utf-8", newline="") as source:
rows = csv.reader(source)
assert next(rows) == ["sku", "name", "quantity"]
assert sum(1 for _ in rows) == 100000
print("PASS: 100000 CSV records")
PY_COUNT
应看到 OK: 100000 items 和 PASS: 100000 CSV records。这验证转换和记录数,不是跨机器的内存基准;若要测量峰值,需固定 Python、输入结构和系统工具。下游继续处理 CSV 时,也应保持 逐条读取 CSV 的方式。
6. 排错:原因与直接修复
| 问题 | 原因与修复 |
|---|---|
| 提示根节点命名空间错误 | 核对实际根标签和 URI;不要通过删除所有命名空间强行匹配。 |
expected one plain-text name |
缺字段、重复字段或字段里有子标签;修复输入,或明确设计混合内容规则。 |
| 数量错误 | 空值、负号、小数或非 ASCII 数字不符合本例约定;更正数据,不要静默转成 0。 |
| 内存仍持续增长 | 不要保存每条记录到列表,不要只 clear();确认输入确实是根节点下的重复记录。 |
| 输出目录不存在或不可写 | 先选择已存在的可写目录;不要通过全局放宽权限绕过错误。 |
XML 末尾报 ParseError |
先修复或重新导出源文件,再重跑;旧报告未被替换不代表这次成功。 |
Python 官方 XML 安全说明提醒,恶意 XML 可能消耗大量资源。流式读取不等于安全沙箱;不要把本脚本直接用于不可信上传。本例也不防止表格软件公式解释,若将外部文本导入电子表格,应按接收端策略另做处理。
7. 总结
处理大量重复 XML 记录时,在 end 事件读取完整记录,并移除已处理的兄弟节点,避免把整棵树留在内存里。先写临时 CSV、完整成功后再替换报告,能让尾部损坏的输入保留旧结果。运行方仍要检查退出码,并根据实际 XML 结构调整命名空间与字段规则。
- 原文作者:春江暮客
- 原文链接:https://www.bobobk.com/python-stream-xml-iterparse-csv.html
- 版权声明:本作品采用 知识共享署名-非商业性使用-禁止演绎 4.0 国际许可协议 进行许可,非商业转载请注明出处(作者,原文链接),商业转载请联系作者获得授权。