В предыдущей части я рассказывал о создании модуля для запуска SQL-запросов и оболочки, в которой эти модули запускаются. После недолгой работы с запросами возникает очевидный вопрос — а как воспользоваться результатом выборки, кроме как посмотреть на экране?
Для этого стоит сделать дополнительные инструменты экспорта и копирования данных. Экспортировать будем в файл в формате Excel, а копировать в системный буфер в формате HTML.
Но для начала прилепим к нашему главному окну панель инструментов.
Напомню, что наше приложение призвано быть простым, универсальным и расширяемым. Чтобы тулбар тоже сделать универсальным и расширяемым, вынесем его определение в файл конфигурации, а выполняемые функции будут находиться во внешних модулях, явно не импортируемых в модуле тулбара. Таким образом добавление новой кнопки и функции сведется к прописыванию их в конфигурационном файле и добавлению модуля в каталог программы.
Для панелей инструментов в Qt есть готовый класс QToolBar, от него породим свой ToolBar. Сейчас нам достаточно одного тулбара, но заложимся на возможность добавления в программу нескольких панелей. Каждой панели нужен свой конфигурационный файл со своим набором кнопок, поэтому имя файла будем передавать параметром при создании тулбара.
Конфигурационный файл будет традиционно в формате Ini и кодировке UTF-8.
Синтаксис определения кнопок в наших руках, в простейшем случае нам нужны три вещи:
— текст на кнопке
— модуль, содержащий функцию кнопки
— функция кнопки
Определимся, что функция кнопки принимает один параметр — текущее дочернее окно. Что именно будет делать модуль с ним — задача модуля кнопки, а задача тулбара ограничивается только его вызовом.
Создадим такой файл tools.ini:
Теперь в питоне разбираем определения из Ini-файла:
Метод выполнения, назначенный всем кнопкам, будет импортировать нужный модуль и вызывать из него назначенную кнопке функцию. Чтобы нам не прописывать каждый модуль в перечне импорта тулбара, воспользуемся библиотекой importlib. Осталось только узнать, что за кнопка была нажата и от какого QAction пришел сигнал — за это отвечает стандартный метод QObject.sender(), далее возьмем сохраненные в нем параметры и сделаем то, что задумано в модуле (что бы это ни было).
Осталось добавить нашу панель в наше главное окно (модуль tasktree.py)
Можем запустить и проверить, появилась ли панель:
Может быть не так симпатично, как на первой картинке, главное, что работает.
Теперь самое время сделать модуль с функциями кнопок. Модуль у нас будет один, потому что функции экспорта и копирования будут работать с одним источником данных и по одинаковым правилам, нет смысла разносить их по разным модулям.
Наши функции будут работать с таблицами данных QTableView, который мы использовали в модулях для просмотра результатов запроса. Чтобы сохранить независимость модулей, определять нужный компонент будем «на лету» — либо это текущий выбранный (focused) компонент QTableView в текущем окне, либо первый попавшийся нужного класса среди дочерних элементов текущего окна.
Из таблицы получаем список выбранных ячеек. Если ничего не выбрано, то принудительно выбираем всё.
Наверное, вы уже в курсе, что в Qt вы не получаете массив данных напрямую, вместо этого вы работаете с индексами в модели. Индекс QModelIndex представляет собой простую структуру и указывает на конкретную позицию данных (строку row() и столбец column(), а в иерархии указание на индекс родителя parent()). Получив индекс, можно из него получить сами данные методом data().
Мы получили список индексов выбранных ячеек в модели, но индексы в этом списке следуют в том порядке, в котором пользователь их выделял, а не отсортированные по строкам и столбцам. Нам же удобнее будет работать не со списком, а с словарем (позиция > индекс) и сортированными списками задействованных строк и столбцов.
Еще стоит учесть, что QTableView по умолчанию позволяет выделять несвязанные ячейки, потому в списке индексов могут быть ячейки, практически случайно расположенные:
Поэтому в d.rows есть каждая использованная строка, в d.columns есть каждый использованный столбец, но их сочетание необязательно есть в d.indexes.
Еще нам для пущей красоты нужен перечень наименований столбцов, который выводятся в QTableView. Получим их из модели методом headerData:
До сих пор код для экспорта и копирования был одинаковым, но теперь пошли различия.
Для экспорта в файлы Excel я воспользовался пакетом xlsxwriter. Он устанавливается, как обычно, через pip:
Документация пакета вполне подробная и понятная, с примерами, поэтому останавливаться на нем не буду. Суть в том, что запись идет по ячейкам, адресуемым по номеру строки и столбца. Если нужно дополнительное форматирование, то нужно определить стиль и указывать его при записи ячейки.
Имя xlsx-файла, в который будем экспортировать, запросим у пользователя, у Qt есть такая функция. В PyQt функция возвращает список из выбранного имени файла и использованного фильтра. Если вернулся список из пустых строк, то это означает, что пользователь отказался от выбора.
Собственно экспорт:
Танцы вокруг QDateTime добавлены из-за разного понимания даты/времени в Python, Qt и Excel — во-первых, пакет xlsxwriter умеет работать с питоновским datetime, но не умеет с QDateTime из Qt, поэтому приходится дополнительно его конвертировать специальной функцией toPyDateTime; во-вторых, Excel умеет работать только с датами с 01.01.1900, а всё, что было до этого времени для Excel — просто строка.
Результат экспорта в Excel:
Не всегда нужен отдельный файл с выборкой, часто, особенно когда данных немного, удобнее скопировать их в табличном виде в системный буфер (clipboard), а затем вставить в нужное место, будь то Excel, Word, редактор веб-страниц или что-то другое.
Наиболее универсальным способом копирования табличных данных через буфер — это обычный формат HTML. В Windows, *nix и MacOS сильно разные способы работы с буфером (не говоря о том, что их несколько), поэтому хорошо, что Qt скрывает от нас детали реализации.
Всё, что нам нужно — создать объект QMimeData, заполнить его через метод setHtml фрагментом HTML-разметки, и отдать в системный clipboard, который доступен через QApplication
Таблицу собираем построчно, начиная с заголовков.
Результат, вставленный в Word:
Здесь границы таблицы видны только благодаря включенной в Word настройке "Показывать границы текста", на самом деле они невидимы. Чтобы таблица копировалась с явными границами, нужно изменить стиль таблицы в тэге table. Предоставляю это сделать вам.
Итак, мы получили способ добавления в наш инструмент новых функций, причем функции добавляются и работают независимо от того, какими источниками данных мы будем пользоваться и как их отображать — модули, работающие с данными, ничего не знают о тулбарах и их функциях, тулбары не связаны ни с модулями данных, ни с функциями кнопок, а функции кнопок, не зная ни о тулбарах, ни о модулях данных, просто пытаются обработать текущий визуальный компонент известным им способом.
Исходники, использованные в примерах, как и ранее, выложены на github под лицензией MIT.
Для этого стоит сделать дополнительные инструменты экспорта и копирования данных. Экспортировать будем в файл в формате 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.
Комментарии (4)
ssss41
04.09.2017 20:56Раз пошла такая пьянка, может расскажите, как из QtableWidget отловить, какую ячейку отредактировал человек (на самом деле это мы уже поняли) и что ввел в нее и главное сигнал, о том что она была выбрана и что окончили ввод текста/редактирования ячейки.
sshmakov Автор
04.09.2017 23:13Интересный вопрос. В 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_())
serg_deep
Ждем коммента что python не нужен и можно все написать на C++.