Leer y escribir ficheros XML con python

De ChuWiki

Veamos cómo escribir y leer ficheros XML con python.

Módulo python xml.etree.ElementTree[editar]

En el módulo xml.etree.ElementTree que viene por defecto en phyton tenemos todo lo necesario para leer y escribir ficheros XML. Lo primero, cómo siempre, es importar el módulo

import xml.etree.ElementTree as ET

ElementTree o con el alias que le hemos puesto ET tiene muchas funciones útiles para el manejo de XML.

Leer un fichero XML[editar]

Para los ejemplos a continuación, usaremos el fichero sample.xml copiado de la [documentación oficial de python https://docs.python.org/3/library/xml.etree.elementtree.html]

Para leer el fichero XML simplemente llamamos a ET.parse() pasando el nombre del fichero. Esto nos devolverá una instancia de ET.ElementRoot a partir de la cual podemos obtener el nodo raíz y de ahí ir navegando o buscando por el árbol.

root = ET.parse("sample.xml")
root_node = root.getroot()

En root_node tenemos ya toda la estructura del XML. root_node representa el nodo raíz. Un dato importante a comentar es que ET.parse() ignora los comentarios y el encabezado inicial del XML si lo hay, algo estilo <?xml version='1.0' encoding='us-ascii'?>. Por lo que si es de interés para nosotros conservarlo, debemos usar otro parser XML más avanzado que vemos más adelante.

Acceder a la información de un nodo[editar]

Una vez tenemos el nodo, podemos acceder a su información. El nodo raíz del fichero sample.xml tiene lo siguiente

<data name="country-list">
 ...
</data>

podemos leer la información de este nodo con el siguiente código

print(root_node.tag, root_node.attrib, root_node.text)

donde tag es el nombre del tag en el fichero XML ("data"), attrib son los atributos de este tag name="country-list" y text es el texto dentro de esta tag, si lo hay. No los nodos hijos, sino texto plano en caso de que lo haya. En nuestro caso, puesto que el fichero XML está formateado, el texto es un retorno de carro y varios espacios, hasta llegar al siguiente tag. El resultado de esa línea de código sería

data {'name': 'country-list'} 
   

attrib nos devuelve un dict de python donde las claves son los nombres de los atributos y los valores son los valores de los atributos. Fíjate además que hay una línea en blanco después de los atributos, correpondiente a node_root.text.

Acceder a nodos hijos[editar]

A partir de un nodo cualquiera tenemos varias formas de acceder a los nodos hijos de este.

Una opción es hacer un bucle para recorrer todos los hijos

for child in root_node:
    print("Hijo de root_node: ", child.tag, child.attrib, child.text, child.tail)

tail corresponde al texto que hay fuera del tag y justo detrás de él. Por ejemplo, si el fichero XML tuviera <etiqueta1>...</etiqueta2>texto<etiqueta3>....</etiqueta3, entonces el tail de "etiqueta2" sería "texto", por estar justo detrás del cierre de "etiqueta2" y antes del siguiente nodo, que sería "etiqueta3".

Otra opción para acceder a los nodos hijos es usar corchetes en el nodo padre y el índice del hijo. La función len() nos dice cuántos nodos hijos tiene. Sabiendo eso, también podemos acceder por índice sin salirnos de rango. En el ejemplo a continuación lo hacemos con un bucle de 0 a len(root_node)

number_of_childs = len(root_node)
for index in range(number_of_childs):
    child = root_node[index]
    print("Hijo de root_node: ", child.tag, child.attrib, child.text, child.tail)

Si los hijos tienen más hijos y estos a su vez más hijos, podemos ir añadiendo corchetes: root_node[i][j]. Eso sí, siempre teniendo cuidado de no usar un índice más alto que el número de nodos hijos que tenga el nodo que estamos leyendo.

Buscar nodos con un tag concreto[editar]

Si queremos buscar todos los tag iguales sin importar en que nivel de profundidad estén a partir de un nodo determinado, tenemos la función iter(). En el fichero sample.xml hay tag "rank" en un nivel bastante inferior. Vamos a buscarlos.

for rank_node in root_node.iter('rank'):
   print("iter: ", rank_node.tag, rank_node.attrib, rank_node.text, rank_node.tail)

La salida de esto sería similar a la siguiente

iter:  rank {} 1 
        
iter:  rank {} 4 
        
iter:  rank {} 68 
        

Las líneas en blanco correponden a los text y tail, ya que el XML está formateado y hay entonces retornos de carro y espacios dentro de los tags.

En el ejemplo estamos buscando desde el nodo node_root, que es el nodo raíz, hacia abajo sin importar nivel de profundidad todos los tag "rank". La función iter() devuelve un iterator con todos los nodos que tienen ese tag. Ya solo es cuestión de recorrerlo y hacer con cada nodo encontrado lo que necesitemos.

Buscar con un filtro: XPath[editar]

XPath es una sintaxis que permite hacer filtros de búsqueda dentro de un XML para obtener aquellos nodos o datos que cumplan determinados filtros. Python da un soporte reducido, pero permite hacer búsquedas con estos filtros. Veamos algunos ejemplo con las funciones find() y findall. La primera función devuelve el primer elemento que encuentre que pase el filtro. La segunda función devuelve una lista con todos los elementos que pasen el filtro.

Tag[editar]

El primero filtro fácil es simplemente el nombre del tag.

print("find(country): ", root_node.find("country").attrib)

countries = root_node.findall("country")
for country in countries:
    print("findall(neighbor): ", country.attrib)

Estamos buscando la etiqueta "country", que debe estar justo debajo de node_root. Para find() sacamos por pantalla directament el único nodo que nos devuelve. Deberíamos comprobar si es None antes de sacar nada. Para findall() debemos hacer un bucle para recorrer la lista que nos devuelve con todos los nodos que ha enconetrado.

Path[editar]

Los nodos "country" deben ser hijos directos de node_root. La sintaxis de XPath nos permite hacer varias cosas según lo que queramos sacar. Hacemos los ejemplos sólo con find() para simplificar. findall() funciona igual sólo que devuelve más resultados, si los hay, y tendríamos que hacer bucles.

print("find(conuntry/neighbor): ", root_node.find("country/neighbor").attrib)

Aquí hemos puesto un path. Sólo nos devolverá nodos que cumplan ese path, es decir, justo debajo de root_node un nodo "country" que tenga a su vez debajo un nodo "neighbor". Si hay "neighbor" que no sean hijos de "country" o que ese "country" no esté justo debajo de root_node, no nos lo devolverá.

Comodines o Wildcards[editar]

Podemos usar * como nombre de uno de los tag en el path.

print("find(*/neighbor): ", root_node.find("*/neighbor").attrib)

Con esto obtendremos todos los "neighbor" que sean nietos de root_node, es decir, un tag cualquiera justo debajo de root_node que tenga a su vez tag "neighbor".

Tenemos también ., .., / y // para afinar en el path de búsqueda. Sólo varias cosas a tener en cuenta:

  • . selecciona el nodo actual, sobre el que estamos llamando al método find() o findall().
  • .. selecciona el nodo padre. No pudemos navegar por encima del nodo actual, así no podemos usarlo al principio del path.
  • / para separar nodos al estilo estructura de directorios. Phython no soporta de momento colocar / al principio para hacer el equivalente a un path absoluto desde la raíz del árbol XML. Los path siempre son relativos al nodo actual.
  • // para seleccionar una jerarquía completa sin tener en cuenta el número de niveles. Por ejemplo, si tenemos en el XML los tag anidados en este orden bisabuelo/abuelo/padre/hijo/nieto y también tenemos bisabuelo/abuela/madre/hija/nieto se puede poner como bisabuelo//nieto y encontraría todos los "nieto" indpendientemente de que vangan por parte paterna o parte materna.

Veamos ejemplos.

print(root_node.find(".//neighbor").attrib)
print(root_node.find(".//neighbor/..").attrib)

La primera línea busca del nodo actual hacia abajo todos los tag "neighbor" independientemente de en qué nivel de profunidad se encuentren. Como llamamos a find() solo devuelve un resultado y de él sacamos por pantalla los atributos attrib

La segunda línea busca lo mismo que el anterior, pero devuelve el padre del nodo "neighbor" que encuentre, es decir, en nuestro fichero de ejemplo el "country".

Búsqueda por atributos[editar]

Podemos buscar también elementos que tengan un determinado atributo, independientemente de su valor. O que tengan el atributo con un valor concreto o distinto de dicho valor. Las búsquedas serían con [@attrib], [@attrib='value'] o [@attrib!='value']. Veamos un ejemplo

print(root_node.find(".//*[@name='Austria']").attrib)

Esta expresión es más compleja. Desgranémosla:

  • .// para buscar nodos desde el nodo actual hacia abajo en cualquier nivel de profundidad. Por supuesto, podríamos pone el path que queramos.
  • * porque nos da igual el tag del nodo. Aquí, por supuesto, podemos poner un tag específico si sólo estamos intersados ese tipo de nodos.
  • [@name='Austria'] queremos que el tag seleccionado tenga un atributo name cuyo valor sea "Austria". Delante de esta expersión es obligatorio poner un tag, que en nuestro caso es el * del punto anterior.

= Búsqueda por texto[editar]

Podemos buscar nodos que tengan un determinado texto o que contengan un texto diferente de uno dado. Las expresiones son [.='text'] o [.!='text']. Veamos un ejemplo, porque hay un detalle importante

xml_string = "<a>texto de a<b>texto de b<c>texto de c</c></b></a>"
string_root_node = ET.fromstring(xml_string)
print(string_root_node.find(".//*[.='texto de c']"))
print(string_root_node.find(".//*[.='texto de btexto de c']"))
print(string_root_node.find(".[.='texto de atexto de btexto de c']"))

Como nuestro fichero de ejemplo no tiene textos, más allá de que al estar formateado tiene espacios y retornos de carro, vamos a hacer un pequeño string xml_string con un XML sencillo. En él hay tag a, b y c. Todas ellas tienen texto dentro: "texto de a", "texto de b" y "texto de c".

Con la función ET.fromstring() podemos parsear un string directamente en vez de leerlo de un fichero.

La primera llamada busca cualquier nodo en cualquier profundidad que tenga como texto "texto de c". Esto nos devolverá el nodo c.

Esta búsqueda por texto no solo busca en el texto concreto del nodo, sino que concatena el texto de todos los nodos hijos en orden. Por ello, para esta búsqueda, el nodo b no tiene el texto "texto de b", sino que tiene concatenado el de sus hijos, es decir "texto de btexto de c". Por ello, el texto que debemos buscar es "texto de btexto de c", que es lo que hacemos en la segunda llamada. Eso nos devolverá en nodo b.

En cuanto a la tercera llamada, vemoso que tenemos que concatenar los textos de todos los hijos. Pero, si nos fijamos, vemos otra diferencia. En el path de búsqueda no hemos puesto .//. Este path busca del nodo actual hacia abajo, pero excluyendo el nodo actual. Por ello usamos como path un .. Esto encontrará el nodo a.

Si en vez de [.='text'] usamos [tag='text'], el texto no tendrá que tenerlo el nodo encontrado, sino un hijo tag del nodo que nos devolverá la búsqueda. Es decir, buscará nodos que tengan un hijo de tipo tag con ese texto.

Modificar un fichero XML[editar]

Una vez que tengamos el XML en memoria y sabemos navegar por él, podemos modificarlo si nos hace falta. Hay varios métodos que podemos usar.

El método clear() limpia el nodo completo y borra los hijos. Deja solo el tag limpio.

panama = root_node.find(".//*[@name='Panama']")
ET.dump(panama)

panama.clear()
ET.dump(panama)

ET.dump() saca por pantalla el nodo. La primera llamada sacará el nodo de nombre "Panama" completo.

<country name="Panama">
    <rank>68</rank>
    <year>2011</year>
    <gdppc>13600</gdppc>
    <neighbor name="Costa Rica" direction="W" />
    <neighbor name="Colombia" direction="E" />
</country>

Después de llamar a clear(), nos queda sólo el tag, sin hijos, sin atributos y sin texto interno si lo hubiera.

<country />

Sin ser tan drásticos, podemos modificar directamente los elementos de un nodo: text, inner, attrib, etc.

panama.set("name", "Panama")
panama.attrib["key"] = "value"
panama.text = "The text"
ET.dump(panama)
panama.attrib.pop("key")
ET.dump(panama)

Podemos añadir atributos con el método set() a bien accediendo directamente a attrib, que no deja de ser un dict de python. Hemos creado dos atributos "name" y "key". Luego hemos puesto texto interno con panama.text y sacamos el resultado por pantalla

<country name="Panama" key="value">The text</country>

Luego eliminamos el atributo "key" que habíamos creado, en nuevo resultado es

<country name="Panama">The text</country>

Podemos añadir y eliminar nodos hijos. La parte de añadir se ve en el apartado de Crear el árbol XML desde cero. La parte de borrar hijos es simplemente llamar al método remove(). Admite como parámetro el elemento hijo que queremos borrar.

for country in root_node.findall("country"):
   if "Panama" == country.get("name"):
      root_node.remove(country)

En el ejemplo buscamos todos los "country" de "root_element" con findall() y en bucle buscamos el que se llama "Panama" y lo borramos llamando a root_element.remove(). Varias cosas

  • Se podría usar XPath con country[@name='Panana'] para evitar el if interno. Lo único a tener en cuenta es que root_node.remove() borra hijos directos de root_node, por lo que con XPath sólo podemos buscar hijos directos de root_node y no nietos o bisnietos.
  • Hay que tener cuidado con cómo recorremos root_node para borrarle elementos. Por ejemplo iter() devuelve un iterador. Si borramos elementos mientas estamos iterando en un bucle podemos tener resultados impredecibles. findall() devuelve una lista que es independiente de root_nodo, por lo que podemos recorrer la lista y borrar elementos del root_node en el mismo bucle.

Crear el árbol XML desde cero[editar]

Supongamos que no tenemos fichero XML previo y que queremos construir en código y desde cero un fichero XML. Veamos los pasos y posibilidades.

Creación del nodo raíz[editar]

Primero creamos el nodo raíz del fichero XML usando ET.Element().

a = ET.Element('A')
a.set("name", "abuelo")

Como parámetro hemos pasado el nombre del tag xml que tendrá este elemento en el fichero. Luego, con set() podemos ponerle atributos, en este ejemplo, name="abuelo". Si sacaramos como XML este trocito, el restultado sería

<A name="abuelo" />

Creación de nodos hijos[editar]

Para crear nodos hijos, usamos la función ET.Subelement() con dos parámetros: El nodo padre del nuevo elemento y el tag XML del nuevo nodo como primer parámetro

b = ET.SubElement(a, 'B', {"name": "padre"})

Con esto tenemos un nodo b cuyo tag XML es "B" y que cuelga del nodo raíz a. Hemos añadido un tercer parámetro opcional que es un dict de python. Esto serán atributos del tag XML. Es una alternativa a lo mismo que hicimos con el nodo raíz con el método set(): Poner los atributos en la misma creación del nodo o bien ponerlos después.

La salida de lo que llevamos hasta ahora sería

<A name="abuelo">
 <B name="padre" />
</A>

Texto dentro del nodo[editar]

Vamos a crear un tercer elemento que sea hijo de b. Pero queremos que b tenga texto antes y después de que aparezca el nuevo nodo. El código sería el siguiente

c = ET.SubElement(b, 'C', {"name": "niño1"})
b.text = "inner1"
b.tail = "inner2"

Hemos creado el nodo c como hijo de b de forma similar a como creamos b. Ahora a b le ponemos los textos que queramos usando sus atributos b.text y b.tail. El primero irá detrás del tag de apertura, pero delante de los nodos hijos y el segundo detrás del tag de cierre, antes del siguente tag. La salida de lo que llevamos hasta ahora sería

<A name="abuelo">
 <B name="padre">inner1
    <C name="niño1" />
 </B>inner2
</A>

Vemos que el texto "inner1" está dentro del tag B pero delante de C, mientras que "inner2" está fuera de B pero delante del cierre de A

Añadir comentarios[editar]

Para crear un comentario, usamos ET.Comment() para crear un nodo comentario y lo añadimos como hijo de quién queramos.

comment = ET.Comment("Comentario")
c.append(comment)

Lo hemos creado pasando como parámetro el texto que queramos y luego lo hemos añadido con append() al nodo c. La salida de todo lo que llevamos hasta ahora sería

<A name="abuelo">
 <B name="padre">inner1
   <C name="niño1">
     <!--Comentario-->
    </C>
 </B>inner2
</A>

Escribir el fichero XML[editar]

Una vez construido el árbol, guardamos el fichero. Para ello necesitamos instanciar la clase ET.ElementTree pasaándole nuestro nodo raíz, configurarla como queramos y llamar al método write()

ET.indent(a)
et = ET.ElementTree(a)
et.write("fichero.xml", xml_declaration=True)

La primera línea es opcional. Si no la ponemos, el ficheo XML no estará indentado (pretty print) y será una única línea toda seguida. La llamada a esta función admite como parámetro obligatorio el nodo raíz. Tiene un segundo parámetro opcional, space=' ' que es la cadena de caracteres que queremos usar para indentar. Por defecto es de dos caracteres, pero podemos poner 3, 4, tabulador o lo que queramos.

La segunda línea crea la instancia de ET.ElementTree pasándole como parámetro el nodo raíz.

Finalmente, la llamada a et.write() pasando com parámetro obligatorio el nombre del fichero XML que queremos crear. El segundo parámetro xml_declaration es opcional y por defecto vale False. Es si queremos o no que ponga como primera línea el encabezado XML <?xml version='1.0' encoding='us-ascii'?>. Aquí indicamos que sí lo queremos.

La salida en el fichero sería

<?xml version='1.0' encoding='us-ascii'?>
<A name="abuelo">
  <B name="padre">inner1<C name="ni&#241;o1">
      <!--Comentario-->
    </C>
  </B>inner2</A>

Fíjate en varias cosas:

  • Ha puesto el encabezado XML con el encoding a us-ascii. Es la opción por defecto. El encoding se puede cambiar con el parámetro opcional encoding='us-ascii' del método write().
  • La "ñ" de "niño" la cambiado por "&#241" que sería la "ñ" con la codificación "us-ascii".
  • "inner1" e "inner" no llevan retornos de carro detrás.

Lectura de comentarios y namespaces: XMLPullParser python[editar]

En el apartado de lectura del fichero XML en python comentamos que no se leían comentarios del fichero ni namespaces en los tag. El parser fácil de python no los lee. Veamos ahora como leer comentarios y namespaces usando XMLPullParser. Este parser tiene una ventaja adicional sobre ElementTree y es que no es necesario cargar/tener todo el fichero XML de golpe en memoria. La filosofía de XMLPullParser es que le vayamos alimentando con líneas o trozos de texto del fichero XML. XMLPullParser nos irá avisando cada vez que encuentre algún tag de XML y nosotros decidimos si queremos contruir todo el árbol para tenerlo en memoria o simplemente vamos tratando los datos según requiera nuestra aplicación. Los avisos nos los dará en forma de eventos. Cada evento cotiene de qué tipo es (lo vemos más adelante) y la información leída del XML relativa a ese evento.

Para este ejemplo, usaremos el fichero sample-pull.xml. Es igual que sample.xml pero nos hemos inventado un namespace, hemos puesto un comentario y un texto.

El código completo del ejemplo lo tienes en pull-parser.py y es el siguiente

import xml.etree.ElementTree as ET

if __name__ == '__main__':
    parser = ET.XMLPullParser(['end', 'comment', 'start-ns'])

    with open("sample-pull.xml") as data:
        for line in data:
            parser.feed(line)
            iterator = parser.read_events()
            for event, element in iterator:
                if 'start-ns' == event:
                    print(event, element)
                else:
                    print(event, element.tag, element.attrib, element.text)

Vamos viendo detalles.

Primero creamos una instancia de ET.XMLPullParser. Este parser nos irá avisando según vaya encontrando cosas de interés en los datos que le vayamos pasando. Si no ponemos parámetros en la creación, sólo nos avisará cuando encuentre el final de un tag de XML. No nos avisará de comentarios ni de namespaces. Podemos pasar como parámetro una listas de string donde cada string representa una de las posibles cosas en las que tenemos interés (tipo de evento):

  • start si queremos que nos avise cuando se abra un tag en XML
  • end si queremos que nos avise cuando se termine un tag en XML
  • comment si queremos que nos avise cuando se encuentre un comentario en XML
  • start-ns si queremos que nos avise cuando encuentre un principio de namespace
  • end-ns si queremos que nos avise cuando encuentre el fin de un namespace
  • pi si queremos que nos avise cuando encuentre processing instruction, cosas estilo <?xml-stylesheet type="text/xsl" href="style.xsl"?>. La sintaxis de las processing instructions es <?PITarget PIContent?>. La primera línea habitual en los XML <?xml version="1.0"?> es una excepción. Cumple esa sintaxis, pero no es una processing instruction.

En el ejemplo hemos puesto que nos avise con los fin de tag, con los comentarios y con el inicio del namespace. Hemos elegido estos porque el evento que nos devuelve el parser cuando encuentra estas cosas de interés son las que contienen la información completa del nodo leído.

Una vez declardo el parser, abrimos el fichero "sample-pul.xml" y hacemos un bucle para ir leyendo líneas. Cada línea leída la usamos para alimentar al parser con parser.feed(). Una vez alimentado, leemos los eventos que haya generado el parser para ese trozo de texto. Usamoa para ello la función parser.read_events() que nos devuelve un iterador con los eventos.

Así que ahora toca bucle para el iterador. El iterador devuelve una lista con dos elementos. Uno el tipo de evento (end, comment, start-ns) y contenido del XML que ha provocado ese evento. Este contenido puede ser distinto según el evento.

Para cada evento aquí solo vamos a sacar por pantalla el tipo de evento y su contenido. El bucle for event, element in iterator extrae del iterador el tipo de evento event y su contenido element. Para el caso de start-ns el contenido es una tupla. Para el caso de end y de comment es un xml.etree.ElementTree.Element, así que los trataremos distinto. De ahí el if dentro del bucle.

Si ejecutamos el programa y vemos la salida, para el caso del namespace xmlns:c="http://chuidiang.org/examples/" veremos en la salida

start-ns ('c', 'http://chuidiang.org/examples/')

La tupla contiene la letra c del namespace y la URL.

En el caso del comentario, obtenemos

comment <function Comment at 0x00000196D2200EA0> {}  Some comment

El comentario está en element.text.

En cuanto a los end-tag, podemos ver cualquiera de ellos. Veamos dos ejemplos

end gdppc {} 13600
end neighbor {'name': 'Costa Rica', 'direction': 'W'} None

El primero es de un tag gdppc, sin atributos {} y con texto 13600. El segundo es del tag neighbor, sí tiene atributos {'name': 'Costa Rica', 'direction': 'W'} y no tiene texto interno None.

Hay que mencionar un caso especial. Si te fijas en el fichero sample-pull.xml, hemos puesto algún tag con el namespace, en concreto <c:country name="Liechtenstein">. La salida de nuestro programa para este tag es

end {http://chuidiang.org/examples/}country {'name': 'Liechtenstein'}

Fíjate que ha reemplazado la c del XML por la URL entre llaves http://chuidiang.org/examples/}.