Лепим тулбар на PyQt, экспортируем данные в Excel и HTML

    В предыдущей части я рассказывал о создании модуля для запуска SQL-запросов и оболочки, в которой эти модули запускаются. После недолгой работы с запросами возникает очевидный вопрос — а как воспользоваться результатом выборки, кроме как посмотреть на экране?

    Для этого стоит сделать дополнительные инструменты экспорта и копирования данных. Экспортировать будем в файл в формате Excel, а копировать в системный буфер в формате HTML.

    Но для начала прилепим к нашему главному окну панель инструментов.



    Панель инструментов


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

    Файл toolbar.py
    #!/usr/bin/python3
    # -*- coding: utf-8 -*-
    
    import sys
    from PyQt5.QtCore import *
    from PyQt5.QtWidgets import *
    import importlib
    
    class ToolBar(QToolBar):
        def __init__(self, iniFile, parent=None):
            super(ToolBar, self).__init__(parent)
            ini = QSettings(iniFile, QSettings.IniFormat)
            ini.setIniCodec("utf-8")
            ini.beginGroup("Tools")
            for key in sorted(ini.childKeys()):
                v = ini.value(key)
                title = v[0]
                params = v[1:]
                a = self.addAction(title)
                a.params = params
                a.triggered.connect(self.execAction)
            ini.endGroup()
    
        def execAction(self):
            try:
                params = self.sender().params
                module = importlib.import_module(params[0])
                if len(params) < 2: func = "run()"
                else: func = params[1]
                win = self.focusTaskWindow()
                exec("module.%s(win)" % func)
            except:
                print(str(sys.exc_info()[1]))
            return
    
        def focusTaskWindow(self):
            try:
                return QApplication.instance().focusedTaskWindow()
            except:
                return None
    
    if __name__ == '__main__':
        app = QApplication(sys.argv)
        ex = ToolBar("tools.ini")
        flags = Qt.Tool | Qt.WindowDoesNotAcceptFocus # | ex.windowFlags()
        ex.setWindowFlags(flags)
        ex.show()
        sys.exit(app.exec_())
    


    Для панелей инструментов в Qt есть готовый класс QToolBar, от него породим свой ToolBar. Сейчас нам достаточно одного тулбара, но заложимся на возможность добавления в программу нескольких панелей. Каждой панели нужен свой конфигурационный файл со своим набором кнопок, поэтому имя файла будем передавать параметром при создании тулбара.
    Конфигурационный файл будет традиционно в формате Ini и кодировке UTF-8.

    class ToolBar(QToolBar):
        def __init__(self, iniFile, parent=None):
            super(ToolBar, self).__init__(parent)
            ini = QSettings(iniFile, QSettings.IniFormat)
            ini.setIniCodec("utf-8")
    

    Синтаксис определения кнопок в наших руках, в простейшем случае нам нужны три вещи:

    — текст на кнопке
    — модуль, содержащий функцию кнопки
    — функция кнопки

    Определимся, что функция кнопки принимает один параметр — текущее дочернее окно. Что именно будет делать модуль с ним — задача модуля кнопки, а задача тулбара ограничивается только его вызовом.

    Создадим такой файл tools.ini:

    [Tools]
    001=Export to Excel,exportview,"exportToExcel"
    002=Copy as HTML,exportview,"copyAsHtml"
    

    Теперь в питоне разбираем определения из Ini-файла:

            ini.beginGroup("Tools")
            # Перебираем переменные в алфавитном порядке
            for key in sorted(ini.childKeys()):
                # Здесь мы получим list, т.к. ini позволяет указать 
                # список значений, разделенных запятыми
                v = ini.value(key)
                title = v[0]
                params = v[1:]
                # создадим на панели кнопку и QAction, отвечающий за нее
                a = self.addAction(title) 
                # остаток списка со второго элемента [модуль, функция] сохраним в QAction
                a.params = params 
                # для всех кнопок у нас будет один метод выполнения
                a.triggered.connect(self.execAction) 
            ini.endGroup()
    

    Метод выполнения, назначенный всем кнопкам, будет импортировать нужный модуль и вызывать из него назначенную кнопке функцию. Чтобы нам не прописывать каждый модуль в перечне импорта тулбара, воспользуемся библиотекой importlib. Осталось только узнать, что за кнопка была нажата и от какого QAction пришел сигнал — за это отвечает стандартный метод QObject.sender(), далее возьмем сохраненные в нем параметры и сделаем то, что задумано в модуле (что бы это ни было).

        def execAction(self):
            try:
                params = self.sender().params
                module = importlib.import_module(params[0])
                func = params[1]
                win = self.focusTaskWindow()
                exec("module.%s(win)" % func)
            except:
                print(str(sys.exc_info()[1]))
            return
    

    Осталось добавить нашу панель в наше главное окно (модуль tasktree.py)

            self.tools = ToolBar("tools.ini",self)
            self.addToolBar(self.tools)
    

    Можем запустить и проверить, появилась ли панель:



    Может быть не так симпатично, как на первой картинке, главное, что работает.

    Модуль функций инструментов


    Теперь самое время сделать модуль с функциями кнопок. Модуль у нас будет один, потому что функции экспорта и копирования будут работать с одним источником данных и по одинаковым правилам, нет смысла разносить их по разным модулям.

    Файл exportview.py
    #!/usr/bin/python3
    # -*- coding: utf-8 -*-
    
    import sys
    import datetime
    from PyQt5.QtCore import *
    from PyQt5.QtWidgets import *
    import xlsxwriter
    
    class ob():
       def test(self):
          return 1
    
    def exportToExcel(win):
       if win == None:
          print("No focused window")
          return
       view = focusItemView(win)
       title = win.windowTitle() + '.xlsx'
       if view == None:
          print("No focused item view")
          return
    
       # Create a workbook and add a worksheet.
       fileName = QFileDialog.getSaveFileName(None, 'Save Excel file', title,'Excel files (*.xlsx)')
       if fileName == ('',''): return
    
       indexes = view.selectionModel().selectedIndexes()
       if len(indexes) == 0:
          indexes = view.selectAll()
          indexes = view.selectionModel().selectedIndexes()
       model = view.model()
       d = sortedIndexes(indexes)
       headers = { col:model.headerData(col, Qt.Horizontal) for col in d.columns }
       minRow = min(d.rows)
       minCol = min(d.columns)
       try:
          workbook = xlsxwriter.Workbook(fileName[0])
          worksheet = workbook.add_worksheet()
          bold = workbook.add_format({'bold': True})
          dateFormat = 'dd.MM.yyyy'
          date = workbook.add_format({'num_format': dateFormat})
          realCol = 0
          for col in d.columns:
             worksheet.write(0, realCol, headers[col], bold)
             realRow = 1
             for row in d.rows:
                if (row, col) in d.indexes:
                   try:
                      v = d.indexes[(row,col)].data(Qt.EditRole)
                      if isinstance(v, QDateTime):
                         if v.isValid() and v.toPyDateTime() > datetime.datetime(1900,1,1):
                            v = v.toPyDateTime()
                            worksheet.write_datetime(realRow, realCol, v, date)
                         else:
                            v = v.toString(dateFormat)
                            worksheet.write(realRow, realCol, v)
                      else:
                         worksheet.write(realRow, realCol, v)
                   except:
                      print(str(sys.exc_info()[1]))
                realRow += 1
             realCol += 1
          workbook.close()
       except:
          QMessageBox.critical(None,'Export error',str(sys.exc_info()[1]))
          return
    
    def copyAsHtml(win):
       if win == None:
          print("No focused window")
          return
       view = focusItemView(win)
       if view == None:
          print("No focused item view")
          return
       indexes = view.selectedIndexes()
       if len(indexes) == 0:
          indexes = view.selectAll()
          indexes = view.selectedIndexes()
       if len(indexes) == 0:
          return;
       model = view.model()
       try:
          d = sortedIndexes(indexes)
          html = '<table><tbody>\n'
          headers = { col:model.headerData(col, Qt.Horizontal) for col in d.columns }
          html += '<tr>' 
          for c in d.columns:
             html += '<th>%s</th>' % headers[c]
          html += '</tr>\n' 
          for r in d.rows:
             html += '<tr>' 
             for c in d.columns:
                if (r, c) in d.indexes:
                   v = d.indexes[(r,c)].data(Qt.DisplayRole)
                   html += '<td>%s</td>' % v
                else:
                   html += '<td></td>'
             html += '</tr>' 
          html += '</tbody></table>'
          mime = QMimeData()
          mime.setHtml(html)
          clipboard = QApplication.clipboard()
          clipboard.setMimeData(mime)
       except:
          QMessageBox.critical(None,'Export error',str(sys.exc_info()[1]))
    
    def sortedIndexes(indexes):
        d = ob()
        d.indexes = { (i.row(), i.column()):i for i in indexes }
        d.rows = sorted(list(set([ i[0] for i in d.indexes ])))
        d.columns = sorted(list(set([ i[1] for i in d.indexes ])))
        return d
    
    def headerNames(model, minCol, maxCol):
        headers = dict()
        for col in range(minCol, maxCol+1):
            headers[col] = model.headerData(col, Qt.Horizontal)
        return headers
    
    def focusItemView(win):
        if win == None: return None
        w = win.focusWidget()
        if w != None and isinstance(w, QTableView):
            return w
        views = win.findChildren(QTableView)
        if type(views) == type([]) and len(views)>0:
            return views[0]
        return None
    


    Наши функции будут работать с таблицами данных QTableView, который мы использовали в модулях для просмотра результатов запроса. Чтобы сохранить независимость модулей, определять нужный компонент будем «на лету» — либо это текущий выбранный (focused) компонент QTableView в текущем окне, либо первый попавшийся нужного класса среди дочерних элементов текущего окна.

    def focusItemView(win):
        if win == None: return None
        w = win.focusWidget()
        if w != None and isinstance(w, QTableView):
            return w
        views = win.findChildren(QTableView)
        if type(views) == type([]) and len(views)>0:
            return views[0]
        return None
    

    Из таблицы получаем список выбранных ячеек. Если ничего не выбрано, то принудительно выбираем всё.

       indexes = view.selectionModel().selectedIndexes()
       if len(indexes) == 0:
          indexes = view.selectAll()
          indexes = view.selectionModel().selectedIndexes()
       if len(indexes) == 0:
          return;
    

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

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

    def sortedIndexes(indexes):
        d = ob() # объект-пустышка
        d.indexes = { (i.row(), i.column()):i for i in indexes }
        d.rows = sorted(list(set([ i[0] for i in d.indexes ])))
        d.columns = sorted(list(set([ i[1] for i in d.indexes ])))
        return d
    

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



    Поэтому в d.rows есть каждая использованная строка, в d.columns есть каждый использованный столбец, но их сочетание необязательно есть в d.indexes.

    Еще нам для пущей красоты нужен перечень наименований столбцов, который выводятся в QTableView. Получим их из модели методом headerData:

       headers = { col:model.headerData(col, Qt.Horizontal) for col in d.columns }
    

    До сих пор код для экспорта и копирования был одинаковым, но теперь пошли различия.

    Экспорт в Excel


    Для экспорта в файлы Excel я воспользовался пакетом xlsxwriter. Он устанавливается, как обычно, через pip:

    pip3 install xlsxwriter
    

    Документация пакета вполне подробная и понятная, с примерами, поэтому останавливаться на нем не буду. Суть в том, что запись идет по ячейкам, адресуемым по номеру строки и столбца. Если нужно дополнительное форматирование, то нужно определить стиль и указывать его при записи ячейки.

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

       fileName = QFileDialog.getSaveFileName(None, 'Save Excel file', title,'Excel files (*.xlsx)')
       if fileName == ('',''): return
    

    Собственно экспорт:

          workbook = xlsxwriter.Workbook(fileName[0])
          worksheet = workbook.add_worksheet()
          bold = workbook.add_format({'bold': True})
          dateFormat = 'dd.MM.yyyy'
          date = workbook.add_format({'num_format': dateFormat})
          realCol = 0
          for col in d.columns:
             worksheet.write(0, realCol, headers[col], bold)
             realRow = 1
             for row in d.rows:
                if (row, col) in d.indexes:
                   try:
                      v = d.indexes[(row,col)].data(Qt.EditRole)
                      if isinstance(v, QDateTime):
                         if v.isValid() and v.toPyDateTime() > datetime.datetime(1900,1,1):
                            v = v.toPyDateTime()
                            worksheet.write_datetime(realRow, realCol, v, date)
                         else:
                            v = v.toString(dateFormat)
                            worksheet.write(realRow, realCol, v)
                      else:
                         worksheet.write(realRow, realCol, v)
                   except:
                      print(str(sys.exc_info()[1]))
                realRow += 1
             realCol += 1
          workbook.close()
    

    Танцы вокруг QDateTime добавлены из-за разного понимания даты/времени в Python, Qt и Excel — во-первых, пакет xlsxwriter умеет работать с питоновским datetime, но не умеет с QDateTime из Qt, поэтому приходится дополнительно его конвертировать специальной функцией toPyDateTime; во-вторых, Excel умеет работать только с датами с 01.01.1900, а всё, что было до этого времени для Excel — просто строка.

    Результат экспорта в Excel:



    Копирование в системный буфер в формате HTML


    Не всегда нужен отдельный файл с выборкой, часто, особенно когда данных немного, удобнее скопировать их в табличном виде в системный буфер (clipboard), а затем вставить в нужное место, будь то Excel, Word, редактор веб-страниц или что-то другое.

    Наиболее универсальным способом копирования табличных данных через буфер — это обычный формат HTML. В Windows, *nix и MacOS сильно разные способы работы с буфером (не говоря о том, что их несколько), поэтому хорошо, что Qt скрывает от нас детали реализации.

    Всё, что нам нужно — создать объект QMimeData, заполнить его через метод setHtml фрагментом HTML-разметки, и отдать в системный clipboard, который доступен через QApplication

          mime = QMimeData()
          mime.setHtml(html)
          clipboard = QApplication.clipboard()
          clipboard.setMimeData(mime)
    

    Таблицу собираем построчно, начиная с заголовков.

          html = '<table><tbody>\n'
          headers = { col:model.headerData(col, Qt.Horizontal) for col in d.columns }
          html += '<tr>' 
          for c in d.columns:
             html += '<th>%s</th>' % headers[c]
          html += '</tr>\n' 
          for r in d.rows:
             html += '<tr>' 
             for c in d.columns:
                if (r, c) in d.indexes:
                   v = d.indexes[(r,c)].data(Qt.DisplayRole)
                   html += '<td>%s</td>' % v
                else:
                   html += '<td></td>'
             html += '</tr>' 
          html += '</tbody></table>'
    

    Результат, вставленный в Word:

    Здесь границы таблицы видны только благодаря включенной в Word настройке "Показывать границы текста", на самом деле они невидимы. Чтобы таблица копировалась с явными границами, нужно изменить стиль таблицы в тэге table. Предоставляю это сделать вам.

    Заключение


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

    Исходники, использованные в примерах, как и ранее, выложены на github под лицензией MIT.

    Начало — Точим себе инструмент на PyQt
    Продолжение — Режем XML по разметке XQuery
    Share post

    Comments 4

    • UFO just landed and posted this here
        0
        Раз пошла такая пьянка, может расскажите, как из QtableWidget отловить, какую ячейку отредактировал человек (на самом деле это мы уже поняли) и что ввел в нее и главное сигнал, о том что она была выбрана и что окончили ввод текста/редактирования ячейки.
          0
          Интересный вопрос. В C++ я чаще работаю с QTableView, поэтому вводимое значение ловлю на setData модели. В одном месте у меня есть QTableWidget, но там используется свой делегат, т.к. данные сложные и редактор неоднозначный. В принципе, можно у дефолтного делегата взять сигнал commitData. А можно у QTableWidget переопределить виртуальные слоты edit и commitData — в первом запоминать редактируемую позицию, а во втором получать значение поля.
          Пример:
          #!/usr/bin/python3
          # -*- coding: utf-8 -*-
          
          import sys
          from PyQt5.QtCore import *
          from PyQt5.QtWidgets import *
          
          class Table(QTableWidget):
              def __init__(self, rows, columns, parent = None):
                  super().__init__(rows, columns, parent)
                  self.editIndex = QModelIndex()
          
              def edit(self, index, trigger, event):
                  self.editIndex = index
                  return super().edit(index, trigger, event)
          
              def commitData(self, editor):
                  print("Commit r: %s, c: %s" % (self.editIndex.row(), self.editIndex.column()))
                  print("Old value:", self.editIndex.data())
                  super().commitData(editor)
                  print("New value:", self.editIndex.data())
                  
          app = QApplication(sys.argv)
          w = Table(12, 2)
          for r in range(0,12):
              i = QTableWidgetItem(str(r))
              w.setItem(r, 0, i)
              i = QTableWidgetItem("Item %s" % str(r))
              w.setItem(r, 1, i)
          w.show()
          sys.exit(app.exec_())
          
            0
            спасибо, то что надо. Давно искал хороший пример.

        Only users with full accounts can post comments. Log in, please.