XML to Dict Pipeline Specification¶
This document specifies how the XML to Dict ingestion pipeline
(cdm_data_loaders.pipelines.xmltodict.pipeline, cdm_data_loaders.pipelines.xmltodict.settings.XmlToDictSettings,
and cdm_data_loaders.readers.xml.process_xml_file_to_dict) discovers, pre-processes, streams, parses, transforms,
and loads XML (.xml and .xml.gz) files without an XSD schema:
- Target selection, file discovery,
.gzdecompression, and file pre-processing (XmlToDictSettings,build_per_file_filesystem_resource,REJECTED_PYARROW_SCHEMA). - Outermost
<xml_tag>streaming andxmltodictparsing (stream_xml_file,_parse_element_to_dict). - Table transforms, PyArrow output schema coercion, and pipeline reporting (
route_and_coerce_record,DictBuffer,run_xmltodict_pipeline).
| Property | Value |
|---|---|
| Base Pipeline Family | xmltodict |
| Base CLI Entry Point | xmltodict |
| Base Settings Class | cdm_data_loaders.pipelines.xmltodict.settings.XmlToDictSettings |
| Pipeline Runner | cdm_data_loaders.pipelines.xmltodict.pipeline.run_xmltodict_pipeline |
| Preset Subclasses Covered | — |
| Specification Directory | specs/pipelines/xmltodict/ |
Running Smoke Tests Across Environments¶
# Run all base and preset smoke tests for xmltodict across built-in environments
uv run pytest tests/cdm_data_loaders/pipelines/test_pipeline_smoke_spec.py --smoke-pipeline xmltodict
# Run against a custom environment directory (config.toml, secrets.toml, settings.toml)
uv run pytest tests/cdm_data_loaders/pipelines/test_pipeline_smoke_spec.py \
--smoke-pipeline xmltodict \
--dlt-config-dir /path/to/env_dlt_config \
--settings-file /path/to/env_dlt_config/settings.toml
1. Conversion & Pipeline Rules¶
1.1 Target Selection, File Discovery, and Pre-processing¶
- R-XMLTODICT-TARGET-AND-GLOB:
XmlToDictSettingsrequiresdataset_name,table_name(-t), andxml_tag(-x), and discovers matching files insettings.input_dirusingsettings.file_glob(default"*.xml*"), including nested subdirectories and.gz-compressed XML archives. Non-matching files are ignored.
Cases: spec/01_outermost_elements_gzip_and_xmlns
- R-XMLTODICT-PREPROCESS-MD5: Before streaming an XML file,
build_per_file_filesystem_resourcerunssettings.get_file_preprocessors(settings.table_name)(resolved frompreprocessors_module, e.g.validate_md5_sidecar). When a pre-processor raisesFilePreprocessError, the file is skipped, omitted fromingested_files, recorded inreport.txtload_errors(status: completed with errors), and routed to_rejected(REJECTED_PYARROW_SCHEMA) withtable_name=settings.table_name,source_file=<rel_path>,line_no=None, andraw_record=None.
Cases: spec/02_md5_sidecar_preprocessing
| Column | PyArrow DataType |
Field.nullable |
Description |
|---|---|---|---|
table_name |
pa.string() |
True |
Target entity table name (None for level-mode file pre-processor errors) |
source_file |
pa.string() |
True |
Input file path relative to input_dir |
line_no |
pa.int64() |
True |
1-based physical line number in source_file (None for file-level errors) |
raw_record |
pa.string() |
True |
Stripped raw input line (None for file-level errors) |
error_detail |
pa.string() |
True |
Parser error message, JSON-encoded list of schema errors, or pre-processor error |
1.2 Outermost Element Streaming and xmltodict Parsing¶
- R-XMLTODICT-OUTERMOST-STREAM:
stream_xml_filestreams only the outermost XML elements matchingsettings.xml_tagusinglxml.etree.iterparse(..., events=("start", "end"), tag=xml_tag, remove_blank_text=True)and clears processed subtrees from memory. Any<xml_tag>element nested inside an enclosing<xml_tag>is preserved in place as part of the outer element rather than emitted as a separate record.
Cases: spec/01_outermost_elements_gzip_and_xmlns, spec/03_table_transforms_and_output_schemas
- R-XMLTODICT-PARSE:
_parse_element_to_dictserializes each streamed element withlxml.etree.tostringand parses it viaxmltodict.parse(..., attr_prefix="_"), producing{"<xml_tag>": {...}}with attributes prefixed by_, element text in#textwhen combined with attributes, and root-level_xmlns*namespace declarations stripped.
Cases: spec/01_outermost_elements_gzip_and_xmlns, spec/03_table_transforms_and_output_schemas
1.3 Table Transforms, PyArrow Output Schemas, and Reporting¶
- R-XMLTODICT-TRANSFORMS: Each parsed
{"<xml_tag>": {...}}dictionary is routed throughroute_and_coerce_recordwithdefault_table=settings.table_name,transform=settings.get_table_transform(settings.table_name)(transforms_module), andschema_map=settings.schema_map(output_schemas_module). Transforms may unwrap the outer<xml_tag>key, filter records ({}/None), or fan out child elements across multiple destination tables coerced topa.Tablebatches viaDictBuffer.
Cases: spec/03_table_transforms_and_output_schemas
- R-XMLTODICT-REPORT:
run_xmltodict_pipelineexecutes thexmltodict_readerresource (parallelized=False) in a singlepipeline.runcall and writesreport.txt(status,ingested_files,load_errors,pipeline_load_info_errs) and_load_info.
Cases: spec/01_outermost_elements_gzip_and_xmlns, spec/02_md5_sidecar_preprocessing, spec/03_table_transforms_and_output_schemas
2. Case Coverage Matrix¶
| Suite | Target CLI | Settings Class | Case | Title | Rules | Expected Table Counts |
|---|---|---|---|---|---|---|
spec |
xmltodict |
XmlToDictSettings |
01_outermost_elements_gzip_and_xmlns |
Outermost <xml_tag> Streaming, Self-Nested Tags, Gzip Decompression, and _xmlns Stripping |
R-XMLTODICT-TARGET-AND-GLOB, R-XMLTODICT-OUTERMOST-STREAM, R-XMLTODICT-PARSE, R-XMLTODICT-REPORT |
default: {'books': 2} |
spec |
xmltodict |
XmlToDictSettings |
02_md5_sidecar_preprocessing |
MD5 Sidecar Pre-processing and File-Level _rejected Routing |
R-XMLTODICT-PREPROCESS-MD5, R-XMLTODICT-REPORT |
partial_md5_failure: {'books': 1, '_rejected': 1} |
spec |
xmltodict |
XmlToDictSettings |
03_table_transforms_and_output_schemas |
Table Transforms, Unwrapping <xml_tag>, Multi-Table Fan-Out, and PyArrow Output Schemas |
R-XMLTODICT-OUTERMOST-STREAM, R-XMLTODICT-PARSE, R-XMLTODICT-TRANSFORMS, R-XMLTODICT-REPORT |
default: {'books': 2, 'book_authors': 3} |
3. Base Pipeline Specification Cases (xmltodict / spec/)¶
Shared Fixture Module (specs/pipelines/xmltodict/transforms.py)
"""File pre-processors, table transforms, and PyArrow output schemas for XML to Dict specification cases."""
from typing import Any, Final
import pyarrow as pa
from cdm_data_loaders.core.preprocessors import validate_md5_sidecar
from cdm_data_loaders.core.registry import FilePreprocessFn, RecordTransformFn
FILE_PREPROCESSORS: tuple[FilePreprocessFn, ...] = (validate_md5_sidecar,)
CATALOG_BOOKS_SCHEMA: Final[pa.Schema] = pa.schema(
[
pa.field("book_id", pa.string(), nullable=False),
pa.field("title", pa.string(), nullable=False),
pa.field("year", pa.int64(), nullable=False),
]
)
BOOK_AUTHORS_SCHEMA: Final[pa.Schema] = pa.schema(
[
pa.field("book_id", pa.string(), nullable=False),
pa.field("author", pa.string(), nullable=False),
]
)
def transform_book(row: dict[str, Any]) -> dict[str, list[dict[str, Any]]]:
"""Unwrap ``<book>`` dictionaries, drop withdrawn entries, and fan out authors to ``book_authors``.
:param row: Parsed ``xmltodict`` dictionary of the form ``{"book": {...}}``.
:type row: dict[str, Any]
:return: Mapping of target table names to lists of output row dictionaries.
:rtype: dict[str, list[dict[str, Any]]]
"""
book = row["book"]
if book.get("_status") == "withdrawn":
return {}
book_id = str(book["_id"]).upper()
title = str(book["title"])
year = int(book["year"])
routed: dict[str, list[dict[str, Any]]] = {
"books": [
{
"book_id": book_id,
"title": title,
"year": year,
}
]
}
raw_authors = book.get("author") or []
authors = raw_authors if isinstance(raw_authors, list) else [raw_authors]
if authors:
routed["book_authors"] = [{"book_id": book_id, "author": str(a)} for a in authors]
return routed
TABLE_TRANSFORMS: Final[dict[str, RecordTransformFn]] = {
"books": transform_book,
}
OUTPUT_SCHEMAS: Final[dict[str, pa.Schema]] = {
"books": CATALOG_BOOKS_SCHEMA,
"book_authors": BOOK_AUTHORS_SCHEMA,
}
Outermost <xml_tag> Streaming, Self-Nested Tags, Gzip Decompression, and _xmlns Stripping (spec/01_outermost_elements_gzip_and_xmlns)¶
Suite: spec · Target CLI: xmltodict · Settings Class: XmlToDictSettings
Directory: specs/pipelines/xmltodict/spec/01_outermost_elements_gzip_and_xmlns/
Rules: R-XMLTODICT-TARGET-AND-GLOB, R-XMLTODICT-OUTERMOST-STREAM, R-XMLTODICT-PARSE, R-XMLTODICT-REPORT
file_glob = "**/*.xml*"discoversbatch_a/part1.xmlandbatch_b/part2.xml.gzacross nested subdirectories while ignoringbatch_b/ignored.txt.batch_a/part1.xmldeclaresxmlns:cat="http://example.org/catalog"on<library>(stripped from the parsed<book>dict via_xmlnsfiltering) and contains a nested<book id="b-1a">inside<related>, whichstream_xml_filepreserves inside the outerb-1element rather than yielding as a separate row.
Settings (specs/pipelines/xmltodict/spec/01_outermost_elements_gzip_and_xmlns/settings.toml)
dataset_name = "xmltodict_spec"
table_name = "books"
xml_tag = "book"
file_glob = "**/*.xml*"
loader_file_format = "parquet"
use_output_dir_for_pipeline_metadata = true
Input batch_a/part1.xml (specs/pipelines/xmltodict/spec/01_outermost_elements_gzip_and_xmlns/input/batch_a/part1.xml)
<?xml version="1.0" encoding="UTF-8"?>
<library xmlns:cat="http://example.org/catalog">
<book id="b-1">
<title>Dune</title>
<related>
<book id="b-1a">
<title>Dune Messiah</title>
</book>
</related>
</book>
</library>
Input batch_b/ignored.txt (specs/pipelines/xmltodict/spec/01_outermost_elements_gzip_and_xmlns/input/batch_b/ignored.txt)
Input batch_b/part2.xml.gz (specs/pipelines/xmltodict/spec/01_outermost_elements_gzip_and_xmlns/input/batch_b/part2.xml.gz) (decompressed preview)
<?xml version="1.0" encoding="UTF-8"?>
<library>
<book id="b-2">
<title>Foundation</title>
</book>
</library>
Expected report.txt Summary & Row Counts ({'books': 2})
status: completed
ingested_files:
- batch_a/part1.xml
- batch_b/part2.xml.gz
load_errors: []
pipeline_load_info_errs: null
Expected PyArrow Tables (specs/pipelines/xmltodict/spec/01_outermost_elements_gzip_and_xmlns/expected_tables.py — EXPECTED_TABLES)
"""Expected PyArrow tables for xmltodict 01_outermost_elements_gzip_and_xmlns."""
import pyarrow as pa
BOOKS_SCHEMA = pa.schema(
[
pa.field("book___id", pa.string(), nullable=False),
pa.field("book__title", pa.string(), nullable=False),
pa.field("book__related__book___id", pa.string(), nullable=True),
pa.field("book__related__book__title", pa.string(), nullable=True),
]
)
EXPECTED_TABLES: dict[str, pa.Table] = {
"books": pa.Table.from_pylist(
[
{
"book___id": "b-1",
"book__title": "Dune",
"book__related__book___id": "b-1a",
"book__related__book__title": "Dune Messiah",
},
{
"book___id": "b-2",
"book__title": "Foundation",
"book__related__book___id": None,
"book__related__book__title": None,
},
],
schema=BOOKS_SCHEMA,
),
}
Expected Canonical Table Rows (specs/pipelines/xmltodict/spec/01_outermost_elements_gzip_and_xmlns/expected.json)
{
"books": [
{
"book___id": "b-1",
"book__related__book___id": "b-1a",
"book__related__book__title": "Dune Messiah",
"book__title": "Dune"
},
{
"book___id": "b-2",
"book__title": "Foundation"
}
]
}
MD5 Sidecar Pre-processing and File-Level _rejected Routing (spec/02_md5_sidecar_preprocessing)¶
Suite: spec · Target CLI: xmltodict · Settings Class: XmlToDictSettings
Directory: specs/pipelines/xmltodict/spec/02_md5_sidecar_preprocessing/
Rules: R-XMLTODICT-PREPROCESS-MD5, R-XMLTODICT-REPORT
preprocessors_module = "specs.pipelines.xmltodict.transforms"registersvalidate_md5_sidecar.good.xmlmatchesgood.xml.md5and streams<book id="b-10">intobooks.bad.xmlfails MD5 validation againstbad.xml.md5; the file is skipped before XML parsing, omitted fromingested_files, recorded inreport.txtload_errors(status: completed with errors), and emitted as a file-level row (line_no=None,raw_record=None) in_rejected.
Settings (specs/pipelines/xmltodict/spec/02_md5_sidecar_preprocessing/settings.toml)
dataset_name = "xmltodict_spec"
table_name = "books"
xml_tag = "book"
file_glob = "*.xml"
preprocessors_module = "specs.pipelines.xmltodict.transforms"
loader_file_format = "parquet"
use_output_dir_for_pipeline_metadata = true
Input bad.xml (specs/pipelines/xmltodict/spec/02_md5_sidecar_preprocessing/input/bad.xml)
<?xml version="1.0" encoding="UTF-8"?>
<library>
<book id="b-99">
<title>Corrupted Book</title>
</book>
</library>
Input bad.xml.md5 (specs/pipelines/xmltodict/spec/02_md5_sidecar_preprocessing/input/bad.xml.md5)
Input good.xml (specs/pipelines/xmltodict/spec/02_md5_sidecar_preprocessing/input/good.xml)
<?xml version="1.0" encoding="UTF-8"?>
<library>
<book id="b-10">
<title>Hyperion</title>
</book>
</library>
Input good.xml.md5 (specs/pipelines/xmltodict/spec/02_md5_sidecar_preprocessing/input/good.xml.md5)
Expected report.txt Summary & Row Counts ({'books': 1, '_rejected': 1})
status: completed with errors
ingested_files:
- good.xml
load_errors:
- 'File pre-processing failed for bad.xml: MD5 mismatch for bad.xml'
pipeline_load_info_errs: null
Expected PyArrow Tables (specs/pipelines/xmltodict/spec/02_md5_sidecar_preprocessing/expected_tables.py — EXPECTED_TABLES)
"""Expected PyArrow tables for xmltodict 02_md5_sidecar_preprocessing."""
import pyarrow as pa
from cdm_data_loaders.utils.buffer import REJECTED_PYARROW_SCHEMA
BOOKS_SCHEMA = pa.schema(
[
pa.field("book___id", pa.string(), nullable=False),
pa.field("book__title", pa.string(), nullable=False),
]
)
EXPECTED_TABLES: dict[str, pa.Table] = {
"books": pa.Table.from_pylist(
[
{"book___id": "b-10", "book__title": "Hyperion"},
],
schema=BOOKS_SCHEMA,
),
"_rejected": pa.Table.from_pylist(
[
{
"table_name": "books",
"source_file": "bad.xml",
"line_no": None,
"raw_record": None,
"error_detail": (
"MD5 mismatch for bad.xml: "
"expected 00000000000000000000000000000000, got 5a46030ee1e4775d1dd0418280d8a0d7"
),
}
],
schema=REJECTED_PYARROW_SCHEMA,
),
}
Expected Canonical Table Rows (specs/pipelines/xmltodict/spec/02_md5_sidecar_preprocessing/expected.json)
{
"_rejected": [
{
"error_detail": "MD5 mismatch for bad.xml: expected 00000000000000000000000000000000, got 5a46030ee1e4775d1dd0418280d8a0d7",
"source_file": "bad.xml",
"table_name": "books"
}
],
"books": [
{
"book___id": "b-10",
"book__title": "Hyperion"
}
]
}
Table Transforms, Unwrapping <xml_tag>, Multi-Table Fan-Out, and PyArrow Output Schemas (spec/03_table_transforms_and_output_schemas)¶
Suite: spec · Target CLI: xmltodict · Settings Class: XmlToDictSettings
Directory: specs/pipelines/xmltodict/spec/03_table_transforms_and_output_schemas/
Rules: R-XMLTODICT-OUTERMOST-STREAM, R-XMLTODICT-PARSE, R-XMLTODICT-TRANSFORMS, R-XMLTODICT-REPORT
transforms_module = "specs.pipelines.xmltodict.transforms"andoutput_schemas_module = "specs.pipelines.xmltodict.transforms"registerTABLE_TRANSFORMS["books"]andOUTPUT_SCHEMASforbooksandbook_authors.transform_bookunwraps each{"book": {...}}dictionary, filters out<book id="b-drop" status="withdrawn">, normalizesbook_idto uppercase, castsyeartoint, and fans out single or repeated<author>elements tobook_authors.
Settings (specs/pipelines/xmltodict/spec/03_table_transforms_and_output_schemas/settings.toml)
dataset_name = "xmltodict_spec"
table_name = "books"
xml_tag = "book"
file_glob = "*.xml"
transforms_module = "specs.pipelines.xmltodict.transforms"
output_schemas_module = "specs.pipelines.xmltodict.transforms"
loader_file_format = "parquet"
use_output_dir_for_pipeline_metadata = true
Input catalog.xml (specs/pipelines/xmltodict/spec/03_table_transforms_and_output_schemas/input/catalog.xml)
<?xml version="1.0" encoding="UTF-8"?>
<library>
<book id="b-1" status="active">
<title>The Left Hand of Darkness</title>
<year>1969</year>
<author>Ursula K. Le Guin</author>
</book>
<book id="b-drop" status="withdrawn">
<title>Withdrawn Draft</title>
<year>1970</year>
<author>Ignored Author</author>
</book>
<book id="b-2" status="active">
<title>Good Omens</title>
<year>1990</year>
<author>Terry Pratchett</author>
<author>Neil Gaiman</author>
</book>
</library>
Expected report.txt Summary & Row Counts ({'books': 2, 'book_authors': 3})
Expected PyArrow Tables (specs/pipelines/xmltodict/spec/03_table_transforms_and_output_schemas/expected_tables.py — EXPECTED_TABLES)
"""Expected PyArrow tables for xmltodict 03_table_transforms_and_output_schemas."""
import pyarrow as pa
from specs.pipelines.xmltodict.transforms import (
BOOK_AUTHORS_SCHEMA,
CATALOG_BOOKS_SCHEMA,
)
EXPECTED_TABLES: dict[str, pa.Table] = {
"books": pa.Table.from_pylist(
[
{"book_id": "B-1", "title": "The Left Hand of Darkness", "year": 1969},
{"book_id": "B-2", "title": "Good Omens", "year": 1990},
],
schema=CATALOG_BOOKS_SCHEMA,
),
"book_authors": pa.Table.from_pylist(
[
{"book_id": "B-1", "author": "Ursula K. Le Guin"},
{"book_id": "B-2", "author": "Terry Pratchett"},
{"book_id": "B-2", "author": "Neil Gaiman"},
],
schema=BOOK_AUTHORS_SCHEMA,
),
}
Expected Canonical Table Rows (specs/pipelines/xmltodict/spec/03_table_transforms_and_output_schemas/expected.json)
{
"book_authors": [
{
"author": "Ursula K. Le Guin",
"book_id": "B-1"
},
{
"author": "Neil Gaiman",
"book_id": "B-2"
},
{
"author": "Terry Pratchett",
"book_id": "B-2"
}
],
"books": [
{
"book_id": "B-1",
"title": "The Left Hand of Darkness",
"year": 1969
},
{
"book_id": "B-2",
"title": "Good Omens",
"year": 1990
}
]
}
4. Fixture Index¶
Every case directory contains a case.yaml manifest; the test suites and this document are both driven by it.
| Case | Target CLI | Manifest | Runs |
|---|---|---|---|
spec/01_outermost_elements_gzip_and_xmlns |
xmltodict |
case.yaml |
default |
spec/02_md5_sidecar_preprocessing |
xmltodict |
case.yaml |
partial_md5_failure |
spec/03_table_transforms_and_output_schemas |
xmltodict |
case.yaml |
default |