xmlcst
Full-fidelity XML concrete syntax tree for Python -- parse, edit, and serialize with zero formatting loss.
Why xmlcst?
Existing Python XML libraries (ElementTree, lxml, minidom) parse XML into a semantic tree that discards lexical details: whitespace, comment placement, attribute quote styles, entity reference forms, and more. When you serialize back, the output differs from the input even if you changed nothing.
xmlcst takes a different approach. It treats XML as source text first and semantic structure second, producing a concrete syntax tree (CST) that retains every byte of the original document. When you edit a single attribute, only that attribute changes in the output -- surrounding formatting, comments, and whitespace remain untouched.
This makes xmlcst ideal for programmatic editing of XML configuration files (Maven POMs, .csproj files, Spring configs, Android manifests) where changes must produce minimal, reviewable diffs.
How xmlcst compares
| Feature | ElementTree | lxml | minidom | xmlcst |
|---|---|---|---|---|
| Attribute order | Partial | Partial | Partial | Preserved |
Quote style (' vs ") |
No | No | No | Preserved |
| Whitespace / indentation | No | No | No | Preserved |
| Comments | No | Yes | Yes | Preserved |
| Entity reference form | No | No | No | Preserved |
| CDATA vs escaped text | No | Yes | Yes | Preserved |
Empty-element syntax (<x/> vs <x />) |
No | No | No | Preserved |
| Byte-identical round-trip | No | No | No | Yes |
The closest conceptual analogue is ruamel.yaml -- a round-trip-capable YAML library -- applied to XML.
Installation
pip install xmlcst
Requires Python 3.12+. Pure Python -- no compiled dependencies. Ships PEP 561 type annotations for full mypy / pyright support.
Quick Start
Parse and round-trip
import xmlcst
source = '<project xmlns="http://maven.apache.org/POM/4.0.0">\n <version>1.0</version>\n</project>'
doc = xmlcst.parse(source)
assert doc.to_string() == source # byte-identical round-trip
Edit an attribute (minimal diff)
doc = xmlcst.parse('<root version="1.0" author="alice"/>')
doc.root.attributes["version"] = "2.0"
print(doc.to_string())
# <root version="2.0" author="alice"/>
# Only the value changed -- quotes, whitespace, other attributes untouched
Navigate the tree
doc = xmlcst.parse("""\
<project>
<dependencies>
<dependency>
<groupId>junit</groupId>
</dependency>
</dependencies>
</project>""")
deps = doc.root.find("dependencies")
dep = deps.find("dependency")
group = dep.find("groupId")
print(group.children[0].content) # "junit" (a Text node)
# Or search recursively
dep2 = doc.root.find_recursive("dependency")
all_deps = doc.root.findall_recursive("dependency")
Add and remove elements
doc = xmlcst.parse("<root>\n <a/>\n <b/>\n</root>")
doc.root.append(xmlcst.Element("c"))
print(doc.to_string())
# <root>
# <a/>
# <b/>
# <c/>
# </root>
Access formatting metadata
doc = xmlcst.parse('<root id = "1" name=\'foo\'/>')
attr = doc.root.attributes["id"]
print(attr.raw_value) # "1"
print(attr.quote) # '"'
print(attr.leading_whitespace) # " "
print(attr.eq_whitespace) # (" ", " ")
Work with entity references
doc = xmlcst.parse("<root>a & b</root>")
text = doc.root.children[0]
print(text.content) # "a & b" (raw, as in the source)
print(text.decoded_content()) # "a & b" (entities resolved)
text.set_content("x < y") # auto-escapes
print(text.content) # "x < y"
Sample Application
The samples/bump_pom_version/ directory contains a complete example: a Maven POM version bumper that reads a pom.xml, increments the patch version, and writes the file back. Only the version string changes -- all comments, whitespace, attribute quoting, and other formatting are preserved exactly.
python samples/bump_pom_version/bump_pom_version.py
# 1.2.3 -> 1.2.4
The script accepts an optional path argument to operate on any POM file:
python samples/bump_pom_version/bump_pom_version.py /path/to/your/pom.xml
API Overview
Parsing
| Function | Input | Returns |
|---|---|---|
xmlcst.parse(text) |
str |
Document |
xmlcst.parse_bytes(data) |
bytes |
Document |
xmlcst.parse_file(path) |
str | Path |
Document |
All parse functions raise xmlcst.ParseError on malformed input. The error includes message, line, column, and offset attributes.
Node Types
| Type | Description |
|---|---|
Document |
Root container; holds all top-level nodes |
Element |
An XML element with tag, attributes, and children |
Attribute |
Name-value pair with formatting metadata (quote style, whitespace) |
AttributeList |
Ordered collection with dict-like access by name |
Text |
Character data (entity references preserved in raw form) |
Whitespace |
Whitespace-only character data between markup |
Comment |
<!-- ... --> |
ProcessingInstruction |
<?target data?> |
CData |
<![CDATA[...]]> |
Doctype |
<!DOCTYPE ...> (preserved verbatim) |
XmlDeclaration |
<?xml version="1.0" ...?> |
Serialization
| Method | Description |
|---|---|
doc.to_string() |
Exact round-trip serialization (default) |
doc.to_string(mode="normalized") |
Pretty-printed with consistent formatting |
doc.to_bytes() |
UTF-8 encoded; BOM preserved if present in input |
doc.write(path) |
Write to file |
Design
xmlcst uses a dual-layer architecture:
- Token stream (Layer 1) -- a lossless sequence of tokens covering every byte of the input. The fundamental invariant:
"".join(t.text for t in tokens) == source. - Tree API (Layer 2) -- mutable nodes backed by the token stream. Each node tracks a token span and a dirty flag.
Unmodified nodes serialize by replaying their original tokens (byte-identical). Modified nodes rebuild from their current properties. This guarantees that edits produce the smallest possible diff.
See SPEC.md for the full specification.
Limitations (v1)
- UTF-8 encoding only
- XML 1.0 well-formed documents only (no error recovery)
- No DTD validation or schema support
- No XPath query engine
- No streaming / SAX-style parsing
- Pure Python (no compiled acceleration)
See the future roadmap in the specification for planned enhancements.
License
MIT
Metadata
Release files for xmlcst 0.1.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| xmlcst-0.1.0.tar.gz | 32.1 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| xmlcst-0.1.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 49.0 kB
Release files / xmlcst-0.1.0.tar.gz
| Download URL | xmlcst-0.1.0.tar.gz |
|---|---|
| Size | 32.1 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
0da82fdf7bf92c17cff0cf18cb5dc844b55881e2be6ec4a3adf8556758d1d9ef
|
|
BLAKE2b-256 checksum How to use checksums |
8bac9ec826c0d7ea85041e515f30895fef47d3e575d25e4cff3818349e789df1
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/6.1.0 CPython/3.13.12
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on May 17, 2026.
Transparency logRelease files / xmlcst-0.1.0-py3-none-any.whl
| Download URL | xmlcst-0.1.0-py3-none-any.whl |
|---|---|
| Size | 16.9 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
bd0178dc88474d6f1fd2bee3300d81ee4ca6f677f624d6f192f17853eacaf192
|
|
BLAKE2b-256 checksum How to use checksums |
522aff52110b65b6b070cb33e5d78ca6acd3e135491cf564df845b214122eb3b
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/6.1.0 CPython/3.13.12
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on May 17, 2026.
Transparency log