Дорожная карта для анализа XML на Python

Оглавление

Если вы когда-либо пытались разобрать XML-документ на Python, то вы знаете, насколько удивительно сложной может быть такая задача. С одной стороны, Дзен Python обещает только один очевидный способ достижения вашей цели. В то же время стандартная библиотека следует принципу в комплекте батарейки, позволяя вам выбирать не из одного, а из нескольких синтаксических анализаторов XML. К счастью, сообщество Python решило эту проблему избытка, создав еще больше библиотек для синтаксического анализа XML.

Шутки в сторону, но все XML-анализаторы имеют свое место в мире, полном мелких или крупных задач. Стоит ознакомиться с доступными инструментами.

В этом руководстве вы узнаете, как:

  • Выберите правильную модель синтаксического анализа XML
  • Используйте синтаксические анализаторы XML в стандартной библиотеке
  • Использовать основные библиотеки для синтаксического анализа XML
  • Выполнять декларативный анализ XML-документов с использованием привязки данных
  • Используйте безопасные синтаксические анализаторы XML для устранения уязвимостей в системе безопасности

Вы можете использовать это руководство в качестве дорожной карты, которая поможет вам разобраться в запутанном мире синтаксических анализаторов XML в Python. К концу этого курса вы сможете выбрать подходящий XML-анализатор для данной задачи. Чтобы извлечь максимальную пользу из этого руководства, вы уже должны быть знакомы с XML и его компонентами, а также с тем, как работать с файлами в Python.

Бесплатный бонус: 5 Размышления о мастерстве владения Python - бесплатный курс для разработчиков Python, который показывает вам план действий и мышление, с которым вы будете работать. вам нужно поднять свои навыки работы с Python на новый уровень.

Выберите правильную модель синтаксического анализа XML

Оказывается, вы можете обрабатывать XML-документы, используя несколько стратегий, не зависящих от языка. Каждая из них демонстрирует различные компромиссы в отношении объема памяти и скорости, что может частично оправдать широкий спектр синтаксических анализаторов XML, доступных в Python. В следующем разделе вы узнаете об их различиях и сильных сторонах.

Объектная модель документа (DOM)

Исторически первой и наиболее распространенной моделью для синтаксического анализа XML была DOM, или объектная модель документа , первоначально определенная консорциумом World Wide Web Consortium (W3C). Возможно, вы уже слышали о DOM, потому что веб-браузеры предоставляют интерфейс DOM через JavaScript, что позволяет вам манипулировать HTML-кодом ваших веб-сайтов. И XML, и HTML принадлежат к одному семейству языков разметки , что делает возможным синтаксический анализ XML с помощью DOM.

DOM, пожалуй, является самой простой и универсальной моделью для использования. Она определяет несколько стандартных операций для перемещения и изменения элементов документа, расположенных в иерархии объектов. Абстрактное представление всего дерева документа хранится в памяти, предоставляя вам произвольный доступ к отдельным элементам.

Хотя дерево DOM обеспечивает быструю и всенаправленную навигацию, построение его абстрактного представления, в первую очередь, может занять много времени. Более того, XML-файл анализируется сразу, как единое целое, поэтому он должен быть достаточно маленьким, чтобы вместить доступную память. Это делает DOM подходящим только для конфигурационных файлов умеренного размера, а не для многобайтных XML-баз данных.

Используйте синтаксический анализатор DOM, когда удобство важнее времени обработки и когда память не является проблемой. Некоторые типичные случаи использования - это когда вам нужно проанализировать относительно небольшой документ или когда вам нужно выполнять синтаксический анализ нечасто.

Простой API для XML (SAX)

Чтобы устранить недостатки DOM, сообщество Java совместными усилиями создало библиотеку, которая затем стала альтернативной моделью для синтаксического анализа XML на других языках. Формальной спецификации не было, только обычные обсуждения в списке рассылки. Конечным результатом стал потоковый API на основе событий, который последовательно работает с отдельными элементами, а не со всем деревом.

Элементы обрабатываются сверху вниз в том же порядке, в котором они отображаются в документе. Синтаксический анализатор запускает определенные пользователем обратные вызовы для обработки определенных узлов XML по мере их нахождения в документе. Этот подход известен как “принудительный” синтаксический анализ, поскольку синтаксический анализатор передает элементы в ваши функции.

SAX также позволяет вам отбрасывать элементы, если они вас не интересуют. Это означает, что он занимает гораздо меньше места в памяти, чем DOM, и может обрабатывать файлы произвольного размера, что отлично подходит для однопроходной обработки, такой как индексация, преобразование в другие форматы и так далее.

Однако поиск или изменение случайных узлов дерева является сложной задачей, поскольку обычно требует многократного просмотра документа и отслеживания посещенных узлов. SAX также неудобен для обработки глубоко вложенных элементов. Наконец, модель SAX просто допускает синтаксический анализ только для чтения.

Короче говоря, SAX дешев с точки зрения пространства и времени, но в большинстве случаев его сложнее использовать, чем DOM. Он хорошо подходит для анализа очень больших документов или входящих XML-данных в режиме реального времени.

Потоковый API для XML (StAX)

Хотя этот третий подход к синтаксическому анализу XML несколько менее популярен в Python, он основан на SAX. Это расширяет идею потоковой передачи, но вместо этого использует модель “вытягивания” синтаксического анализа , которая дает вам больше контроля. Вы можете рассматривать StAX как итератор, перемещающий объект курсора через XML-документ, где пользовательские обработчики вызывают синтаксический анализатор по требованию, а не наоборот.

Примечание: Можно комбинировать более одной модели синтаксического анализа XML. Например, вы можете использовать SAX или StAX, чтобы быстро найти интересующий фрагмент данных в документе, а затем создать представление DOM только для этой конкретной ветви в памяти.

Использование StAX дает вам больший контроль над процессом синтаксического анализа и обеспечивает более удобное управление состоянием. События в потоке используются только по запросу, что позволяет выполнять отложенную оценку. Кроме этого, его производительность должна быть на уровне SAX, в зависимости от реализации синтаксического анализатора.

Узнайте больше о синтаксических анализаторах XML в стандартной библиотеке Python

В этом разделе вы познакомитесь со встроенными в Python синтаксическими анализаторами XML, которые доступны практически в каждом дистрибутиве Python. Вы собираетесь сравнить эти анализаторы с образцом Масштабируемой векторной графики (SVG) изображения, которое представляет собой формат, основанный на XML. Обрабатывая один и тот же документ разными парсерами, вы сможете выбрать тот, который подходит вам больше всего.

На примере изображения, которое вы собираетесь сохранить в локальном файле для ознакомления, изображен смайлик. Оно содержит следующее содержимое в формате XML:

<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN"
  "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd" [
    <!ENTITY custom_entity "Hello">
]>
<svg xmlns="http://www.w3.org/2000/svg"
  xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
  viewBox="-105 -100 210 270" width="210" height="270">
  <inkscape:custom x="42" inkscape:z="555">Some value</inkscape:custom>
  <defs>
    <linearGradient id="skin" x1="0" x2="0" y1="0" y2="1">
      <stop offset="0%" stop-color="yellow" stop-opacity="1.0"/>
      <stop offset="75%" stop-color="gold" stop-opacity="1.0"/>
      <stop offset="100%" stop-color="orange" stop-opacity="1"/>
    </linearGradient>
  </defs>
  <g id="smiley" inkscape:groupmode="layer" inkscape:label="Smiley">
    <!-- Head -->
    <circle cx="0" cy="0" r="50"
      fill="url(#skin)" stroke="orange" stroke-width="2"/>
    <!-- Eyes -->
    <ellipse cx="-20" cy="-10" rx="6" ry="8" fill="black" stroke="none"/>
    <ellipse cx="20" cy="-10" rx="6" ry="8" fill="black" stroke="none"/>
    <!-- Mouth -->
    <path d="M-20 20 A25 25 0 0 0 20 20"
      fill="white" stroke="black" stroke-width="3"/>
  </g>
  <text x="-40" y="75">&custom_entity; &lt;svg&gt;!</text>
  <script>
    <![CDATA[
      console.log("CDATA disables XML parsing: <svg>")
      const smiley = document.getElementById("smiley")
      const eyes = document.querySelectorAll("ellipse")
      const setRadius = r => e => eyes.forEach(x => x.setAttribute("ry", r))
      smiley.addEventListener("mouseenter", setRadius(2))
      smiley.addEventListener("mouseleave", setRadius(8))
    ]]>
  </script>
</svg>

Он начинается с XML-объявления, за которым следует Определение типа документа (DTD) и <svg> корневой элемент. DTD необязателен, но он может помочь проверить структуру вашего документа, если вы решите использовать средство проверки XML. Корневой элемент определяет пространство имен по умолчанию xmlns а также пространство имен с префиксом xmlns:inkscape для элементов, зависящих от редактора и атрибуты. Документ также содержит:

  • Вложенные элементы
  • Атрибуты
  • Комментарии
  • Символьные данные (CDATA)
  • Предопределенные и пользовательские объекты

Далее, сохраните XML-файл с именем smiley.svg и откройте его с помощью современного веб-браузера, который запустит фрагмент JavaScript, представленный в конце:

Smiley Face (SVG)

Код добавляет интерактивный компонент к изображению. Когда вы наводите курсор мыши на смайлик, он моргает глазами. Если вы хотите отредактировать смайлик с помощью удобного графического интерфейса пользователя (GUI), то вы можете открыть файл с помощью редактора векторной графики, такого как Adobe Illustrator или Inkscape.

Примечание: В отличие от JSON или YAML, некоторые функции XML могут быть использованы хакерами. Стандартные синтаксические анализаторы XML, доступные в пакете xml на Python, небезопасны и уязвимы для множества атак . Чтобы безопасно анализировать XML-документы из ненадежного источника, отдавайте предпочтение безопасным альтернативам. Вы можете перейти к последнему разделу этого руководства для получения более подробной информации.

Стоит отметить, что стандартная библиотека Python определяет абстрактные интерфейсы для синтаксического анализа XML-документов, позволяя при этом предоставить конкретную реализацию синтаксического анализатора. На практике вы редко это делаете, потому что Python включает привязку к библиотеке Expat, которая представляет собой широко используемый синтаксический анализатор XML с открытым исходным кодом, написанный на C. Все следующие модули Python в стандартной библиотеке по умолчанию используют Expat под капотом.

К сожалению, хотя анализатор Expat может определить, является ли ваш документ правильно оформленным, он не может проверить структуру вашего документа. документы в соответствии с Определением схемы XML (XSD) или Определением типа документа (DTD). Для этого вам придется воспользоваться одной из сторонних библиотек, о которых речь пойдет ниже.

xml.dom.minidom: Минимальная реализация DOM

Учитывая, что синтаксический анализ XML-документов с использованием DOM, возможно, является наиболее простым, вы не будете сильно удивлены, обнаружив синтаксический анализатор DOM в стандартной библиотеке Python. Однако, что удивительно, так это то, что на самом деле существует два DOM-анализатора.

Пакет xml.dom содержит два модуля для работы с DOM в Python:

  1. xml.dom.minidom
  2. xml.dom.pulldom

Первый представляет собой урезанную реализацию интерфейса DOM, соответствующую относительно старой версии спецификации W3C. Он предоставляет общие объекты, определенные DOM API, такие как Document, Element, и Attr. Этот модуль плохо документирован и, как вы скоро узнаете, имеет весьма ограниченную полезность.

Второй модуль имеет немного вводящее в заблуждение название, поскольку он определяет анализатор потокового извлечения, который может дополнительно создавать DOM-представление текущий узел в дереве документа. Более подробную информацию о pulldom синтаксическом анализаторе вы найдете позже.

В minidom есть две функции, которые позволяют анализировать XML-данные из различных источников данных. Один принимает либо имя файла, либо файловый объект, в то время как другой ожидает строку Python:

>>> from xml.dom.minidom import parse, parseString

>>> # Parse XML from a filename
>>> document = parse("smiley.svg")

>>> # Parse XML from a file object
>>> with open("smiley.svg") as file:
...     document = parse(file)
...

>>> # Parse XML from a Python string
>>> document = parseString("""\
... <svg viewBox="-105 -100 210 270">
...   <!-- More content goes here... -->
... </svg>
... """)

Строка , заключенная в тройные кавычки, позволяет вставлять многострочный строковый литерал без использования символа продолжения (\) в конце каждой строки. В любом случае, в итоге вы получите экземпляр Document, который обладает знакомым интерфейсом DOM, позволяющим перемещаться по дереву.

Кроме того, вы сможете получить доступ к XML-объявлению, DTD и корневому элементу:

>>> document = parse("smiley.svg")

>>> # XML Declaration
>>> document.version, document.encoding, document.standalone
('1.0', 'UTF-8', False)

>>> # Document Type Definition (DTD)
>>> dtd = document.doctype
>>> dtd.entities["custom_entity"].childNodes
[<DOM Text node "'Hello'">]

>>> # Document Root
>>> document.documentElement
<DOM Element: svg at 0x7fc78c62d790>

Как вы можете видеть, несмотря на то, что синтаксический анализатор XML по умолчанию в Python не может проверять документы, он все равно позволяет вам проверять .doctype, DTD, если он присутствует. Обратите внимание, что объявление XML и DTD необязательны. Если объявление XML или данный атрибут XML отсутствуют, то соответствующие атрибуты Python будут None.

Чтобы найти элемент по идентификатору, вы должны использовать экземпляр Document, а не конкретный родительский элемент Element. В примере SVG-изображения есть два узла с атрибутом id, но вы не можете найти ни один из них:

>>> document.getElementById("skin") is None
True
>>> document.getElementById("smiley") is None
True

Это может удивить того, кто работал только с HTML и JavaScript, но никогда раньше не работал с XML. В то время как HTML определяет семантику для определенных элементов и атрибутов, таких как <body> или id, XML не придает никакого значения своим компоновочным блокам. Вам нужно явно пометить атрибут как идентификатор, используя DTD или вызывая .setIdAttribute() в Python, например:

Definition Style Implementation
DTD <!ATTLIST linearGradient id ID #IMPLIED>
Python linearGradient.setIdAttribute("id")

Однако использования DTD недостаточно для устранения проблемы, если ваш документ имеет пространство имен по умолчанию, как в случае с образцом SVG-изображения. Чтобы решить эту проблему, вы можете посетить все элементы рекурсивно в Python, проверить, есть ли у них атрибут id, и указать его в качестве идентификатора за один раз:

>>> from xml.dom.minidom import parse, Node

>>> def set_id_attribute(parent, attribute_name="id"):
...     if parent.nodeType == Node.ELEMENT_NODE:
...         if parent.hasAttribute(attribute_name):
...             parent.setIdAttribute(attribute_name)
...     for child in parent.childNodes:
...         set_id_attribute(child, attribute_name)
...
>>> document = parse("smiley.svg")
>>> set_id_attribute(document)

Ваша пользовательская функция set_id_attribute() принимает родительский элемент и необязательное имя для атрибута identity, которое по умолчанию равно "id". Когда вы вызываете эту функцию в своем документе SVG, все дочерние элементы, имеющие атрибут id, становятся доступными через DOM API:

>>> document.getElementById("skin")
<DOM Element: linearGradient at 0x7f82247703a0>

>>> document.getElementById("smiley")
<DOM Element: g at 0x7f8224770940>

Теперь вы получаете ожидаемый XML-элемент, соответствующий значению атрибута id.

Использование идентификатора позволяет найти не более одного уникального элемента, но вы также можете найти коллекцию похожих элементов по их тегу . В отличие от метода .getElementById(), вы можете вызвать .getElementsByTagName() для документа или определенного родительского элемента, чтобы уменьшить область поиска:

>>> document.getElementsByTagName("ellipse")
[
    <DOM Element: ellipse at 0x7fa2c944f430>,
    <DOM Element: ellipse at 0x7fa2c944f4c0>
]

>>> root = document.documentElement
>>> root.getElementsByTagName("ellipse")
[
    <DOM Element: ellipse at 0x7fa2c944f430>,
    <DOM Element: ellipse at 0x7fa2c944f4c0>
]

Обратите внимание, что .getElementsByTagName() всегда возвращает список элементов вместо одного элемента или None. Забывание об этом при переключении между обоими методами является распространенным источником ошибок.

К сожалению, такие элементы, как <inkscape:custom>, которые имеют префикс с идентификатором пространства имен, включены не будут. Их необходимо искать с помощью .getElementsByTagNameNS(), который ожидает разные аргументы:

>>> document.getElementsByTagNameNS(
...     "http://www.inkscape.org/namespaces/inkscape",
...     "custom"
... )
...
[<DOM Element: inkscape:custom at 0x7f97e3f2a3a0>]

>>> document.getElementsByTagNameNS("*", "custom")
[<DOM Element: inkscape:custom at 0x7f97e3f2a3a0>]

Первым аргументом должно быть пространство имен XML, которое обычно имеет форму доменного имени, в то время как вторым аргументом является имя тега. Обратите внимание, что префикс пространства имен не имеет значения! Для поиска по всем пространствам имен вы можете ввести подстановочный знак (*).

Примечание: Чтобы найти пространства имен, объявленные в вашем XML-документе, вы можете проверить атрибуты корневого элемента. Теоретически, они могут быть объявлены в любом элементе, но обычно вы их находите на верхнем уровне.

Как только вы найдете интересующий вас элемент, вы можете использовать его для перемещения по дереву. Однако еще одна неприятная особенность minidom заключается в том, как он обрабатывает пробельные символы между элементами:

>>> element = document.getElementById("smiley")

>>> element.parentNode
<DOM Element: svg at 0x7fc78c62d790>

>>> element.firstChild
<DOM Text node "'\n    '">

>>> element.lastChild
<DOM Text node "'\n  '">

>>> element.nextSibling
<DOM Text node "'\n  '">

>>> element.previousSibling
<DOM Text node "'\n  '">

Символы новой строки и начальный отступ отображаются как отдельные элементы дерева, чего требует спецификация. Некоторые синтаксические анализаторы позволяют игнорировать их, но не Python. Однако, что вы можете сделать, так это свернуть пробелы в таких узлах вручную:

>>> def remove_whitespace(node):
...     if node.nodeType == Node.TEXT_NODE:
...         if node.nodeValue.strip() == "":
...             node.nodeValue = ""
...     for child in node.childNodes:
...         remove_whitespace(child)
...
>>> document = parse("smiley.svg")
>>> set_id_attribute(document)
>>> remove_whitespace(document)
>>> document.normalize()

Обратите внимание, что вам также необходимо .normalize() объединить в документе смежные текстовые узлы. В противном случае вы можете получить кучу избыточных XML-элементов, содержащих только пробелы. Опять же, рекурсия - это единственный способ обращения к элементам дерева, поскольку вы не можете выполнять итерацию по документу и его элементам с помощью цикла. Наконец, это должно дать вам ожидаемый результат:

>>> element = document.getElementById("smiley")

>>> element.parentNode
<DOM Element: svg at 0x7fc78c62d790>

>>> element.firstChild
<DOM Comment node "' Head '">

>>> element.lastChild
<DOM Element: path at 0x7f8beea0f670>

>>> element.nextSibling
<DOM Element: text at 0x7f8beea0f700>

>>> element.previousSibling
<DOM Element: defs at 0x7f8beea0f160>

>>> element.childNodes
[
    <DOM Comment node "' Head '">,
    <DOM Element: circle at 0x7f8beea0f4c0>,
    <DOM Comment node "' Eyes '">,
    <DOM Element: ellipse at 0x7fa2c944f430>,
    <DOM Element: ellipse at 0x7fa2c944f4c0>,
    <DOM Comment node "' Mouth '">,
    <DOM Element: path at 0x7f8beea0f670>
]

Элементы предоставляют несколько полезных методов и свойств, позволяющих запрашивать их подробную информацию:

>>> element = document.getElementsByTagNameNS("*", "custom")[0]

>>> element.prefix
'inkscape'

>>> element.tagName
'inkscape:custom'

>>> element.attributes
<xml.dom.minidom.NamedNodeMap object at 0x7f6c9d83ba80>

>>> dict(element.attributes.items())
{'x': '42', 'inkscape:z': '555'}

>>> element.hasChildNodes()
True

>>> element.hasAttributes()
True

>>> element.hasAttribute("x")
True

>>> element.getAttribute("x")
'42'

>>> element.getAttributeNode("x")
<xml.dom.minidom.Attr object at 0x7f82244a05f0>

>>> element.getAttribute("missing-attribute")
''

Например, вы можете проверить пространство имен элемента, название тега или атрибуты. Если вы запросите отсутствующий атрибут, то получите пустую строку ('').

Работа с атрибутами, расположенными в пространстве имен, не сильно отличается. Вам просто нужно помнить о соответствующем префиксе к имени атрибута или указать доменное имя:

>>> element.hasAttribute("z")
False

>>> element.hasAttribute("inkscape:z")
True

>>> element.hasAttributeNS(
...     "http://www.inkscape.org/namespaces/inkscape",
...     "z"
... )
...
True

>>> element.hasAttributeNS("*", "z")
False

Как ни странно, подстановочный знак (*) здесь не работает, как это было с методом .getElementsByTagNameNS() ранее.

Поскольку в этом руководстве речь идет только о синтаксическом анализе XML, вам нужно будет ознакомиться с документацией minidom на предмет методов, которые изменяют дерево DOM. В основном они соответствуют спецификации W3C.

Как вы можете видеть, модуль minidom не очень удобен. Его главное преимущество заключается в том, что он является частью стандартной библиотеки, а это означает, что вам не нужно устанавливать какие-либо внешние зависимости в свой проект для работы с DOM.

xml.sax: Интерфейс SAX для Python

Чтобы начать работать с SAX в Python, вы можете использовать те же удобные функции parse() и parseString(), что и раньше, но вместо этого из пакета xml.sax. Вы также должны указать, по крайней мере, еще один обязательный аргумент, который должен быть экземпляром обработчика содержимого. В духе Java, вы предоставляете его, создавая подклассы определенного базового класса:

from xml.sax import parse
from xml.sax.handler import ContentHandler

class SVGHandler(ContentHandler):
    pass

parse("smiley.svg", SVGHandler())

Обработчик содержимого получает поток событий, соответствующих элементам в вашем документе во время его синтаксического анализа. Выполнение этого кода пока не принесет ничего полезного, поскольку ваш класс обработчика пуст. Чтобы это заработало, вам нужно перегрузить один или несколько методов обратного вызова из суперкласса.

Запустите свой любимый редактор, введите следующий код и сохраните его в файле с именем svg_handler.py:

# svg_handler.py

from xml.sax.handler import ContentHandler

class SVGHandler(ContentHandler):

    def startElement(self, name, attrs):
        print(f"BEGIN: <{name}>, {attrs.keys()}")

    def endElement(self, name):
        print(f"END: </{name}>")

    def characters(self, content):
        if content.strip() != "":
            print("CONTENT:", repr(content))

Этот модифицированный обработчик содержимого выводит несколько событий в стандартный вывод. Синтаксический анализатор SAX вызовет для вас эти три метода в ответ на поиск начального тега, конечного тега и некоторого текста между ними. Когда вы откроете интерактивную сессию интерпретатора Python, импортируйте свой обработчик содержимого и протестируйте его. Это должно привести к следующему результату:

>>> from xml.sax import parse
>>> from svg_handler import SVGHandler
>>> parse("smiley.svg", SVGHandler())
BEGIN: <svg>, ['xmlns', 'xmlns:inkscape', 'viewBox', 'width', 'height']
BEGIN: <inkscape:custom>, ['x', 'inkscape:z']
CONTENT: 'Some value'
END: </inkscape:custom>
BEGIN: <defs>, []
BEGIN: <linearGradient>, ['id', 'x1', 'x2', 'y1', 'y2']
BEGIN: <stop>, ['offset', 'stop-color', 'stop-opacity']
END: </stop>
⋮

По сути, это шаблон проектирования observer, который позволяет постепенно преобразовывать XML в другой иерархический формат. Допустим, вы хотите преобразовать этот SVG-файл в упрощенное представление в формате JSON. Во-первых, вы захотите сохранить свой объект content handler в отдельной переменной, чтобы позже извлекать из него информацию:

>>> from xml.sax import parse
>>> from svg_handler import SVGHandler
>>> handler = SVGHandler()
>>> parse("smiley.svg", handler)

Поскольку синтаксический анализатор SAX генерирует события без предоставления какого-либо контекста об элементе, который он нашел, вам необходимо отслеживать, где вы находитесь в дереве. Следовательно, имеет смысл поместить текущий элемент в стек, который вы можете смоделировать с помощью обычного списка Python. Вы также можете определить вспомогательное свойство .current_element, которое будет возвращать последний элемент, помещенный на вершину стека:

# svg_handler.py

# ...

class SVGHandler(ContentHandler):

    def __init__(self):
        super().__init__()
        self.element_stack = []

    @property
    def current_element(self):
        return self.element_stack[-1]

    # ...

Когда синтаксический анализатор SAX находит новый элемент, вы можете немедленно записать его имя тега и атрибуты, одновременно создавая заполнители для дочерних элементов и значения, оба из которых являются необязательными. На данный момент вы можете сохранить каждый элемент как объект dict. Замените существующий метод .startElement() новой реализацией:

# svg_handler.py

# ...

class SVGHandler(ContentHandler):

    # ...

    def startElement(self, name, attrs):
        self.element_stack.append({
            "name": name,
            "attributes": dict(attrs),
            "children": [],
            "value": ""
        })

Синтаксический анализатор SAX предоставляет вам атрибуты в виде отображения, которые вы можете преобразовать в обычный словарь Python с вызовом dict() функция. Значение элемента часто распределено по нескольким частям, которые можно объединить с помощью оператора plus (+) или соответствующего расширенного оператора присваивания:

# svg_handler.py

# ...

class SVGHandler(ContentHandler):

    # ...

    def characters(self, content):
        self.current_element["value"] += content

Агрегирование текста таким образом гарантирует, что многострочное содержимое попадет в текущий элемент. Например, тег <script> в примере файла SVG содержит шесть строк кода JavaScript, которые запускают отдельные вызовы обратного вызова characters().

Наконец, как только синтаксический анализатор наткнется на закрывающий тег, вы можете извлечь текущий элемент из стека и добавить его к дочерним элементам его родителя. Если остался только один элемент, то это будет корень вашего документа, который вам следует сохранить на будущее. Кроме этого, вы можете очистить текущий элемент, удалив ключи с пустыми значениями:

# svg_handler.py

# ...

class SVGHandler(ContentHandler):

    # ...

    def endElement(self, name):
        clean(self.current_element)
        if len(self.element_stack) > 1:
            child = self.element_stack.pop()
            self.current_element["children"].append(child)

def clean(element):
    element["value"] = element["value"].strip()
    for key in ("attributes", "children", "value"):
        if not element[key]:
            del element[key]

Обратите внимание, что clean() - это функция, определенная вне тела класса. Очистку необходимо выполнять в конце, поскольку заранее невозможно определить, сколько фрагментов текста может потребоваться объединить. Вы можете развернуть раздел "Сворачиваемый" ниже, чтобы получить полный код обработчика содержимого.

 

# svg_handler.py

from xml.sax.handler import ContentHandler

class SVGHandler(ContentHandler):

    def __init__(self):
        super().__init__()
        self.element_stack = []

    @property
    def current_element(self):
        return self.element_stack[-1]

    def startElement(self, name, attrs):
        self.element_stack.append({
            "name": name,
            "attributes": dict(attrs),
            "children": [],
            "value": ""
        })

    def endElement(self, name):
        clean(self.current_element)
        if len(self.element_stack) > 1:
            child = self.element_stack.pop()
            self.current_element["children"].append(child)

    def characters(self, content):
        self.current_element["value"] += content

def clean(element):
    element["value"] = element["value"].strip()
    for key in ("attributes", "children", "value"):
        if not element[key]:
            del element[key]

Теперь пришло время проверить все на практике, проанализировав XML, извлекая корневой элемент из вашего обработчика содержимого и преобразуя его в строку JSON:

>>> from xml.sax import parse
>>> from svg_handler import SVGHandler
>>> handler = SVGHandler()
>>> parse("smiley.svg", handler)
>>> root = handler.current_element

>>> import json
>>> print(json.dumps(root, indent=4))
{
    "name": "svg",
    "attributes": {
        "xmlns": "http://www.w3.org/2000/svg",
        "xmlns:inkscape": "http://www.inkscape.org/namespaces/inkscape",
        "viewBox": "-105 -100 210 270",
        "width": "210",
        "height": "270"
    },
    "children": [
        {
            "name": "inkscape:custom",
            "attributes": {
                "x": "42",
                "inkscape:z": "555"
            },
            "value": "Some value"
        },
⋮

Стоит отметить, что эта реализация не дает выигрыша в памяти по сравнению с DOM, поскольку, как и раньше, она создает абстрактное представление всего документа. Разница в том, что вместо стандартного дерева DOM вы создали представление пользовательского словаря. Однако вы могли бы представить, что при получении событий SAX вы можете записывать данные непосредственно в файл или базу данных, а не в память. Это значительно снизило бы ограничение памяти вашего компьютера.

Если вы хотите анализировать пространства имен XML, то вам нужно будет самостоятельно создать и настроить синтаксический анализатор SAX с помощью небольшого количества шаблонного кода, а также реализовать несколько иные обратные вызовы:

# svg_handler.py

from xml.sax.handler import ContentHandler

class SVGHandler(ContentHandler):

    def startPrefixMapping(self, prefix, uri):
        print(f"startPrefixMapping: {prefix=}, {uri=}")

    def endPrefixMapping(self, prefix):
        print(f"endPrefixMapping: {prefix=}")

    def startElementNS(self, name, qname, attrs):
        print(f"startElementNS: {name=}")

    def endElementNS(self, name, qname):
        print(f"endElementNS: {name=}")

Эти обратные вызовы получают дополнительные параметры о пространстве имен элемента. Чтобы синтаксический анализатор SAX действительно запускал эти обратные вызовы вместо некоторых предыдущих, вы должны явно включить поддержку пространства имен XML:

>>> from xml.sax import make_parser
>>> from xml.sax.handler import feature_namespaces
>>> from svg_handler import SVGHandler

>>> parser = make_parser()
>>> parser.setFeature(feature_namespaces, True)
>>> parser.setContentHandler(SVGHandler())

>>> parser.parse("smiley.svg")
startPrefixMapping: prefix=None, uri='http://www.w3.org/2000/svg'
startPrefixMapping: prefix='inkscape', uri='http://www.inkscape.org/namespaces/inkscape'
startElementNS: name=('http://www.w3.org/2000/svg', 'svg')
⋮
endElementNS: name=('http://www.w3.org/2000/svg', 'svg')
endPrefixMapping: prefix='inkscape'
endPrefixMapping: prefix=None

Установка этой функции превращает элемент name в кортеж, состоящий из доменного имени пространства имен и имени тега.

Пакет xml.sax предлагает достойный интерфейс синтаксического анализа XML на основе событий, созданный по образцу оригинального Java API. Он несколько ограничен по сравнению с DOM, но его должно хватить для реализации базового push-анализатора потоковой передачи XML, не прибегая к сторонним библиотекам. Учитывая это, в Python доступен менее подробный pull-анализатор, который вы изучите далее.

xml.dom.pulldom: Анализатор потокового извлечения

Синтаксические анализаторы в стандартной библиотеке Python часто работают совместно. Например, модуль xml.dom.pulldom использует синтаксический анализатор из xml.sax, чтобы использовать преимущества буферизации и считывать документ по частям. В то же время, он использует реализацию DOM по умолчанию из xml.dom.minidom для представления элементов документа. Однако эти элементы обрабатываются по одному за раз без какой-либо связи, пока вы не запросите это явно.

Примечание: Поддержка пространства имен XML включена по умолчанию в xml.dom.pulldom.

В то время как модель SAX следует шаблону observer, вы можете рассматривать StAX как шаблон проектирования итератора, который позволяет выполнять цикл по плоский поток событий. Еще раз, вы можете вызвать знакомые parse() или parseString() функции, импортированные из модуля для разбора SVG-изображения:

>>> from xml.dom.pulldom import parse
>>> event_stream = parse("smiley.svg")
>>> for event, node in event_stream:
...     print(event, node)
...
START_DOCUMENT <xml.dom.minidom.Document object at 0x7f74f9283e80>
START_ELEMENT <DOM Element: svg at 0x7f74fde18040>
CHARACTERS <DOM Text node "'\n'">
⋮
END_ELEMENT <DOM Element: script at 0x7f74f92b3c10>
CHARACTERS <DOM Text node "'\n'">
END_ELEMENT <DOM Element: svg at 0x7f74fde18040>

Для синтаксического анализа документа требуется всего несколько строк кода. Наиболее заметным отличием между xml.sax и xml.dom.pulldom является отсутствие обратных вызовов, поскольку вы управляете всем процессом. У вас гораздо больше свободы в структурировании вашего кода, и вам не нужно использовать классы, если вы этого не хотите.

Обратите внимание, что XML-узлы, извлеченные из потока, имеют типы, определенные в xml.dom.minidom. Но если бы вы проверили их родителей, братьев и сестер, а также дочерних элементов, то обнаружили бы, что они ничего не знают друг о друге:

>>> from xml.dom.pulldom import parse, START_ELEMENT
>>> event_stream = parse("smiley.svg")
>>> for event, node in event_stream:
...     if event == START_ELEMENT:
...         print(node.parentNode, node.previousSibling, node.childNodes)
<xml.dom.minidom.Document object at 0x7f90864f6e80> None []
None None []
None None []
None None []
⋮

Соответствующие атрибуты пусты. В любом случае, синтаксический анализатор pull может помочь в гибридном подходе, позволяющем быстро найти какой-либо родительский элемент и построить дерево DOM только для укорененной в нем ветви:

from xml.dom.pulldom import parse, START_ELEMENT

def process_group(parent):
    left_eye, right_eye = parent.getElementsByTagName("ellipse")
    # ...

event_stream = parse("smiley.svg")
for event, node in event_stream:
    if event == START_ELEMENT:
        if node.tagName == "g":
            event_stream.expandNode(node)
            process_group(node)

Вызывая .expandNode() в потоке событий, вы, по сути, перемещаете итератор вперед и рекурсивно анализируете узлы XML, пока не найдете соответствующий закрывающий тег родительского элемента. Результирующий узел будет иметь дочерние элементы с должным образом инициализированными атрибутами. Более того, вы сможете использовать для них методы DOM.

Синтаксический анализатор pull предлагает интересную альтернативу DOM и SAX, сочетая в себе лучшее из обоих миров. Он эффективен, гибок и прост в использовании, что позволяет создавать более компактный и читаемый код. Вы также могли бы использовать его для упрощения одновременной обработки нескольких XML-файлов. Тем не менее, ни один из упомянутых до сих пор синтаксических анализаторов XML не может сравниться по элегантности, простоте и полноте с последним из них, появившимся в стандартной библиотеке Python.

xml.etree.ElementTree: Облегченная альтернатива на языке Python

Синтаксические анализаторы XML, с которыми вы уже познакомились, справляются с этой задачей. Однако они не очень хорошо соответствуют философии Python, и это не случайно. Хотя DOM соответствует спецификации W3C, а SAX был создан по образцу Java API, ни то, ни другое не кажется особенно похожим на Python.

Что еще хуже, парсеры DOM и SAX кажутся устаревшими, поскольку часть их кода в интерпретаторе CPython не менялась более двух десятилетий! На момент написания этой статьи их реализация все еще не завершена и содержит пропущенных типизированных заглушки, что нарушает завершение кода в редакторах кода.

Между тем, Python 2.5 привнес новый взгляд на синтаксический анализ и написание XML—документов - ElementTree API. Это легкий, эффективный, элегантный и многофункциональный интерфейс, на который опираются даже некоторые сторонние библиотеки. Чтобы начать работу с ним, вам необходимо импортировать модуль xml.etree.ElementTree, который немного громоздок. Поэтому принято определять псевдоним следующим образом:

import xml.etree.ElementTree as ET

В немного более старом коде вы, возможно, видели модуль cElementTree, импортированный вместо него. Это была реализация в несколько раз быстрее, чем тот же интерфейс, написанный на C. Сегодня обычный модуль использует быструю реализацию, когда это возможно, так что вам больше не нужно беспокоиться.

Вы можете использовать ElementTree API, используя различные стратегии синтаксического анализа:

  Non-incremental Incremental (Blocking) Incremental (Non-blocking)
ET.parse() ✔️    
ET.fromstring() ✔️    
ET.iterparse()   ✔️  
ET.XMLPullParser     ✔️

Неинкрементная стратегия загружает весь документ в память способом, похожим на DOM. В модуле есть две функции с соответствующими названиями, которые позволяют анализировать файл или строку Python с XML-содержимым:

>>> import xml.etree.ElementTree as ET

>>> # Parse XML from a filename
>>> ET.parse("smiley.svg")
<xml.etree.ElementTree.ElementTree object at 0x7fa4c980a6a0>

>>> # Parse XML from a file object
>>> with open("smiley.svg") as file:
...     ET.parse(file)
...
<xml.etree.ElementTree.ElementTree object at 0x7fa4c96df340>

>>> # Parse XML from a Python string
>>> ET.fromstring("""\
... <svg viewBox="-105 -100 210 270">
...   <!-- More content goes here... -->
... </svg>
... """)
<Element 'svg' at 0x7fa4c987a1d0>

Синтаксический анализ файлового объекта или имени файла с помощью parse() возвращает экземпляр класса ET.ElementTree, который представляет всю иерархию элементов. С другой стороны, синтаксический анализ строки с помощью fromstring() вернет конкретный корень ET.Element.

В качестве альтернативы, вы можете читать XML-документ поэтапно с помощью потокового синтаксического анализатора , который выдает последовательность событий и элементов:

>>> for event, element in ET.iterparse("smiley.svg"):
...     print(event, element.tag)
...
end {http://www.inkscape.org/namespaces/inkscape}custom
end {http://www.w3.org/2000/svg}stop
end {http://www.w3.org/2000/svg}stop
end {http://www.w3.org/2000/svg}stop
end {http://www.w3.org/2000/svg}linearGradient
⋮

По умолчанию iterparse() выдает только события end, связанные с закрывающим тегом XML. Однако вы можете подписаться и на другие события. Вы можете найти их с помощью строковых констант, таких как "comment":

>>> import xml.etree.ElementTree as ET
>>> for event, element in ET.iterparse("smiley.svg", ["comment"]):
...     print(element.text.strip())
...
Head
Eyes
Mouth

Вот список всех доступных типов событий:

  • start: Начало элемента
  • end: Конец элемента
  • comment: Элемент комментария
  • pi: Инструкция по обработке, как в XSL
  • start-ns: Начало пространства имен
  • end-ns: Конец пространства имен

Недостатком iterparse() является то, что он использует блокирующие вызовы для чтения следующего фрагмента данных, который может оказаться неподходящим для асинхронного кода выполняется в одном потоке выполнения. Чтобы облегчить это, вы можете заглянуть в XMLPullParser, , который немного более подробный:

import xml.etree.ElementTree as ET

async def receive_data(url):
    """Download chunks of bytes from the URL asynchronously."""
    yield b"<svg "
    yield b"viewBox=\"-105 -100 210 270\""
    yield b"></svg>"

async def parse(url, events=None):
    parser = ET.XMLPullParser(events)
    async for chunk in receive_data(url):
        parser.feed(chunk)
        for event, element in parser.read_events():
            yield event, element

В этом гипотетическом примере синтаксический анализатор загружает фрагменты XML, которые могут поступать с интервалом в несколько секунд. Как только содержимого будет достаточно, вы можете выполнить итерацию по последовательности событий и элементов, буферизованных синтаксическим анализатором. Эта неблокирующая стратегия поэтапного синтаксического анализа позволяет выполнять действительно параллельный синтаксический анализ нескольких XML-документов "на лету" во время их загрузки.

Элементы в дереве являются изменяемыми, повторяемыми и индексируемыми последовательностями . Их длина соответствует количеству их непосредственных дочерних элементов:

>>> import xml.etree.ElementTree as ET
>>> tree = ET.parse("smiley.svg")
>>> root = tree.getroot()

>>> # The length of an element equals the number of its children.
>>> len(root)
5

>>> # The square brackets let you access a child by an index.
>>> root[1]
<Element '{http://www.w3.org/2000/svg}defs' at 0x7fe05d2e8860>
>>> root[2]
<Element '{http://www.w3.org/2000/svg}g' at 0x7fa4c9848400>

>>> # Elements are mutable. For example, you can swap their children.
>>> root[2], root[1] = root[1], root[2]

>>> # You can iterate over an element's children.
>>> for child in root:
...     print(child.tag)
...
{http://www.inkscape.org/namespaces/inkscape}custom
{http://www.w3.org/2000/svg}g
{http://www.w3.org/2000/svg}defs
{http://www.w3.org/2000/svg}text
{http://www.w3.org/2000/svg}script

Имена тегов могут иметь префикс с необязательным пространством имен, заключенным в пару фигурных скобок ({}). Пространство имен XML по умолчанию также отображается там, когда оно определено. Обратите внимание, что при замене в выделенной строке элемент <g> оказался перед элементом <defs>. Это показывает изменяемый характер последовательности.

Вот еще несколько атрибутов и методов элементов, о которых стоит упомянуть:

>>> element = root[0]

>>> element.tag
'{http://www.inkscape.org/namespaces/inkscape}custom'

>>> element.text
'Some value'

>>> element.attrib
{'x': '42', '{http://www.inkscape.org/namespaces/inkscape}z': '555'}

>>> element.get("x")
'42'

Одно из преимуществ этого API заключается в том, что он использует собственные типы данных Python. Выше говорилось, что для атрибутов элемента используется словарь Python. В предыдущих модулях они были упакованы в менее удобные адаптеры. В отличие от DOM, ElementTree API не предоставляет методов или свойств для перемещения по дереву в любом направлении, но есть пара лучших альтернатив.

Как вы уже видели, экземпляры класса Element реализуют протокол последовательности , позволяющий выполнять итерацию по их прямым дочерним элементам с помощью цикла:

>>> for child in root:
...     print(child.tag)
...
{http://www.inkscape.org/namespaces/inkscape}custom
{http://www.w3.org/2000/svg}defs
{http://www.w3.org/2000/svg}g
{http://www.w3.org/2000/svg}text
{http://www.w3.org/2000/svg}script

Вы получаете последовательность непосредственных дочерних элементов корня. Однако, чтобы углубиться во вложенные потомки, вам придется вызвать метод .iter() для элемента-предка:

>>> for descendant in root.iter():
...     print(descendant.tag)
...
{http://www.w3.org/2000/svg}svg
{http://www.inkscape.org/namespaces/inkscape}custom
{http://www.w3.org/2000/svg}defs
{http://www.w3.org/2000/svg}linearGradient
{http://www.w3.org/2000/svg}stop
{http://www.w3.org/2000/svg}stop
{http://www.w3.org/2000/svg}stop
{http://www.w3.org/2000/svg}g
{http://www.w3.org/2000/svg}circle
{http://www.w3.org/2000/svg}ellipse
{http://www.w3.org/2000/svg}ellipse
{http://www.w3.org/2000/svg}path
{http://www.w3.org/2000/svg}text
{http://www.w3.org/2000/svg}script

Корневой элемент имеет только пять дочерних элементов, но в общей сложности тринадцать потомков. Также можно сузить список потомков, отфильтровав только определенные имена тегов, используя необязательный tag аргумент:

>>> tag_name = "{http://www.w3.org/2000/svg}ellipse"
>>> for descendant in root.iter(tag_name):
...     print(descendant)
...
<Element '{http://www.w3.org/2000/svg}ellipse' at 0x7f430baa03b0>
<Element '{http://www.w3.org/2000/svg}ellipse' at 0x7f430baa0450>

На этот раз у вас есть только два <ellipse> элемента. Не забудьте включить пространство имен XML, например {http://www.w3.org/2000/svg}, в название вашего тега — при условии, что оно было определено. В противном случае, если вы укажете только имя тега без правильного пространства имен, у вас может получиться меньше или больше элементов-потомков, чем предполагалось изначально.

Работа с пространствами имен более удобна при использовании .iterfind(), который допускает необязательное сопоставление префиксов с доменными именами. Чтобы указать пространство имен по умолчанию, вы можете оставить ключ пустым или назначить произвольный префикс, который должен быть использован в названии тега позже:

>>> namespaces = {
...     "": "http://www.w3.org/2000/svg",
...     "custom": "http://www.w3.org/2000/svg"
... }

>>> for descendant in root.iterfind("g", namespaces):
...     print(descendant)
...
<Element '{http://www.w3.org/2000/svg}g' at 0x7f430baa0270>

>>> for descendant in root.iterfind("custom:g", namespaces):
...     print(descendant)
...
<Element '{http://www.w3.org/2000/svg}g' at 0x7f430baa0270>

Сопоставление пространств имен позволяет ссылаться на один и тот же элемент с разными префиксами. Удивительно, но если вы попытаетесь найти эти вложенные <ellipse> элементы, как и раньше, то .iterfind() ничего не вернет, потому что ожидает выражение XPath, а не простое имя тега:

>>> for descendant in root.iterfind("ellipse", namespaces):
...     print(descendant)
...

>>> for descendant in root.iterfind("g/ellipse", namespaces):
...     print(descendant)
...
<Element '{http://www.w3.org/2000/svg}ellipse' at 0x7f430baa03b0>
<Element '{http://www.w3.org/2000/svg}ellipse' at 0x7f430baa0450>

По совпадению, строка "g" является допустимым путем относительно текущего элемента root, поэтому функция ранее возвращала непустой результат. Однако, чтобы найти многоточия, вложенные на один уровень глубже в иерархии XML, вам потребуется более подробное выражение пути.

ElementTree имеет ограниченную поддержку синтаксиса для мини-языка XPath, который вы можете использовать для запроса элементов в XML, аналогично CSS селекторы в HTML. Существуют и другие методы, которые принимают такое выражение:

>>> namespaces = {"": "http://www.w3.org/2000/svg"}

>>> root.iterfind("defs", namespaces)
<generator object prepare_child.<locals>.select at 0x7f430ba6d190>

>>> root.findall("defs", namespaces)
[<Element '{http://www.w3.org/2000/svg}defs' at 0x7f430ba09e00>]

>>> root.find("defs", namespaces)
<Element '{http://www.w3.org/2000/svg}defs' at 0x7f430ba09e00>

В то время как .iterfind() выдает совпадающие элементы лениво, .findall() возвращает список, а .find() возвращает только первый совпадающий элемент. Аналогично, вы можете извлечь текст, заключенный между открывающим и закрывающим тегами элементов, используя .findtext(), или получить внутренний текст всего документа с помощью .itertext():

>>> namespaces = {"i": "http://www.inkscape.org/namespaces/inkscape"}

>>> root.findtext("i:custom", namespaces=namespaces)
'Some value'

>>> for text in root.itertext():
...     if text.strip() != "":
...         print(text.strip())
...
Some value
Hello <svg>!
console.log("CDATA disables XML parsing: <svg>")
⋮

Сначала выполняется поиск текста, встроенного в определенный элемент XML, а затем по всему документу. Поиск по тексту - мощная функция ElementTree API. Его можно воспроизвести с помощью других встроенных анализаторов, но за счет увеличения сложности кода и уменьшения удобства.

API ElementTree, вероятно, самый интуитивно понятный из всех. Он основан на Python, эффективен, надежен и универсален. Если у вас нет особых причин использовать DOM или SAX, это должно быть вашим выбором по умолчанию.

Ознакомьтесь со сторонними библиотеками синтаксического анализа XML

Иногда обращение к синтаксическим анализаторам XML в стандартной библиотеке может вызвать ощущение, что вы беретесь за кувалду, чтобы расколоть орех. В других случаях все наоборот, и вам нужен синтаксический анализатор, который мог бы делать гораздо больше. Например, вы можете захотеть проверить соответствие XML схеме или использовать расширенные выражения XPath. В таких ситуациях лучше всего обратиться к внешним библиотекам, доступным на PyPI.

Ниже вы найдете подборку внешних библиотек различной степени сложности.

untangle: Преобразование XML в объект Python

Если вы ищете однострочный текст, который мог бы превратить ваш XML-документ в объект Python, то не ищите его дальше. Хотя она не обновлялась в течение нескольких лет, библиотека untangle вскоре может стать вашим любимым способом синтаксического анализа XML на Python. Нужно запомнить только одну функцию, и она принимает URL-адрес, имя файла, файловый объект или XML-строку:

>>> import untangle

>>> # Parse XML from a URL
>>> untangle.parse("http://localhost:8000/smiley.svg")
Element(name = None, attributes = None, cdata = )

>>> # Parse XML from a filename
>>> untangle.parse("smiley.svg")
Element(name = None, attributes = None, cdata = )

>>> # Parse XML from a file object
>>> with open("smiley.svg") as file:
...     untangle.parse(file)
...
Element(name = None, attributes = None, cdata = )

>>> # Parse XML from a Python string
>>> untangle.parse("""\
... <svg viewBox="-105 -100 210 270">
...   <!-- More content goes here... -->
... </svg>
... """)
Element(name = None, attributes = None, cdata = )

В каждом случае он возвращает экземпляр класса Element. Вы можете использовать оператор точки для доступа к его дочерним узлам и синтаксис квадратные скобки для получения атрибутов XML или одного из дочерних узлов по индексу. Например, чтобы получить корневой элемент документа, вы можете получить к нему доступ, как к свойству объекта. Чтобы получить один из XML-атрибутов элемента, вы можете передать его имя в качестве ключа словаря:

>>> import untangle
>>> document = untangle.parse("smiley.svg")

>>> document.svg
Element(name = svg, attributes = {'xmlns': ...}, ...)

>>> document.svg["viewBox"]
'-105 -100 210 270'

Здесь нет имен функций или методов, которые нужно запоминать. Вместо этого каждый анализируемый объект уникален, поэтому вам действительно нужно знать структуру базового XML-документа, чтобы использовать его с помощью untangle.

Чтобы узнать имя корневого элемента, вызовите dir() в документе:

>>> dir(document)
['svg']

Здесь отображаются имена непосредственных дочерних элементов элемента. Обратите внимание, что untangle переопределяет значение dir() для его проанализированных документов. Обычно вы вызываете эту встроенную функцию для проверки класса или модуля Python. Реализация по умолчанию возвращает список имен атрибутов, а не дочерние элементы XML-документа.

Если существует более одного дочернего элемента с заданным именем тега, то вы можете выполнить итерацию по ним с помощью цикла или обратиться к одному из них по индексу:

>>> dir(document.svg)
['defs', 'g', 'inkscape_custom', 'script', 'text']

>>> dir(document.svg.defs.linearGradient)
['stop', 'stop', 'stop']

>>> for stop in document.svg.defs.linearGradient.stop:
...     print(stop)
...
Element <stop> with attributes {'offset': ...}, ...
Element <stop> with attributes {'offset': ...}, ...
Element <stop> with attributes {'offset': ...}, ...

>>> document.svg.defs.linearGradient.stop[1]
Element(name = stop, attributes = {'offset': ...}, ...)

Возможно, вы заметили, что элемент <inkscape:custom> был переименован в inkscape_custom. К сожалению, библиотека не может обрабатывать пространства имен XML, так что, если вам нужно на что-то положиться, вам следует поискать в другом месте.

Из-за точечной записи имена элементов в XML-документах должны быть допустимыми Идентификаторами Python. Если это не так, то untangle автоматически перепишет их имена, заменив запрещенные символы символом подчеркивания:

>>> dir(untangle.parse("<com:company.web-app></com:company.web-app>"))
['com_company_web_app']

Имена дочерних тегов - не единственные свойства объектов, к которым вы можете получить доступ. Элементы имеют несколько предопределенных атрибутов объектов, которые можно отобразить, вызвав vars():

>>> element = document.svg.text

>>> list(vars(element).keys())
['_name', '_attributes', 'children', 'is_root', 'cdata']

>>> element._name
'text'

>>> element._attributes
{'x': '-40', 'y': '75'}

>>> element.children
[]

>>> element.is_root
False

>>> element.cdata
'Hello <svg>!'

За кулисами untangle используется встроенный синтаксический анализатор SAX, но поскольку библиотека реализована на чистом Python и создает множество тяжелых объектов, она имеет значительно низкую производительность. Хотя он предназначен для чтения небольших документов, вы все равно можете комбинировать его с другим подходом для чтения XML-файлов объемом в несколько гигабайт.

Вот как это делается. Если вы перейдете к Архивам Википедии, вы сможете загрузить один из их сжатых XML-файлов. Та, что вверху, должна содержать краткое изложение тезисов статей:

<feed>
  <doc>
    <title>Wikipedia: Anarchism</title>
    <url>https://en.wikipedia.org/wiki/Anarchism</url>
    <abstract>Anarchism is a political philosophy...</abstract>
    <links>
      <sublink linktype="nav">
        <anchor>Etymology, terminology and definition</anchor>
        <link>https://en.wikipedia.org/wiki/Anarchism#Etymology...</link>
      </sublink>
      <sublink linktype="nav">
        <anchor>History</anchor>
        <link>https://en.wikipedia.org/wiki/Anarchism#History</link>
      </sublink>
      ⋮
    </links>
  </doc>
  ⋮
</feed>

После загрузки его размер превышает 6 ГБ, что идеально подходит для данного упражнения. Идея состоит в том, чтобы просканировать файл, чтобы найти последовательные открывающие и закрывающие теги <doc>, а затем проанализировать XML-фрагмент между ними, используя untangle для удобства.

Встроенный модуль mmap позволяет создавать виртуальное представление содержимого файла, даже если оно не помещается в доступной памяти. Это создает впечатление работы с огромной строкой байтов, которая поддерживает поиск и обычный синтаксис нарезки. Если вам интересно, как инкапсулировать эту логику в класс Python и воспользоваться преимуществами генератора для отложенной оценки, тогда разверните раздел collapsible ниже.

 

Вот полный код класса XMLTagStream:

import mmap
import untangle

class XMLTagStream:
    def __init__(self, path, tag_name, encoding="utf-8"):
        self.file = open(path)
        self.stream = mmap.mmap(
            self.file.fileno(), 0, access=mmap.ACCESS_READ
        )
        self.tag_name = tag_name
        self.encoding = encoding
        self.start_tag = f"<{tag_name}>".encode(encoding)
        self.end_tag = f"</{tag_name}>".encode(encoding)

    def __enter__(self):
        return self

    def __exit__(self, *args, **kwargs):
        self.stream.close()
        self.file.close()

    def __iter__(self):
        end = 0
        while (begin := self.stream.find(self.start_tag, end)) != -1:
            end = self.stream.find(self.end_tag, begin)
            yield self.parse(self.stream[begin: end + len(self.end_tag)])

    def parse(self, chunk):
        document = untangle.parse(chunk.decode(self.encoding))
        return getattr(document, self.tag_name)

Это пользовательский контекстный менеджер, который использует протокол итератора, определенный как встроенная функция генератора. Результирующий объект generator повторяет цикл по XML-документу, как если бы это был длинный поток символов.

Обратите внимание, что цикл while использует преимущества довольно нового синтаксиса Python, оператора walrus (:=), для упрощения кода. Вы можете использовать этот оператор в выражениях присваивания, где выражение может быть вычислено и присвоено переменной.

Не вдаваясь в подробности, вот как вы можете использовать этот пользовательский класс для быстрого просмотра большого XML-файла и более тщательного изучения конкретных элементов с помощью untangle:

>>> with XMLTagStream("abstract.xml", "doc") as stream:
...     for doc in stream:
...         print(doc.title.cdata.center(50, "="))
...         for sublink in doc.links.sublink:
...             print("-", sublink.anchor.cdata)
...         if "q" == input("Press [q] to exit or any key to continue..."):
...             break
...
===============Wikipedia: Anarchism===============
- Etymology, terminology and definition
- History
- Pre-modern era
⋮
Press [q] to exit or any key to continue...
================Wikipedia: Autism=================
- Characteristics
- Social development
- Communication
⋮
Press [q] to exit or any key to continue...

Сначала вы открываете файл для чтения и указываете имя тега, который хотите найти. Затем вы перебираете эти элементы и получаете обработанный фрагмент XML-документа. Это почти как смотреть в крошечное окошко, перемещаясь по бесконечно длинному листу бумаги. Это относительно простой пример, в котором не учитываются некоторые детали, но он должен дать вам общее представление о том, как использовать такую гибридную стратегию синтаксического анализа.

xmltodict: Преобразование XML в словарь Python

Если вам нравится JSON, но вы не фанат XML, ознакомьтесь с xmltodict, , который пытается преодолеть разрыв между обоими форматами данных. Как следует из названия, библиотека может анализировать XML-документ и представлять его в виде словаря Python, который также является целевым типом данных для документов JSON в Python. Это делает возможным преобразование между XML и JSON.

Примечание: Словари состоят из пар ключ-значение, в то время как XML-документы по своей сути иерархичны, что может привести к некоторой потере информации при преобразовании. Кроме того, XML содержит атрибуты, комментарии, инструкции по обработке и другие способы определения метаданных, которые недоступны в словарях.

В отличие от остальных синтаксических анализаторов XML, этот ожидает либо строку Python, либо файлообразный объект, открытый для чтения в двоичном режиме:

>>> import xmltodict

>>> xmltodict.parse("""\
... <svg viewBox="-105 -100 210 270">
...   <!-- More content goes here... -->
... </svg>
... """)
OrderedDict([('svg', OrderedDict([('@viewBox', '-105 -100 210 270')]))])

>>> with open("smiley.svg", "rb") as file:
...     xmltodict.parse(file)
...
OrderedDict([('svg', ...)])

По умолчанию библиотека возвращает экземпляр коллекции OrderedDict для сохранения порядка элементов . Однако, начиная с Python 3.6, обычные словари также сохраняют порядок вставки. Если вы хотите вместо этого работать с обычными словарями, то передайте dict в качестве аргумента dict_constructor функции parse():

>>> import xmltodict

>>> with open("smiley.svg", "rb") as file:
...     xmltodict.parse(file, dict_constructor=dict)
...
{'svg': ...}

Теперь parse() возвращает обычный старый словарь со знакомым текстовым представлением.

Чтобы избежать конфликтов имен между элементами XML и их атрибутами, библиотека автоматически присваивает последним префикс @. Вы также можете полностью игнорировать атрибуты, установив соответствующий флаг xml_attribs:

>>> import xmltodict

>>> # Rename attributes by default
>>> with open("smiley.svg", "rb") as file:
...     document = xmltodict.parse(file)
...     print([x for x in document["svg"] if x.startswith("@")])
...
['@xmlns', '@xmlns:inkscape', '@viewBox', '@width', '@height']

>>> # Ignore attributes when requested
>>> with open("smiley.svg", "rb") as file:
...     document = xmltodict.parse(file, xml_attribs=False)
...     print([x for x in document["svg"] if x.startswith("@")])
...
[]

Еще одним элементом информации, который по умолчанию игнорируется, является объявление пространства имен XML. Они обрабатываются как обычные атрибуты, в то время как соответствующие префиксы становятся частью имени тега. Однако вы можете расширить, переименовать или пропустить некоторые пространства имен, если хотите:

>>> import xmltodict

>>> # Ignore namespaces by default
>>> with open("smiley.svg", "rb") as file:
...     document = xmltodict.parse(file)
...     print(document.keys())
...
odict_keys(['svg'])

>>> # Process namespaces when requested
>>> with open("smiley.svg", "rb") as file:
...     document = xmltodict.parse(file, process_namespaces=True)
...     print(document.keys())
...
odict_keys(['http://www.w3.org/2000/svg:svg'])

>>> # Rename and skip some namespaces
>>> namespaces = {
...     "http://www.w3.org/2000/svg": "svg",
...     "http://www.inkscape.org/namespaces/inkscape": None,
... }
>>> with open("smiley.svg", "rb") as file:
...     document = xmltodict.parse(
...         file, process_namespaces=True, namespaces=namespaces
...     )
...     print(document.keys())
...     print("custom" in document["svg:svg"])
...     print("inkscape:custom" in document["svg:svg"])
...
odict_keys(['svg:svg'])
True
False

В первом примере, приведенном выше, имена тегов не содержат префикса пространства имен XML. Во втором примере они содержат префикс, потому что вы запросили их обработку. Наконец, в третьем примере вы свернули пространство имен по умолчанию до svg, одновременно отключив пространство имен Inkscape с помощью None.

Строковое представление словаря Python по умолчанию может быть недостаточно разборчивым. Чтобы улучшить его представление, вы можете красиво напечатать или преобразовать его в другой формат, такой как JSON или YAML:

>>> import xmltodict
>>> with open("smiley.svg", "rb") as file:
...     document = xmltodict.parse(file, dict_constructor=dict)
...

>>> from pprint import pprint as pp
>>> pp(document)
{'svg': {'@height': '270',
         '@viewBox': '-105 -100 210 270',
         '@width': '210',
         '@xmlns': 'http://www.w3.org/2000/svg',
         '@xmlns:inkscape': 'http://www.inkscape.org/namespaces/inkscape',
         'defs': {'linearGradient': {'@id': 'skin',
         ⋮

>>> import json
>>> print(json.dumps(document, indent=4, sort_keys=True))
{
    "svg": {
        "@height": "270",
        "@viewBox": "-105 -100 210 270",
        "@width": "210",
        "@xmlns": "http://www.w3.org/2000/svg",
        "@xmlns:inkscape": "http://www.inkscape.org/namespaces/inkscape",
        "defs": {
            "linearGradient": {
             ⋮

>>> import yaml  # Install first with 'pip install PyYAML'
>>> print(yaml.dump(document))
svg:
  '@height': '270'
  '@viewBox': -105 -100 210 270
  '@width': '210'
  '@xmlns': http://www.w3.org/2000/svg
  '@xmlns:inkscape': http://www.inkscape.org/namespaces/inkscape
  defs:
    linearGradient:
    ⋮

Библиотека xmltodict позволяет преобразовать документ наоборот, то есть из словаря Python обратно в строку XML:

>>> import xmltodict

>>> with open("smiley.svg", "rb") as file:
...     document = xmltodict.parse(file, dict_constructor=dict)
...

>>> xmltodict.unparse(document)
'<?xml version="1.0" encoding="utf-8"?>\n<svg...'

Словарь может пригодиться в качестве промежуточного формата при преобразовании данных из JSON или YAML в XML, если возникнет такая необходимость.

В библиотеке xmltodict есть еще множество функций, таких как потоковая передача, так что не стесняйтесь изучать их самостоятельно. Однако эта библиотека тоже немного устарела. Кроме того, это следующая библиотека, которая должна быть у вас на виду, если вы действительно ищете расширенные возможности синтаксического анализа XML.

lxml: Используйте ElementTree на стероидах

Если вы хотите получить максимальную производительность, широчайший спектр функциональных возможностей и максимально знакомый интерфейс в одном пакете, установите lxml и забудьте об остальных библиотеках. Это Привязка к Python для библиотек C libxml2 и libxslt, которые поддерживают несколько стандартов, включая XPath, XML-схему и XSLT.

Библиотека совместима с Python ElementTree API, о котором вы узнали ранее из этого руководства. Это означает, что вы можете повторно использовать существующий код, заменив только один оператор импорта:

import lxml.etree as ET

Это значительно повысит производительность . Кроме того, библиотека lxml содержит обширный набор функций и предоставляет различные способы их использования. Например, это позволяет вам проверять ваши XML-документы на нескольких языках схем, одним из которых является определение XML-схемы:

>>> import lxml.etree as ET

>>> xml_schema = ET.XMLSchema(
...     ET.fromstring("""\
...         <xsd:schema xmlns:xsd="http://www.w3.org/2001/XMLSchema">
...             <xsd:element name="parent"/>
...             <xsd:complexType name="SomeType">
...                 <xsd:sequence>
...                     <xsd:element name="child" type="xsd:string"/>
...                 </xsd:sequence>
...             </xsd:complexType>
...         </xsd:schema>"""))

>>> valid = ET.fromstring("<parent><child></child></parent>")
>>> invalid = ET.fromstring("<child><parent></parent></child>")

>>> xml_schema.validate(valid)
True

>>> xml_schema.validate(invalid)
False

Ни один из синтаксических анализаторов XML в стандартной библиотеке Python не имеет возможности проверять документы. Между тем, lxml позволяет вам определять объект XMLSchema и запускать документы через него, оставаясь в значительной степени совместимым с ElementTree API.

Помимо ElementTree API, lxml поддерживает альтернативный интерфейс lxml.objectify, который вы рассмотрите позже в разделе привязка данных раздел.

BeautifulSoup: Работа с искаженным XML

Обычно вы не будете использовать последнюю библиотеку в этом сравнении для синтаксического анализа XML, так как чаще всего сталкиваетесь с веб-очисткой HTML-документов. Тем не менее, он также способен анализировать XML. BeautifulSoup поставляется с подключаемой архитектурой, которая позволяет вам выбрать базовый синтаксический анализатор. Описанный ранее вариант lxml на самом деле рекомендован официальной документацией и в настоящее время является единственным синтаксическим анализатором XML, поддерживаемым библиотекой.

В зависимости от типа документов, которые вы хотите проанализировать, желаемой эффективности и доступности функций, вы можете выбрать один из этих анализаторов:

Document Type Parser Name Python Library Speed
HTML "html.parser" - Moderate
HTML "html5lib" html5lib Slow
HTML "lxml" lxml Fast
XML "lxml-xml" or "xml" lxml Fast

Помимо скорости, между отдельными парсерами есть заметные различия. Например, некоторые из них более снисходительны к искаженным элементам, чем другие, в то время как другие лучше эмулируют веб-браузеры.

Интересный факт: Название библиотеки отсылает к тегу soup, который описывает синтаксически или структурно некорректный HTML-код.

Предполагая, что вы уже установили библиотеки lxml и beautifulsoup4 в свою активную виртуальную среду, вы можете сразу приступить к разбору XML-документов. Вам нужно только импортировать BeautifulSoup:

from bs4 import BeautifulSoup

# Parse XML from a file object
with open("smiley.svg") as file:
    soup = BeautifulSoup(file, features="lxml-xml")

# Parse XML from a Python string
soup = BeautifulSoup("""\
<svg viewBox="-105 -100 210 270">
  <!-- More content goes here... -->
</svg>
""", features="lxml-xml")

Если вы случайно указали другой синтаксический анализатор, скажем, lxml, то библиотека добавит отсутствующие HTML-теги, такие как <body>, в проанализированный документ за вас. Вероятно, в данном случае это не то, что вы хотели, поэтому будьте осторожны при указании имени синтаксического анализатора.

BeautifulSoup - это мощный инструмент для разбора XML-документов, поскольку он может обрабатывать недопустимый контент и имеет богатый API для извлечения информации. Посмотрите, как он справляется с неправильно вложенными тегами, запрещенными символами и неправильно размещенным текстом:

>>> from bs4 import BeautifulSoup

>>> soup = BeautifulSoup("""\
... <parent>
...     <child>Forbidden < character </parent>
...     </child>
... ignored
... """, features="lxml-xml")

>>> print(soup.prettify())
<?xml version="1.0" encoding="utf-8"?>
<parent>
 <child>
  Forbidden
 </child>
</parent>

Другой синтаксический анализатор вызвал бы исключение и сдался бы, как только обнаружил бы что-то неправильное в документе. Здесь он не только проигнорировал проблемы, но и нашел разумные способы их устранения. Теперь элементы правильно вложены и не содержат недопустимого содержимого.

Существует слишком много методов поиска элементов с помощью BeautifulSoup, чтобы описать их все здесь. Обычно вы вызываете вариант .find() или .findall() для элемента soup:

>>> from bs4 import BeautifulSoup

>>> with open("smiley.svg") as file:
...     soup = BeautifulSoup(file, features="lxml-xml")
...

>>> soup.find_all("ellipse", limit=1)
[<ellipse cx="-20" cy="-10" fill="black" rx="6" ry="8" stroke="none"/>]

>>> soup.find(x=42)
<inkscape:custom inkscape:z="555" x="42">Some value</inkscape:custom>

>>> soup.find("stop", {"stop-color": "gold"})
<stop offset="75%" stop-color="gold" stop-opacity="1.0"/>

>>> soup.find(text=lambda x: "value" in x).parent
<inkscape:custom inkscape:z="555" x="42">Some value</inkscape:custom>

Параметр limit аналогичен предложению LIMIT в MySQL, которое позволяет вам решить, сколько результатов вы хотите получить максимум. Он вернет указанное количество результатов или меньше. Это не совпадение. Вы можете рассматривать эти методы поиска как простой язык запросов с мощными фильтрами.

Интерфейс поиска очень гибкий, но он выходит за рамки данного руководства. Вы можете ознакомиться с документацией библиотеки для получения более подробной информации или прочитать еще одно руководство по веб-скрейпингу в Python, которое касается BeautifulSoup.

Привязка XML-данных к объектам Python

Допустим, вы хотите получать данные в режиме реального времени по веб-сокету с низкой задержкой, обмениваясь сообщениями в формате XML. Для целей этой презентации вы собираетесь использовать веб-браузер для трансляции событий с мыши и клавиатуры на сервер Python. Вы создадите пользовательский протокол и будете использовать привязку данных для преобразования XML в собственные объекты Python.

Идея привязки данных заключается в том, чтобы определить модель данных декларативно, позволяя программе выяснить, как извлечь ценную часть информации из XML во время выполнения. Если вы когда-либо работали с моделями Django, то эта концепция должна быть вам знакома.

Во-первых, начните с разработки вашей модели данных. Она будет состоять из двух типов событий:

  1. KeyboardEvent
  2. MouseEvent

Каждый из них может представлять несколько специализированных подтипов, таких как нажатие или отпускание клавиши для клавиатуры и щелчок правой кнопкой мыши для мыши. Вот пример XML-сообщения, генерируемого в ответ на удерживание комбинации клавиш Shift+2:

<KeyboardEvent>
    <Type>keydown</Type>
    <Timestamp>253459.17999999982</Timestamp>
    <Key>
        <Code>Digit2</Code>
        <Unicode>@</Unicode>
    </Key>
    <Modifiers>
        <Alt>false</Alt>
        <Ctrl>false</Ctrl>
        <Shift>true</Shift>
        <Meta>false</Meta>
    </Modifiers>
</KeyboardEvent>

Это сообщение содержит определенный тип события на клавиатуре, временную метку, код клавиши и ее Юникод, а также клавиши-модификаторы, такие как Alt, Ctrl или Shift. Мета-клавишей обычно является Win или Cmd, в зависимости от вашей раскладки клавиатуры.

Аналогично, событие мыши может выглядеть следующим образом:

<MouseEvent>
    <Type>mousemove</Type>
    <Timestamp>52489.07000000145</Timestamp>
    <Cursor>
        <Delta x="-4" y="8"/>
        <Window x="171" y="480"/>
        <Screen x="586" y="690"/>
    </Cursor>
    <Buttons bitField="0"/>
    <Modifiers>
        <Alt>false</Alt>
        <Ctrl>true</Ctrl>
        <Shift>false</Shift>
        <Meta>false</Meta>
    </Modifiers>
</MouseEvent>

Вместо клавиши, однако, указано положение курсора мыши и битовое поле , кодирующее кнопки мыши, нажатые во время события. Нулевое битовое поле указывает на то, что ни одна кнопка не была нажата.

Как только клиент установит соединение, он начнет заполнять сервер сообщениями. Протокол не будет состоять из каких-либо рукопожатий, сердцебиений, корректных завершений работы, подписок на темы или управляющих сообщений. Вы можете запрограммировать это на JavaScript, зарегистрировав обработчики событий и создав объект WebSocket менее чем за пятьдесят строк кода.

Однако реализация клиента не является целью данного упражнения. Поскольку вам не нужно в этом разбираться, просто разверните раздел "Сворачиваемый" ниже, чтобы показать HTML-код со встроенным JavaScript, и сохраните его в файле с любым именем, которое вам нравится.

 

<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8">
    <title>Real-Time Data Feed</title>
</head>
<body>
    <script>
        const ws = new WebSocket("ws://localhost:8000")
        ws.onopen = event => {
            ["keydown", "keyup"].forEach(name =>
                window.addEventListener(name, event =>
                    ws.send(`\
<KeyboardEvent>
    <Type>${event.type}</Type>
    <Timestamp>${event.timeStamp}</Timestamp>
    <Key>
        <Code>${event.code}</Code>
        <Unicode>${event.key}</Unicode>
    </Key>
    <Modifiers>
        <Alt>${event.altKey}</Alt>
        <Ctrl>${event.ctrlKey}</Ctrl>
        <Shift>${event.shiftKey}</Shift>
        <Meta>${event.metaKey}</Meta>
    </Modifiers>
</KeyboardEvent>`))
            );
            ["mousedown", "mouseup", "mousemove"].forEach(name =>
                window.addEventListener(name, event =>
                    ws.send(`\
<MouseEvent>
    <Type>${event.type}</Type>
    <Timestamp>${event.timeStamp}</Timestamp>
    <Cursor>
        <Delta x="${event.movementX}" y="${event.movementY}"/>
        <Window x="${event.clientX}" y="${event.clientY}"/>
        <Screen x="${event.screenX}" y="${event.screenY}"/>
    </Cursor>
    <Buttons bitField="${event.buttons}"/>
    <Modifiers>
        <Alt>${event.altKey}</Alt>
        <Ctrl>${event.ctrlKey}</Ctrl>
        <Shift>${event.shiftKey}</Shift>
        <Meta>${event.metaKey}</Meta>
    </Modifiers>
</MouseEvent>`))
            )
        }
    </script>
</body>
</html>

Клиент подключается к локальному серверу, который прослушивает данные через порт 8000. Как только вы сохраните HTML-код в файле, вы сможете открыть его в своем любимом веб-браузере. Но перед этим вам нужно будет внедрить сервер.

Python не поддерживает WebSocket, но вы можете установить библиотеку websockets в свою активную виртуальную среду. Вам также понадобится lxml позже, так что сейчас самое время установить обе зависимости за один раз:

$ python -m pip install websockets lxml

Наконец, вы можете создать минимальный асинхронный веб-сервер:

# server.py

import asyncio
import websockets

async def handle_connection(websocket, path):
    async for message in websocket:
        print(message)

if __name__ == "__main__":
    future = websockets.serve(handle_connection, "localhost", 8000)
    asyncio.get_event_loop().run_until_complete(future)
    asyncio.get_event_loop().run_forever()

Когда вы запустите сервер и откроете сохраненный HTML-файл в веб-браузере, вы должны увидеть, что XML-сообщения появляются в стандартном виде в ответ на ваши движения мыши и нажатия клавиш. Вы можете открыть клиент на нескольких вкладках или даже в нескольких браузерах одновременно!

Определение моделей с помощью выражений XPath

Сейчас ваши сообщения приходят в обычном строковом формате. Работать с сообщениями в этом формате не очень удобно. К счастью, вы можете превратить их в составные объекты Python с помощью одной строки кода, используя модуль lxml.objectify:

# server.py

import asyncio
import websockets
import lxml.objectify

async def handle_connection(websocket, path):
    async for message in websocket:
        try:
            xml = lxml.objectify.fromstring(message)
        except SyntaxError:
            print("Malformed XML message:", repr(message))
        else:
            if xml.tag == "KeyboardEvent":
                if xml.Type == "keyup":
                    print("Key:", xml.Key.Unicode)
            elif xml.tag == "MouseEvent":
                screen = xml.Cursor.Screen
                print("Mouse:", screen.get("x"), screen.get("y"))
            else:
                print("Unrecognized event type")

# ...

Если синтаксический анализ XML выполнен успешно, вы можете проверить обычные свойства корневого элемента, такие как имя тега, атрибуты, внутренний текст и т.д. Вы сможете использовать оператор dot для навигации по дереву элементов. В большинстве случаев библиотека распознает подходящий тип данных Python и преобразует значение для вас.

После сохранения этих изменений и перезапуска сервера вам потребуется перезагрузить страницу в вашем веб-браузере, чтобы установить новое подключение к WebSocket. Вот пример выходных данных измененной программы:

$ python server.py
Mouse: 820 121
Mouse: 820 122
Mouse: 820 123
Mouse: 820 124
Mouse: 820 125
Key: a
Mouse: 820 125
Mouse: 820 125
Key: a
Key: A
Key: Shift
Mouse: 821 125
Mouse: 821 125
Mouse: 820 123
⋮

Иногда XML может содержать имена тегов, которые не являются допустимыми идентификаторами Python, или вы можете захотеть адаптировать структуру сообщения в соответствии с вашей моделью данных. В таком случае интересным вариантом было бы определение пользовательских классов моделей с дескрипторами, которые объявляют, как искать информацию, используя выражения XPath. Это та часть, которая начинает напоминать модели Django или определения схем Pydantic.

Вы собираетесь использовать пользовательский дескриптор XPath и сопутствующий класс Model, которые предоставляют повторно используемые свойства для ваших моделей данных. Дескриптор ожидает, что для поиска элемента в полученном сообщении будет использовано выражение XPath. Базовая реализация немного усовершенствована, поэтому не стесняйтесь копировать код из раздела collapsible, приведенного ниже.

 

import lxml.objectify

class XPath:
    def __init__(self, expression, /, default=None, multiple=False):
        self.expression = expression
        self.default = default
        self.multiple = multiple

    def __set_name__(self, owner, name):
        self.attribute_name = name
        self.annotation = owner.__annotations__.get(name)

    def __get__(self, instance, owner):
        value = self.extract(instance.xml)
        instance.__dict__[self.attribute_name] = value
        return value

    def extract(self, xml):
        elements = xml.xpath(self.expression)
        if elements:
            if self.multiple:
                if self.annotation:
                    return [self.annotation(x) for x in elements]
                else:
                    return elements
            else:
                first = elements[0]
                if self.annotation:
                    return self.annotation(first)
                else:
                    return first
        else:
            return self.default

class Model:
    """Abstract base class for your models."""
    def __init__(self, data):
        if isinstance(data, str):
            self.xml = lxml.objectify.fromstring(data)
        elif isinstance(data, lxml.objectify.ObjectifiedElement):
            self.xml = data
        else:
            raise TypeError("Unsupported data type:", type(data))

Предполагая, что у вас уже есть желаемый дескриптор XPath и абстрактный базовый класс Model в вашем модуле, вы можете использовать их для определения типов сообщений KeyboardEvent и MouseEvent вместе с многоразовыми строительными блоками, чтобы избежать повторения. Существует бесконечное множество способов сделать это, но вот один из примеров:

# ...

class Event(Model):
    """Base class for event messages with common elements."""
    type_: str = XPath("./Type")
    timestamp: float = XPath("./Timestamp")

class Modifiers(Model):
    alt: bool = XPath("./Alt")
    ctrl: bool = XPath("./Ctrl")
    shift: bool = XPath("./Shift")
    meta: bool = XPath("./Meta")

class KeyboardEvent(Event):
    key: str = XPath("./Key/Code")
    modifiers: Modifiers = XPath("./Modifiers")

class MouseEvent(Event):
    x: int = XPath("./Cursor/Screen/@x")
    y: int = XPath("./Cursor/Screen/@y")
    modifiers: Modifiers = XPath("./Modifiers")

Дескриптор XPath допускает отложенную оценку, так что элементы XML-сообщений просматриваются только по запросу. Более конкретно, они просматриваются только при обращении к свойству объекта event. Кроме того, результаты кэшируются, чтобы избежать многократного выполнения одного и того же запроса XPath. Дескриптор также учитывает аннотации типов и автоматически преобразует десериализованные данные в нужный тип Python.

Использование этих объектов event не сильно отличается от тех, которые были автоматически сгенерированы lxml.objectify ранее:

if xml.tag == "KeyboardEvent":
    event = KeyboardEvent(xml)
    if event.type_ == "keyup":
        print("Key:", event.key)
elif xml.tag == "MouseEvent":
    event = MouseEvent(xml)
    print("Mouse:", event.x, event.y)
else:
    print("Unrecognized event type")

Существует дополнительный шаг создания новых объектов определенного типа событий. Но в остальном это дает вам больше гибкости в плане структурирования вашей модели независимо от протокола XML. Кроме того, можно выводить новые атрибуты модели на основе тех, что содержатся в полученных сообщениях, и добавлять к ним дополнительные методы.

Создание моделей на основе XML-схемы

Реализация классов моделей - трудоемкая и подверженная ошибкам задача. Однако, если ваша модель отражает XML-сообщения, вы можете воспользоваться автоматизированным инструментом для создания необходимого кода на основе XML-схемы. Недостатком такого кода является то, что он обычно менее удобочитаем, чем если бы был написан от руки.

Одним из старейших сторонних модулей, позволяющих это, был PyXB, который имитирует популярную библиотеку Java JAXB. К сожалению, последний раз он был выпущен несколько лет назад и был ориентирован на устаревшие версии Python. Вы можете ознакомиться с похожим, но активно поддерживаемым вариантом generateDS, который генерирует структуры данных из XML-схемы.

Допустим, у вас есть этот models.xsd файл схемы, описывающий ваше KeyboardEvent сообщение:

<xsd:schema xmlns:xsd="http://www.w3.org/2001/XMLSchema">
    <xsd:element name="KeyboardEvent" type="KeyboardEventType"/>
    <xsd:complexType name="KeyboardEventType">
        <xsd:sequence>
            <xsd:element type="xsd:string" name="Type"/>
            <xsd:element type="xsd:float" name="Timestamp"/>
            <xsd:element type="KeyType" name="Key"/>
            <xsd:element type="ModifiersType" name="Modifiers"/>
        </xsd:sequence>
    </xsd:complexType>
    <xsd:complexType name="KeyType">
        <xsd:sequence>
            <xsd:element type="xsd:string" name="Code"/>
            <xsd:element type="xsd:string" name="Unicode"/>
        </xsd:sequence>
    </xsd:complexType>
    <xsd:complexType name="ModifiersType">
        <xsd:sequence>
            <xsd:element type="xsd:string" name="Alt"/>
            <xsd:element type="xsd:string" name="Ctrl"/>
            <xsd:element type="xsd:string" name="Shift"/>
            <xsd:element type="xsd:string" name="Meta"/>
        </xsd:sequence>
    </xsd:complexType>
</xsd:schema>

Схема сообщает синтаксическому анализатору XML, какие элементы следует ожидать, их порядок и уровень в дереве. Она также ограничивает допустимые значения для атрибутов XML. Любые расхождения между этими объявлениями и реальным XML-документом должны сделать его недействительным и заставить анализатор отклонить документ.

Кроме того, некоторые инструменты могут использовать эту информацию для создания фрагмента кода, который скрывает от вас детали синтаксического анализа XML. После установки библиотеки вы сможете запустить команду generateDS в вашей активной виртуальной среде:

$ generateDS -o models.py models.xsd

При этом будет создан новый файл с именем models.py в том же каталоге, что и сгенерированный исходный код Python. Затем вы можете импортировать этот модуль и использовать его для анализа входящих сообщений:

>>> from models import parseString

>>> event = parseString("""\
... <KeyboardEvent>
...     <Type>keydown</Type>
...     <Timestamp>253459.17999999982</Timestamp>
...     <Key>
...         <Code>Digit2</Code>
...         <Unicode>@</Unicode>
...     </Key>
...     <Modifiers>
...         <Alt>false</Alt>
...         <Ctrl>false</Ctrl>
...         <Shift>true</Shift>
...         <Meta>false</Meta>
...     </Modifiers>
... </KeyboardEvent>""", silence=True)

>>> event.Type, event.Key.Code
('keydown', 'Digit2')

Это выглядит аналогично lxml.objectify примеру, показанному ранее. Разница в том, что использование привязки данных обеспечивает соответствие схеме, тогда как lxml.objectify создает объекты динамически, независимо от того, являются ли они семантически корректными.

Обезвредьте XML-бомбу с помощью безопасных синтаксических анализаторов

Синтаксические анализаторы XML в стандартной библиотеке Python уязвимы для множества угроз безопасности, которые в лучшем случае могут привести к отказу в обслуживании (DoS) или потере данных. Честно говоря, это не их вина. Они просто следуют спецификации стандарта XML, который является более сложным и мощным, чем известно большинству людей.

Примечание: Пожалуйста, имейте в виду, что вам следует разумно использовать информацию, которую вы сейчас увидите. Вы же не хотите в конечном итоге стать злоумышленником, подвергнуть себя юридическим последствиям или столкнуться с пожизненным запретом пользоваться той или иной услугой.

Одной из наиболее распространенных атак является XML-бомба, также известная как атака на миллиард смехов. Атака использует расширение сущности в DTD, чтобы увеличить объем памяти и занять центральный процессор как можно дольше. Все, что вам нужно, чтобы остановить получение нового трафика незащищенным веб-сервером, - это несколько строк XML-кода:

import xml.etree.ElementTree as ET
ET.fromstring("""\
<?xml version="1.0"?>
<!DOCTYPE lolz [
 <!ENTITY lol "lol">
 <!ELEMENT lolz (#PCDATA)>
 <!ENTITY lol1 "&lol;&lol;&lol;&lol;&lol;&lol;&lol;&lol;&lol;&lol;">
 <!ENTITY lol2 "&lol1;&lol1;&lol1;&lol1;&lol1;&lol1;&lol1;&lol1;&lol1;&lol1;">
 <!ENTITY lol3 "&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;">
 <!ENTITY lol4 "&lol3;&lol3;&lol3;&lol3;&lol3;&lol3;&lol3;&lol3;&lol3;&lol3;">
 <!ENTITY lol5 "&lol4;&lol4;&lol4;&lol4;&lol4;&lol4;&lol4;&lol4;&lol4;&lol4;">
 <!ENTITY lol6 "&lol5;&lol5;&lol5;&lol5;&lol5;&lol5;&lol5;&lol5;&lol5;&lol5;">
 <!ENTITY lol7 "&lol6;&lol6;&lol6;&lol6;&lol6;&lol6;&lol6;&lol6;&lol6;&lol6;">
 <!ENTITY lol8 "&lol7;&lol7;&lol7;&lol7;&lol7;&lol7;&lol7;&lol7;&lol7;&lol7;">
 <!ENTITY lol9 "&lol8;&lol8;&lol8;&lol8;&lol8;&lol8;&lol8;&lol8;&lol8;&lol8;">
]>
<lolz>&lol9;</lolz>""")

Наивный синтаксический анализатор попытается разрешить пользовательскую сущность &lol9;, помещенную в корень документа, проверив DTD. Однако сама эта сущность несколько раз ссылается на другую сущность, которая ссылается на еще одну сущность и так далее. Когда вы запустите сценарий, описанный выше, вы заметите, что что-то беспокоит вашу память и процессор: