春江暮客

春江暮客的个人学习分享网站

用 Python iterparse 流式转换 XML 到 CSV:释放节点,保留完整报告

2026-11-03 技术
用 Python iterparse 流式转换 XML 到 CSV:释放节点,保留完整报告

XML 导出文件变大后,ET.parse() 构建整棵树,内存也跟着上涨;改成 iterparse() 后,如果仍把已完成的节点挂在根节点上,内存问题并没有真正解决。

本文面向初级到中级读者,用 Python 标准库逐条读取本地库存 XML,导出 CSV,并在文件尾部损坏时保留旧报告。只处理来源可信、结构已知、转换期间不再变化的普通本地文件,不接收上传或不可信网络 XML。

1. 方法:在 end 事件处理整条记录

  1. 输入结构固定为一个 inventory 根节点,直接子节点都是 item。每条记录需要非空 sku 属性,以及各一个纯文本 name、quantity 子节点;数量只接受 ASCII 非负整数字符串。
  2. 收到 item 的 end 事件后再读字段。写完一行后调用 root.remove(node),再清空节点,不积累已处理的兄弟节点。
  3. CSV 先写到输出目录里的临时文件,完整解析并关闭文件后才替换最终报告。失败返回 1,不替换旧报告;成功会覆盖同名旧报告。

ElementTree 官方说明指出,iterparse() 是阻塞读取,完整元素应在 end 事件处理。只调用 clear() 会清空内容,但父节点仍持有空节点;这里显式移除已完成的直接子节点。保留的数据主要随单条记录与解析缓冲变化,而不是把全部记录收集成列表;这不是固定内存上限,超大单节点仍会占用内存。

若只是检查 Hugo 站点地图中的 URL,可使用已有的 Hugo sitemap 检查器。本文解决的是大量重复 XML 记录的流式转换,不替代站点地图的业务规则。

2. 准备:创建独立演示目录

使用现有 Python 3.9 或更新版本,无第三方依赖。以下命令在 Linux、Python 3.11.4 下验证,可在 Bash 或 zsh 中执行。

  1. 在同一终端创建新目录;重复练习时换一个目录名,避免覆盖演示文件:
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 &amp; spacer</name><quantity>0</quantity></item>
</inventory>
XML
  1. 将下一节保存为 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 的 &amp; 已解码:

sku,name,quantity
001,"螺母, M6",12
002,Washer & spacer,0

文件或数据错误返回 1,错误写入 stderr;命令行用法错误返回 2。成功返回 0,仅 stdout 打印数量,不把 CSV 混入日志。不要只看目标文件是否存在:失败时它可能仍是上一轮的报告,调用方必须检查退出码。

5. 验证:文件尾损坏与十万条记录

  1. 验证 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
  1. 生成十万条小记录,转换后逐条计数,不把 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 结构调整命名空间与字段规则。

标签

1024 12306 2fa ablang adsense agents.md ai ai-agent ai-agents ai-seo algorithm amp antibodies apparmor argparse automation backup bash batch-processing binarycif bioinformatics biopython blockchain boltz bootstrapping boxes bubblewrap c-index cca cdn chatgpt checkpoint cli cloudflare codex cofoldarena concurrency configparser configuration copy cprofile cpu监控 cron csv cuda curl data-leakage data-processing data-quality data-validation datascience datavisualization datetime debugging decimal deployment desktop-app devtools difflib disk-space disown dns docker dovecot download electron encoding esm esm2 esm3 esmc esmfold2 etag evaluation faceswap fasta fastmcp faulthandler ffmpeg file-descriptors file-io file-management filenames files flashppi flask folium frontend game generator git github-actions google google-research gpt-6.1-sol grep gzip harness heapq hls html http hugo imports indexnow ipaddress jailbreak javascript jev json jsonl just k-means kaggle langfuse leecode linux list litellm llm llms.txt logging logs lollipop lsof m3u8 machine-learning macos manacher matplotlib mcp memory mirror mmcif model-evaluation monitoring mp3 mp4 mpnn multiomics multiprocessing mutation mysql nanobert nanobodies networking networkx nginx normalize numpy ollama omegatherm pandas password pathlib pdb pep-723 performance permissions phaser pillow pip postfix preprocessing print prompt-engineering protein-alignment protein-design protein-embeddings protein-interactions protein-language-models protein-stability protein-structure proxy pydantic pyecharts pyqt python python3 r raincloud reproducibility requests reservoir-sampling rfoptimization rg ripgrep rosettafold3 roundcube rrsi rsi rsync s-tui sampling scale scikit-learn scrapy screen seaborn security selenium seo sequence-identity sha256 shell signals sitemap sklearn socket solana somaticsignatures sorting spl sqlite ssh standardize static-site subprocess symlink sysadmin system-one tarfile tcp tempfile tensorflow tkinter tls toml totp tracemalloc tron tronpy troubleshooting turtle typesafe-ai ubuntu unicode urllib usdt utf-8 uv venv vhhbert vite web-tools webp wordcloud wordpress workflow xml yaml zipfile zoneinfo 后台 寓言 概率 经济 贸易 迅雷解析 钱包

友情链接

其它