DEV Community

Wendy for AI MetaClean

Posted on AI-assisted

Verify XLSX metadata cleanup with Python

An XLSX cleanup report can tell you which fields it changed. For a regression check, I also want to inspect the downloaded file itself. Did the chosen author and company fields become empty or disappear? Did the sheets, formulas, and other package parts keep the same bytes?

This example uses a small demo workbook with fictional names, a custom ProjectCode, a formula, and a hidden sheet. The script uses Python's standard library. It reads two workbook copies and reports exactly which checks passed.

AI assisted with drafting and editing this English article. The code and reported results were checked against the demo files.

What to compare inside the workbook

An XLSX file is a ZIP package. In the sample, the author and last editor are in docProps/core.xml, the company name is in docProps/app.xml, and ProjectCode is in docProps/custom.xml. The sheet XML and the workbook definition sit under xl/.

Open a small workbook you control, save a working copy, and keep the original beside it. The example below uses a local browser cleanup as the source of the second copy. The same comparison works with another cleanup workflow that follows the same rule about which package parts it may change.

File selector showing the fictional demo workbook

The input is the demo workbook. Keep this copy for the comparison.

Property review showing a fictional author, company and project code

Record the fields you expect the cleaner to remove. If you use a different custom property name, change the ProjectCode filter in the script.

Check the property values and hash the other parts

The script parses the three property XML parts and compares SHA-256 hashes of the uncompressed bytes for every other file in the archive. It flags any added or removed part outside the three allowed property paths. Duplicate member names and unexpected property XML roots are treated as errors.

Save the following as verify_xlsx_cleanup.py.

"""Read-only regression check for this article's small XLSX fixture."""

import argparse
import hashlib
import json
from collections import Counter
from xml.etree import ElementTree as ET
from zipfile import BadZipFile, ZipFile

PROPERTY_PARTS = {"docProps/core.xml", "docProps/app.xml", "docProps/custom.xml"}
DC = "http://purl.org/dc/elements/1.1/"
CP = "http://schemas.openxmlformats.org/package/2006/metadata/core-properties"
APP = "http://schemas.openxmlformats.org/officeDocument/2006/extended-properties"
CUSTOM = "http://schemas.openxmlformats.org/officeDocument/2006/custom-properties"
ROOTS = {
    "docProps/core.xml": f"{{{CP}}}coreProperties",
    "docProps/app.xml": f"{{{APP}}}Properties",
    "docProps/custom.xml": f"{{{CUSTOM}}}Properties",
}


def xml_part(archive, path):
    if path not in archive.namelist():
        return None
    root = ET.fromstring(archive.read(path))
    if root.tag != ROOTS[path]:
        raise ValueError("Unexpected property XML root: " + path)
    return root


def text_at(root, name):
    node = None if root is None else root.find(name)
    return "" if node is None else "".join(node.itertext()).strip()


def snapshot(path):
    with ZipFile(path) as archive:
        names = [item.filename for item in archive.infolist() if not item.is_dir()]
        if any(count > 1 for count in Counter(names).values()):
            raise ValueError("Duplicate ZIP member names")
        if "xl/workbook.xml" not in names:
            raise ValueError("Missing xl/workbook.xml")
        core = xml_part(archive, "docProps/core.xml")
        app = xml_part(archive, "docProps/app.xml")
        custom = xml_part(archive, "docProps/custom.xml")
        project_codes = [] if custom is None else [
            "".join(node.itertext()).strip()
            for node in custom.findall(f"{{{CUSTOM}}}property")
            if node.get("name") == "ProjectCode"
        ]
        properties = {
            "creator": text_at(core, f"{{{DC}}}creator"),
            "lastModifiedBy": text_at(core, f"{{{CP}}}lastModifiedBy"),
            "company": text_at(app, f"{{{APP}}}Company"),
            "ProjectCode": project_codes,
        }
        other_parts = {
            name: hashlib.sha256(archive.read(name)).hexdigest()
            for name in names if name not in PROPERTY_PARTS
        }
        return properties, other_parts


def compare(before_path, after_path):
    before, old_parts = snapshot(before_path)
    after, new_parts = snapshot(after_path)
    changed = sorted(
        name for name in old_parts.keys() | new_parts.keys()
        if old_parts.get(name) != new_parts.get(name)
    )
    remaining = sorted(key for key, value in after.items() if value)
    print("Before properties:", json.dumps(before, ensure_ascii=False))
    print("After properties:", json.dumps(after, ensure_ascii=False))
    print("Remaining checked properties:", remaining)
    print("Changed/added/removed other parts:", changed)
    print("Other parts in output:", len(new_parts))
    if remaining or changed:
        print("REVIEW: one or more checks failed.")
        return 1
    print("OK: checked properties empty/absent; other part hashes match.")
    return 0


def main():
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("before")
    parser.add_argument("after")
    args = parser.parse_args()
    try:
        return compare(args.before, args.after)
    except (BadZipFile, ET.ParseError, OSError, RuntimeError, ValueError) as error:
        print("ERROR:", error)
        return 2


if __name__ == "__main__":
    raise SystemExit(main())
Enter fullscreen mode Exit fullscreen mode

Run it with the original workbook first and the cleaned copy second.

python3 verify_xlsx_cleanup.py excel-metadata-demo.xlsx excel-metadata-demo-clean.xlsx
Enter fullscreen mode Exit fullscreen mode

Read the result

In the demo, the original contained Sample Author, Sample Editor, Example Company, and DEMO-XLSX-001. The saved cleanup report listed ten removed metadata items. The independent script checked the four selected properties and found identical hashes for the other six parts.

Cleanup report for the saved fictional demo workbook

The tool report counts metadata items. The Python check below counts ZIP parts outside the three allowed property paths.

Remaining checked properties: []
Changed/added/removed other parts: []
Other parts in output: 6
OK: checked properties empty/absent; other part hashes match.
Enter fullscreen mode Exit fullscreen mode

Try the original as both arguments and the script exits with 1, listing the four remaining properties. A test copy with a changed cell also exits with 1 and identifies xl/worksheets/sheet1.xml. An added ZIP member is listed by name. An empty ProjectCode element still counts as a remaining property.

This comparison applies to small, trusted Transitional OOXML workbooks and the four chosen properties. A cleanup that intentionally rewrites other package parts will trigger review. Workbook rendering, recalculation, package relationships, and signatures each need their own checks. Review any sensitive values in cells, comments, hidden sheets, or external connections before sharing the final copy.

Source

The demo was produced by the AI MetaClean project. The project also documents the general workbook sharing workflow and Excel's built-in inspection steps. Excel metadata cleanup guide

Top comments (0)