En el día a día del desarrollo, la integración con sistemas legados, pasarelas de pago o ERPs como Odoo nos obliga a lidiar con formatos XML. Escribir estas estructuras a mano o concatenando strings es ineficiente y propenso a errores de sintaxis. Los desarrolladores con experiencia sabemos que la solución no es picar más código, sino construir herramientas modulares de automatización que ahorren tiempo al equipo.
En este artículo vamos a construir una herramienta de producción en Python que transforma cualquier estructura de datos anidada (estilo JSON o diccionarios) en un archivo XML perfectamente indentado. Además, llevaremos la experiencia de desarrollo (DX) al límite integrando un bloque de autoinstalación de dependencias en tiempo de ejecución.
🤔 ¿Qué vamos a conseguir?
- Un script 100% genérico que convierte diccionarios y listas de Python en XML anidado de forma recursiva.
- Soporte para atributos (
_attrs), texto (_text) y sub-nodos (_children) dentro de la estructura. - Serialización profesional: indentado automático, declaración XML y codificación UTF-8 gracias a
lxml. - Un script autoinstalable que instala
lxmlsolo si hace falta. - Un ejemplo real: un catálogo de productos con SKU y atributos listo para producción.
🧰 Prerrequisitos
- Python 3.8+ instalado en tu sistema.
- Conocimientos básicos de Python: diccionarios, listas y funciones.
- Entender qué es un archivo XML (etiquetas, atributos y anidación).
Instala las dependencias (aunque el script también lo hará solo):
pip install lxml
La estructura de datos de entrada usa tres claves reservadas:
_attrs: un diccionario que mapea los atributos clave-valor del nodo._text: el contenido de texto plano que va dentro de la etiqueta._children: los sub-nodos anidados que la función procesará de forma recursiva.
🐳 Docker: probar sin tocar tu Python
Puedes ejecutar el ejemplo completo en un contenedor con Python 3.12 sin contaminar tu sistema:
docker run --rm -v "$(pwd):/app" -w /app python:3.12-slim \
python xml_generico.py
Dentro del contenedor el bloque de autoinstalación descargará lxml y generará el output_sistema.xml del ejemplo.
🛠️ El enfoque arquitectónico
La clave es no hardcodear ninguna etiqueta: el script recorre la estructura de datos y va creando nodos XML sobre la marcha. Para eso defino tres funciones que se reparten el trabajo:
build_generic_xml(root_tag, data)— crea el nodo raíz y arranca la recursión._create_nodes(parent, data)— recorre dicts y listas decidiendo cómo crear cada nodo._generate_element(tag, parent, attrs, text)— helper estricto que instancia un sub-elemento con sus atributos y texto.
🚀 El código completo (listo para producción)
Aquí está el script definitivo. Sustituyo las librerías estándar y lentas por lxml (escrita en C, estándar para datos de gran volumen) y aislo el entorno para que el script configure sus propias dependencias mediante subprocess:
import sys
import subprocess
from pathlib import Path
from typing import Any, Dict, List, Union
# --- BLOQUE DE AUTOINSTALACIÓN DE DEPENDENCIAS (DX) ---
try:
from lxml import etree as ET
except ImportError:
print("📦 La librería 'lxml' no está instalada. Iniciando autoinstalación...")
try:
# Ejecuta 'pip install lxml' de forma silenciosa en el entorno actual
subprocess.check_call([sys.executable, "-m", "pip", "install", "lxml"],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
from lxml import etree as ET
print("✅ 'lxml' instalada y cargada correctamente.")
except Exception as e:
print(f"❌ Falló la instalación automática de dependencias: {e}")
print("Se aborta la ejecución. Por favor, instala 'lxml' manualmente.")
sys.exit(1)
# Alias de tipado estricto para estructuras JSON anidadas
JsonStructure = Union[Dict[str, Any], List[Any], str, int, float]
def build_generic_xml(root_tag: str, data_structure: JsonStructure) -> ET._Element:
"""Genera un árbol XML a partir de una estructura de datos genérica.
Args:
root_tag: Nombre de la etiqueta raíz del archivo XML.
data_structure: Diccionario o lista con los datos anidados.
"""
root = ET.Element(root_tag)
_create_nodes(root, data_structure)
return root
def _create_nodes(parent_element: ET._Element, data: JsonStructure) -> None:
"""Recorre recursivamente la estructura de datos para inyectar nodos al XML."""
if isinstance(data, dict):
for tag, content in data.items():
# Validación de palabras clave de nuestra estructura avanzada
if isinstance(content, dict) and any(k in content for k in ('_attrs', '_text', '_children')):
attrs = content.get('_attrs', {})
text = content.get('_text', None)
children = content.get('_children', None)
element = _generate_element(tag, parent_element, attrs, text)
if children:
_create_nodes(element, children)
else:
# Comportamiento por defecto para diccionarios planos
element = _generate_element(tag, parent_element)
_create_nodes(element, content)
elif isinstance(data, list):
for item in data:
_create_nodes(parent_element, item)
else:
# Nodo hoja: asignamos el valor simple como texto del nodo padre
parent_element.text = str(data)
def _generate_element(tag: str, parent_element: ET._Element, attrs: Dict[str, str] = None, text: str = None) -> ET._Element:
"""Helper estricto para la instanciación segura de sub-elementos usando lxml."""
xml_element = ET.SubElement(parent_element, tag)
if attrs:
for key, value in attrs.items():
xml_element.set(str(key), str(value))
if text:
xml_element.text = str(text)
return xml_element
def save_pretty_xml(root: ET._Element, filename: str) -> None:
"""Serializa el árbol XML con indentación profesional nativa y codificación UTF-8."""
try:
# Gestión moderna de rutas multiplataforma con Pathlib
target_path = Path(__file__).resolve().parent / f"{filename}.xml"
# lxml permite escribir directamente aplicando pretty_print nativo
tree = ET.ElementTree(root)
tree.write(str(target_path), pretty_print=True, xml_declaration=True, encoding="utf-8")
print(f"✔️ Archivo XML generado con éxito en: {target_path}")
except IOError as e:
print(f"❌ Error crítico de E/S al escribir en el disco: {e}")
except Exception as e:
print(f"❌ Error inesperado durante la serialización: {e}")
# --- Bloque de ejecución de pruebas ---
if __name__ == "__main__":
# Definición de un catálogo de productos altamente anidado con atributos
estructura_ejemplo: JsonStructure = {
"configuracion": {
"_attrs": {"entorno": "produccion", "version_api": "v2"},
"_text": "Logs globales del sistema"
},
"catalogo": [
{
"producto": {
"_attrs": {"sku": "TECH-001", "disponible": "true"},
"_children": {
"nombre": "Teclado Mecánico RGB",
"precio": "120.50",
"categoria": "Periféricos"
}
}
},
{
"producto": {
"_attrs": {"sku": "TECH-002", "disponible": "false"},
"_children": {
"nombre": "Ratón Inalámbrico",
"precio": "45.00",
"categoria": "Periféricos"
}
}
}
]
}
# Construcción y guardado del archivo
doc = build_generic_xml("app_response", estructura_ejemplo)
save_pretty_xml(doc, "output_sistema")
🚀 El resultado generado
Al ejecutar el script se crea el archivo output_sistema.xml con este contenido, ya indentado y con la declaración UTF-8:
<?xml version='1.0' encoding='UTF-8'?>
<app_response>
<configuracion entorno="produccion" version_api="v2">Logs globales del sistema</configuracion>
<catalogo>
<producto sku="TECH-001" disponible="true">
<nombre>Teclado Mecánico RGB</nombre>
<precio>120.50</precio>
<categoria>Periféricos</categoria>
</producto>
<producto sku="TECH-002" disponible="false">
<nombre>Ratón Inalámbrico</nombre>
<precio>45.00</precio>
<categoria>Periféricos</categoria>
</producto>
</catalogo>
</app_response>
Fíjate en cómo la recursión convierte:
_attrsen atributos de la etiqueta (entorno="produccion")._texten el contenido entre apertura y cierre (>Logs globales del sistema<)._childrenen sub-etiquetas anidadas (nombre,precio,categoriadentro deproducto).
🧪 Comprobar que funciona
- Ejecuta
python xml_generico.pyy verás✔️ Archivo XML generado con éxito en: .../output_sistema.xml. - Abre
output_sistema.xmlcon un editor ocaty comprueba que la indentación y los atributos coinciden con el resultado anterior. - Valida que es XML bien formado con Python:
python -c "import xml.etree.ElementTree as ET; ET.parse('output_sistema.xml'); print('XML válido ✅')"
- El código de salida del script debe ser
0: compruébalo conecho $?.
⚠️ Errores comunes
| Error | Causa | Solución |
|---|---|---|
ImportError: No module named 'lxml' y no se instala sola | pip no tiene permisos o hay red bloqueada | Instala manualmente pip install lxml en tu entorno virtual |
| El XML sale en una sola línea | Olvidarte de pretty_print=True en tree.write | Activa pretty_print=True en la serialización |
| Aparecen caracteres raros al abrir en Windows | Falta la declaración de codificación | Usa encoding="utf-8" y xml_declaration=True |
ValueError: All strings must be XML compatible | _text o atributos contienen caracteres inválidos | Sanitiza el texto antes de asignarlo (escapa &, <, >) |
| El listado se repite o el orden cambia | Confundir _children con dicts planos | Asegúrate de usar _children para anidar y dicts normales para repetir estructura |
xml.etree no lee el archivo generado | El archivo quedó a medias por un error de E/S | Comprueba permisos de escritura de la carpeta destino |
🪜 Siguiente nivel
- Valida contra un XSD — si integras con un ERP Odoo o una pasarela, valida el XML generado contra su schema para garantizar el formato.
- Manejo de errores robusto — añade un log con colores y reintentos con la librería
rich(como hice en PDF Ninja). - Expande la estructura — soporta listas repetidas, atributos opcionales y cdata para campos con contenido largo.
- Automatízalo en producción — invoca el script desde un
crono un pipeline de CI para regenerar archivos de exportación cada noche. - Funda tu camino en Python — si te quedas con ganas de más, sigue la Guía de Python: de 0 a 100.
