Analizar un documento XML

Los documentos XML analizados se representan en la memoria mediante objetos ElementTree Element conectados en una estructura de árbol basada en la forma en que los nodos están anidados en el documento XML.

Analizar un documento completo con parse() devuelve una instancia ElementTree. El árbol conoce todos los datos en el documento de entrada, y los nodos del árbol se pueden buscar o manipulados en su lugar. Si bien esta flexibilidad puede hacer que trabajar con el documento analizado sea más conveniente, normalmente requiere más memoria que un enfoque de análisis basado en eventos ya que todo el documento debe ser cargado a la vez.

La huella de memoria de documentos pequeños y simples como esta lista de podcasts representada como un esquema OPML no son significativa:

podcasts.opml
<?xml version="1.0" encoding="UTF-8"?>
<opml version="1.0">
<head>
    <title>My Podcasts</title>
    <dateCreated>Sat, 06 Aug 2016 15:53:26 GMT</dateCreated>
    <dateModified>Sat, 06 Aug 2016 15:53:26 GMT</dateModified>
</head>
<body>
  <outline text="Non-tech">
    <outline
        text="99% Invisible" type="rss"
        xmlUrl="http://feeds.99percentinvisible.org/99percentinvisible"
        htmlUrl="http://99percentinvisible.org" />
  </outline>
  <outline text="Python">
    <outline
        text="Talk Python to Me" type="rss"
        xmlUrl="https://talkpython.fm/episodes/rss"
        htmlUrl="https://talkpython.fm" />
    <outline
        text="Podcast.__init__" type="rss"
        xmlUrl="http://podcastinit.podbean.com/feed/"
        htmlUrl="http://podcastinit.com" />
  </outline>
</body>
</opml>

Para analizar el archivo, pasa un identificador de archivo abierto a parse().

ElementTree_parse_opml.py
from xml.etree import ElementTree

with open('podcasts.opml', 'rt') as f:
    tree = ElementTree.parse(f)

print(tree)

Leerá los datos, analizará el XML y devolverá un objeto ElementTree.

$ python3 ElementTree_parse_opml.py

<xml.etree.ElementTree.ElementTree object at 0x1013e5630>

Atravesar el árbol analizado

Para visitar a todos los niños en orden, usa iter() para crear un generador que itera sobre la instancia ElementTree.

ElementTree_dump_opml.py
from xml.etree import ElementTree
import pprint

with open('podcasts.opml', 'rt') as f:
    tree = ElementTree.parse(f)

for node in tree.iter():
    print(node.tag)

Este ejemplo imprime todo el árbol, una etiqueta a la vez.

$ python3 ElementTree_dump_opml.py

opml
head
title
dateCreated
dateModified
body
outline
outline
outline
outline
outline

Para imprimir solo los grupos de nombres y URL de fuentes para los podcasts, dejando fuera de todos los datos en la sección de encabezado iterando sobre solo los nodos outline e imprimir los atributos text y xmlUrl buscando los valores en el diccionario attrib.

ElementTree_show_feed_urls.py
from xml.etree import ElementTree

with open('podcasts.opml', 'rt') as f:
    tree = ElementTree.parse(f)

for node in tree.iter('outline'):
    name = node.attrib.get('text')
    url = node.attrib.get('xmlUrl')
    if name and url:
        print('  %s' % name)
        print('    %s' % url)
    else:
        print(name)

El argumento 'outline' para iter() significa que el procesamiento esa limitado solo a nodos con la etiqueta 'outline'.

$ python3 ElementTree_show_feed_urls.py

Non-tech
  99% Invisible
    http://feeds.99percentinvisible.org/99percentinvisible
Python
  Talk Python to Me
    https://talkpython.fm/episodes/rss
  Podcast.__init__
    http://podcastinit.podbean.com/feed/

Encontrar nodos en un documento

Caminar por todo el árbol de esta manera, buscando nodos relevantes, puede ser propenso a errores. El ejemplo anterior tenía que mirar cada nodo outline para determinar si era un grupo (solo nodos con un atributo text) o podcast (con ambos text y xmlUrl). Para producir una lista simple de las URL de los canales de información de podcast, sin nombres o grupos, la lógica podría simplificarse utilizando findall() para buscar nodos con características de búsqueda más descriptivos.

Como primer paso para convertir la primera versión, se puede utilizar un argumento XPath para buscar todos los nodos outline.

ElementTree_find_feeds_by_tag.py
from xml.etree import ElementTree

with open('podcasts.opml', 'rt') as f:
    tree = ElementTree.parse(f)

for node in tree.findall('.//outline'):
    url = node.attrib.get('xmlUrl')
    if url:
        print(url)

La lógica en esta versión no es sustancialmente diferente a la versión utilizando getiterator(). Todavía tiene que comprobar la presencia de la URL, excepto que no imprime el nombre del grupo cuando no se encuentra la URL.

$ python3 ElementTree_find_feeds_by_tag.py

http://feeds.99percentinvisible.org/99percentinvisible
https://talkpython.fm/episodes/rss
http://podcastinit.podbean.com/feed/

Es posible aprovechar el hecho de que los nodos outline solo se anidan dos niveles de profundidad. Cambiando la ruta de búsqueda a .//outline/outline significa que el bucle procesará solo el segundo nivel de nodos outline.

ElementTree_find_feeds_by_structure.py
from xml.etree import ElementTree

with open('podcasts.opml', 'rt') as f:
    tree = ElementTree.parse(f)

for node in tree.findall('.//outline/outline'):
    url = node.attrib.get('xmlUrl')
    print(url)

Se espera que todos los nodos outline anidados dos niveles de profundidad en la entrada tengan el atributo xmlURL que se refiere a la fuente de podcast, por lo que el bucle puede omitir la comprobación del atributo antes de usarlo.

$ python3 ElementTree_find_feeds_by_structure.py

http://feeds.99percentinvisible.org/99percentinvisible
https://talkpython.fm/episodes/rss
http://podcastinit.podbean.com/feed/

Sin embargo, esta versión está limitada a la estructura existente, por lo que si los nodos outline se reorganizan en un árbol más profundo, dejará de trabajar.

Atributos del nodo analizado

Los elementos devueltos por findall() y iter() son objetos Element, cada uno representa un nodo en el árbol de análisis XML. Cada Element tiene atributos para acceder a los datos extraídos del XML. Esto se puede ilustrar con un archivo de ejemplo de entrada un poco más ideado data.xml.

data.xml
1
2
3
4
5
6
7
8
9
<?xml version="1.0" encoding="UTF-8"?>
<top>
  <child>Regular text.</child>
  <child_with_tail>Regular text.</child_with_tail>"Tail" text.
  <with_attributes name="value" foo="bar" />
  <entity_expansion attribute="This &#38; That">
    That &#38; This
  </entity_expansion>
</top>

Los atributos XML de un nodo están disponibles en la propiedad attrib que actúa como un diccionario.

ElementTree_node_attributes.py
from xml.etree import ElementTree

with open('data.xml', 'rt') as f:
    tree = ElementTree.parse(f)

node = tree.find('./with_attributes')
print(node.tag)
for name, value in sorted(node.attrib.items()):
    print('  %-4s = "%s"' % (name, value))

El nodo en la línea cinco del archivo de entrada tiene dos atributos, name y foo.

$ python3 ElementTree_node_attributes.py

with_attributes
  foo  = "bar"
  name = "value"

El contenido de texto de los nodos está disponible, junto con el texto tail, que viene después del final de una etiqueta de cierre.

ElementTree_node_text.py
from xml.etree import ElementTree

with open('data.xml', 'rt') as f:
    tree = ElementTree.parse(f)

for path in ['./child', './child_with_tail']:
    node = tree.find(path)
    print(node.tag)
    print('  child node text:', node.text)
    print('  and tail text  :', node.tail)

El nodo child en la línea tres contiene texto incrustado, y el nodo en la línea cuatro tiene texto con una cola (incluyendo espacios en blanco).

$ python3 ElementTree_node_text.py

child
  child node text: Regular text.
  and tail text  :

child_with_tail
  child node text: Regular text.
  and tail text  : "Tail" text.

Las referencias de entidad XML incrustadas en el documento se convierten a caracteres apropiados antes de que se devuelvan los valores.

ElementTree_entity_references.py
from xml.etree import ElementTree

with open('data.xml', 'rt') as f:
    tree = ElementTree.parse(f)

node = tree.find('entity_expansion')
print(node.tag)
print('  in attribute:', node.attrib['attribute'])
print('  in text     :', node.text.strip())

La conversión automática significa que el detalle de implementación de la representación de ciertos caracteres en un documento XML puede ser ignorada.

$ python3 ElementTree_entity_references.py

entity_expansion
  in attribute: This & That
  in text     : That & This

Vigilar eventos mientras analizamos

La otra interfaz de programación para procesar documentos XML está basada en eventos. El analizador genera eventos start para abrir etiquetas y eventos end para cerrar etiquetas. Los datos pueden ser extraídos del documento durante la fase de análisis mediante la iteración de la secuencia de eventos, lo que es conveniente si no es necesario manipular todo el documento posteriormente y no es necesario mantener todo el documento analizado en la memoria.

Los eventos pueden ser uno de los siguientes:

start
Se ha encontrado una nueva etiqueta. El corchete angular de cierre de la etiqueta fue procesado, pero no el contenido.
end
Se ha procesado el corchete de angular de cierre de una etiqueta de cierre. Todos los niños ya fueron procesados.
start-ns
Inicia una declaración de espacio de nombres.
end-ns
Finaliza una declaración de espacio de nombres.

iterparse() devuelve un iterable que produce tuplas que contienen el nombre del evento y el nodo que desencadena el evento.

ElementTree_show_all_events.py
from xml.etree.ElementTree import iterparse

depth = 0
prefix_width = 8
prefix_dots = '.' * prefix_width
line_template = ''.join([
    '{prefix:<0.{prefix_len}}',
    '{event:<8}',
    '{suffix:<{suffix_len}} ',
    '{node.tag:<12} ',
    '{node_id}',
])

EVENT_NAMES = ['start', 'end', 'start-ns', 'end-ns']

for (event, node) in iterparse('podcasts.opml', EVENT_NAMES):
    if event == 'end':
        depth -= 1

    prefix_len = depth * 2

    print(line_template.format(
        prefix=prefix_dots,
        prefix_len=prefix_len,
        suffix='',
        suffix_len=(prefix_width - prefix_len),
        node=node,
        node_id=id(node),
        event=event,
    ))

    if event == 'start':
        depth += 1

Por defecto, solo se generan eventos end. Para ver otros eventos, pasa la lista de nombres de eventos deseados a iterparse(), como en este ejemplo.

$ python3 ElementTree_show_all_events.py

start            opml         4312612200
..start          head         4316174520
....start        title        4316254440
....end          title        4316254440
....start        dateCreated  4316254520
....end          dateCreated  4316254520
....start        dateModified 4316254680
....end          dateModified 4316254680
..end            head         4316174520
..start          body         4316254840
....start        outline      4316254920
......start      outline      4316255080
......end        outline      4316255080
....end          outline      4316254920
....start        outline      4316255160
......start      outline      4316255240
......end        outline      4316255240
......start      outline      4316255320
......end        outline      4316255320
....end          outline      4316255160
..end            body         4316254840
end              opml         4312612200

El estilo de procesamiento del evento es más natural para algunas operaciones, como la conversión de entrada XML a algún otro formato. Esta técnica puede ser utilizada para convertir la lista de podcasts de los ejemplos anteriores de un archivo XML a un archivo CSV, para que puedan cargarse en una hoja de cálculo o una aplicación de base de datos.

ElementTree_write_podcast_csv.py
import csv
from xml.etree.ElementTree import iterparse
import sys

writer = csv.writer(sys.stdout, quoting=csv.QUOTE_NONNUMERIC)

group_name = ''

parsing = iterparse('podcasts.opml', events=['start'])

for (event, node) in parsing:
    if node.tag != 'outline':
        # Ignore anything not part of the outline
        continue
    if not node.attrib.get('xmlUrl'):
        # Remember the current group
        group_name = node.attrib['text']
    else:
        # Output a podcast entry
        writer.writerow(
            (group_name, node.attrib['text'],
             node.attrib['xmlUrl'],
             node.attrib.get('htmlUrl', ''))
        )

Este programa de conversión no necesita mantener toda el archivo de entrada analizado en la memoria, y procesa cada nodo a medida que se encuentra en la entrada es más eficiente.

$ python3 ElementTree_write_podcast_csv.py

"Non-tech","99% Invisible","http://feeds.99percentinvisible.org/\
99percentinvisible","http://99percentinvisible.org"
"Python","Talk Python to Me","https://talkpython.fm/episodes/rss\
","https://talkpython.fm"
"Python","Podcast.__init__","http://podcastinit.podbean.com/feed\
/","http://podcastinit.com"

Nota

La salida de ElementTree_write_podcast_csv.py ha sido reformateada para caber en esta página. Las líneas de salida que terminan con \ indican un salto de línea artificial.

Crear un generador de árbol personalizado

Un medio potencialmente más eficiente para manejar eventos de análisis es reemplazar el comportamiento del generador de árbol estándar con una versión personalizada. El analizador XMLParser utiliza un TreeBuilder para procesar el XML y llama métodos en una clase de destino para guardar los resultados. La salida habitual es una instancia ElementTree creada por clase TreeBuilder por defecto. Sustituyendo TreeBuilder con otra clase le permite recibir los eventos antes que los nodos Element sea creados, evitando esa parte de la sobrecarga.

El convertidor de XML a CSV de la sección anterior puede ser re-implementado como un constructor de árboles.

ElementTree_podcast_csv_treebuilder.py
import csv
import io
from xml.etree.ElementTree import XMLParser
import sys


class PodcastListToCSV(object):

    def __init__(self, outputFile):
        self.writer = csv.writer(
            outputFile,
            quoting=csv.QUOTE_NONNUMERIC,
        )
        self.group_name = ''

    def start(self, tag, attrib):
        if tag != 'outline':
            # Ignore anything not part of the outline
            return
        if not attrib.get('xmlUrl'):
            # Remember the current group
            self.group_name = attrib['text']
        else:
            # Output a podcast entry
            self.writer.writerow(
                (self.group_name,
                 attrib['text'],
                 attrib['xmlUrl'],
                 attrib.get('htmlUrl', ''))
            )

    def end(self, tag):
        "Ignore closing tags"

    def data(self, data):
        "Ignore data inside nodes"

    def close(self):
        "Nothing special to do here"


target = PodcastListToCSV(sys.stdout)
parser = XMLParser(target=target)
with open('podcasts.opml', 'rt') as f:
    for line in f:
        parser.feed(line)
parser.close()

PodcastListToCSV implementa el protocolo TreeBuilder. Cada vez que se encuentra una nueva etiqueta XML, start() es llamado con el nombre y los atributos de la etiqueta. Cuando se ve una etiqueta de cierre, end() se llama con el nombre. En medio, se llama data() cuando un nodo tiene contenido (se espera que el generador de árboles mantenga actual el nodo «current»). Cuando toda la entrada se procesa, se llama close(). Puede devolver un valor, que será devuelto al usuario del TreeBuilder.

$ python3 ElementTree_podcast_csv_treebuilder.py

"Non-tech","99% Invisible","http://feeds.99percentinvisible.org/\
99percentinvisible","http://99percentinvisible.org"
"Python","Talk Python to Me","https://talkpython.fm/episodes/rss\
","https://talkpython.fm"
"Python","Podcast.__init__","http://podcastinit.podbean.com/feed\
/","http://podcastinit.com"

Nota

La salida de ElementTree_podcast_csv_treebuidler.py ha sido reformateada para caber en esta página. Las líneas de salida que terminan con \ indican un salto de línea artificial.

Analizar cadenas

Para trabajar con pedazos más pequeños de texto XML, especialmente literales de cadena que podrían estar incrustados en la fuente de un programa, usa XML() y la cadena que contiene el XML que se analizará como el único argumento.

ElementTree_XML.py
from xml.etree.ElementTree import XML


def show_node(node):
    print(node.tag)
    if node.text is not None and node.text.strip():
        print('  text: "%s"' % node.text)
    if node.tail is not None and node.tail.strip():
        print('  tail: "%s"' % node.tail)
    for name, value in sorted(node.attrib.items()):
        print('  %-4s = "%s"' % (name, value))
    for child in node:
        show_node(child)


parsed = XML('''
<root>
  <group>
    <child id="a">This is child "a".</child>
    <child id="b">This is child "b".</child>
  </group>
  <group>
    <child id="c">This is child "c".</child>
  </group>
</root>
''')

print('parsed =', parsed)

for elem in parsed:
    show_node(elem)

A diferencia de parse(), el valor de retorno es una instancia Element en lugar de un ElementTree. Un Element soporta el protocolo del iterador directamente, entonces no hay necesidad de llamar a getiterator().

$ python3 ElementTree_XML.py

parsed = <Element 'root' at 0x10079eef8>
group
child
  text: "This is child "a"."
  id   = "a"
child
  text: "This is child "b"."
  id   = "b"
group
child
  text: "This is child "c"."
  id   = "c"

Para XML estructurado que usa el atributo id para identificar nodos de interés únicos, XMLID() es una forma conveniente de acceder a los resultados del análisis.

ElementTree_XMLID.py
from xml.etree.ElementTree import XMLID

tree, id_map = XMLID('''
<root>
  <group>
    <child id="a">This is child "a".</child>
    <child id="b">This is child "b".</child>
  </group>
  <group>
    <child id="c">This is child "c".</child>
  </group>
</root>
''')

for key, value in sorted(id_map.items()):
    print('%s = %s' % (key, value))

XMLID() devuelve el árbol analizado como un objeto Element, junto con un diccionario que asigna las cadenas de atributo :attr: id a los nodos individuales en el árbol.

$ python3 ElementTree_XMLID.py

a = <Element 'child' at 0x10133aea8>
b = <Element 'child' at 0x10133aef8>
c = <Element 'child' at 0x10133af98>