Режем XML по разметке XQuery +2


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

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



Чтобы обратиться к web-сервису существует огромное количество способов. В Python есть requests (статьи на Хабре 1, 2), но я буду использовать средства Qt, отчасти по привычке, отчасти для уменьшения зависимостей, так как PyQt5 уже подключен, отчасти для уменьшения промежуточных преобразований данных. Соответственно, для преобразования полученного xml-ответа использую XPath и XQuery, так же заложенные в Qt.

Подключаться будем к сервису курса валют Банка России. Сервис по ссылке отвечает таким XML:

<?xml version="1.0" encoding="windows-1251" ?>
<ValCurs Date="16.09.2017" name="Foreign Currency Market">
  <Valute ID="R01010">
    <NumCode>036</NumCode>
    <CharCode>AUD</CharCode>
    <Nominal>1</Nominal>
    <Name>Австралийский доллар</Name>
    <Value>46,0614</Value>
  </Valute>
  ...
</ValCurs>

В параметрах передается дата формата ДД/ММ/ГГГГ, ее можно не указывать, тогда курсы будут на текущий день.

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

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

[Input]
; Показать на экране поле ввода даты
Date=Дата,02/03/2017

[WebPage]
; Ссылка на сервис с подставляемым параметром
Url="http://www.cbr.ru/scripts/XML_daily.asp?date_req={Date}"
; Шаблон, которым будет преобразовываться ответ
Transform=valcurs.xq

Подставлять параметры в url можно было по разному. Традиционный способ в Qt — это QString::arg() в Qt, но он недоступен в PyQt5 из-за отсутствия QString. В Python есть оператор % и метод str.format() (про него тоже имеется статья на Хабре). Поскольку параметры нам нужны именованные, то подстановка должна быть тоже именованной — такой способ дает str.format(). Отсюда параметр Date в фигурных скобках.

Полученный ответ можно отображать тоже миллионом разных способов. Можно распарсить XML в таблицу (а курсы валют по сути таблица) и показать ее в QTableWidget. Но тогда придется описать правила соответствия тэгов XML и столбцов таблицы – можно, наверное, но мне представляется такое описание довольно сложным.

Можно оставить иерархию тэгов и отобразить ее в QTreeWidget. Ага, но только тогда мы получим типа «понятное представление» как в просмотрщике событий Win.



Исключительно понятное.

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

Для показа веб-страниц в Qt есть модуль QWebEngineView – по сути, встроенный Chromium. Раньше был WebKit, проще и куда более интегрированный в Qt, но под влиянием моды на HTML5 разработчики отказались от его поддержки во фреймворке. Taк что живем с чем есть.

Соберем рабочее окно по аналогии с окном SQL, описанным в первой части, только вместо QTableView добавим QWebEngineView. Получится такое окно



Если у вас не запустилось...
… и вы в Windows, то причиной может быть невозможность инициализации OpenGL в модуле QtWebEngine, т.к. Chromium, в отличие от WebKit-а, шибка умный, однако, и требует OpenGL.

Под Windows есть три варианта работы:
— 'desktop' — прямые вызовы «нативного» OpenGL
— 'angle' — через опенсорсную прослойку ANGLE от Google, транслирующую вызовы OpenGL в вызовы DirectX.
— 'software' — через программную реализацию OpenGL

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

    os.environ.putenv('QT_OPENGL','software') # desktop, software, angle

Если переменная не определена, то PyQt5 (по крайней мере, та сборка, что у меня) пытается сам определить способ работы. И… не всегда это ему удается, и тогда приложение вылетает.

Наиболее надежно работает 'software', только нужно скачать соответствующую dll отсюда (я брал opengl32sw-32.7z), распаковать и положить рядом с библиотеками Qt (...\Lib\site-packages\PyQt5\Qt\bin\).

Для 'angle' нужны d3dcompiler_4x.dll, которые берутся здесь. Если всё же не взлетает, то можно отключить обращение к DirectX:

    os.environ.putenv('QT_ANGLE_PLATFORM','warp') # d3d11, d3d9 and warp

И даже в этом случае может выскочить сообщение
QtWebEngineProcess.exe - Системная ошибка
---------------------------
Запуск программы невозможен, так как на компьютере отсутствует VCRUNTIME140.dll. Попробуйте переустановить программу.


Этот файл входит в Visual C++ Redistributable for Visual Studio 2015, скачать можно отсюда.

Вот теперь всё должно работать.

Пока оно ничего не умеет делать, нужно учить. В общем, создание и выполнение запроса делается в PyQt довольно просто

           # Создадим один раз менеджер
           self.man = QNetworkAccessManager(self)

    # Кнопка Run
    def run(self):
        try:
            # Считаем введенные параметры
            values = { kp[0]:self.inputs[kp[0]].text() for kp in self.params}
            url = self.url.format(**values)
            req = QNetworkRequest(QUrl(url))
            # Вызовем GET
            reply = self.man.get(req)
            reply.finished.connect(self.replyFinished)

В последней строке к сигналу finished подключается функция-обработчик ответа, в терминах Qt «слот», в котором исходный объект QNetworkReply получим из QObject.sender().

    def replyFinished(self):
        reply = self.sender()

К моменту вызова этой функции из reply можно вычитывать данные, как из файла.

Над выбором шаблонизатора тоже долго думать не пришлось – хоть их и много (wiki, обзор), и на JS, и на Python, естественно использовать уже установленный – в Qt есть QXmlQuery, поддерживающий XQuery и таблицы преобразования XSLT, правда с ограничениями.

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

xquery version "1.0" encoding "utf-8";
<html>
  <head>
  <meta http-equiv="Content-Type" content="text/html; charset=utf-8"/>
  </head>
  <body>
    <div>Курсы ЦБ на {string(/ValCurs/@Date)}</div>
    <table><tbody>
      <tr>
        <th>Код валюты</th>
        <th>Код валюты</th>
        <th>Номинал</th>
        <th>Название</th>
        <th>Курс</th>
      </tr>
    {for $i in /ValCurs/Valute
    return (
      <tr>
        <td>{string($i/NumCode)}</td>
        <td>{string($i/CharCode)}</td>
        <td>{string($i/Nominal)}</td>
        <td>{string($i/Name)}</td>
        <td>{string($i/Value)}</td>
      </tr>
    )
    }
    </tbody></table>
  </body>
</html>

Теперь осталось прогнать ответ через шаблон и результат вставить в веб-страницу. Удобно, что можно передать QXmlQuery основной документ не буфером, а источником на основе QIODevice. Наш ответ для этого вполне годится.

            # Создаем объект
            lang = QXmlQuery.XQuery10
            query = QXmlQuery(lang)
            query.setMessageHandler(XmlQueryMessageHandler())
            query.setFocus(reply)

            # Начитываем шаблон из файла
            templ = QFile(self.transformTemplate)
            templ.open(QIODevice.ReadOnly)
            query.setQuery(templ)

            # Результат преобразуем в строку и вставим в веб-страницу
            out = query.evaluateToString()
            self.page.setHtml(out)

Соберем все вместе и запустим. Работает ли? Увы, чуда опять не случилось, работает, конечно.



Вместо XQuery можно использовать XSL-таблицы. Всего-то потребуется поменять одну строчку

                lang = QXmlQuery.XSLT20

xmlview.xslt
<?xml version="1.0" encoding="UTF-8"?>
<xsl:stylesheet version="2.0"
xmlns:xsl="http://www.w3.org/1999/XSL/Transform">
<xsl:strip-space elements="*" />
<xsl:output method="html" version="2.0" encoding="UTF-8" indent="no"/>

    <xsl:template match="/">
      <html>
      <head>
        <style type="text/css">
           .node { font-weight: normal }
           .nodeName { font-weight: normal }
           .nodeLine { font-weight: bold }
        </style>
      </head>
      <body>
      <h2>Transformed XML</h2>
      <xsl:apply-templates select="/*"/>
      </body>
      </html>
    </xsl:template>

    <xsl:template match="node()">
        <xsl:param name="level" select="0"/>
        <div>
            <xsl:attribute name="style">
                 <xsl:value-of select="concat('margin-left: ',$level,'em')"/>
            </xsl:attribute>
            <span class="nodeLine"><<span class="nodeName"><xsl:value-of select="name(.)"/></span>
                <xsl:for-each select="@*"><xsl:value-of select="concat(' ',name(.))"/>="<xsl:value-of select="."/>"</xsl:for-each>><xsl:value-of select="text()"/><xsl:apply-templates select="*"><xsl:with-param name="level" select="2"/></xsl:apply-templates><span class="node"></<span class="nodeName"><xsl:value-of select="name(.)"/></span>></span></span>
        </div> 
    </xsl:template>
</xsl:stylesheet>


Получается так



А что, если отдать полученный XML без шаблона напрямую в страницу?

        b = reply.readAll()
        self.page.setContent(b, reply.header(QNetworkRequest.ContentTypeHeader), reply.url())

Chromium сумеет показать этот XML.



Кодировка нарушилась, потому что сервис ЦБ не прописал кодовую страницу в заголовке ответа HTTP, а разбирать заголовок XML Chromium не стал. Добавим принудительное добавление кодовой страницы по умолчанию.

        b = reply.readAll()
        cth = reply.header(QNetworkRequest.ContentTypeHeader)
        if len(cth.split(';')) == 1:
            cth = cth + ";charset=windows-1251"
        self.page.setContent(b,cth,reply.url())



Теперь все хорошо.

Осталось только добавить новый модуль в общий набор инструментов. Исходный код лежит на github.




К сожалению, не доступен сервер mySQL