qvm_template_gui.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447
  1. import asyncio
  2. import collections
  3. from datetime import datetime
  4. import itertools
  5. import json
  6. import os
  7. import typing
  8. import PyQt5 # pylint: disable=import-error
  9. import PyQt5.QtWidgets # pylint: disable=import-error
  10. from . import ui_qvmtemplate # pylint: disable=no-name-in-module
  11. from . import ui_templateinstallconfirmdlg # pylint: disable=no-name-in-module
  12. from . import ui_templateinstallprogressdlg # pylint: disable=no-name-in-module
  13. from . import utils
  14. #pylint: disable=invalid-name
  15. BASE_CMD = ['qvm-template', '--enablerepo=*', '--yes']
  16. # singleton for "no date"
  17. ZERO_DATE = datetime.utcfromtimestamp(0)
  18. # pylint: disable=too-few-public-methods,inherit-non-class
  19. class Template(typing.NamedTuple):
  20. status: str
  21. name: str
  22. evr: str
  23. reponame: str
  24. size: int
  25. buildtime: datetime
  26. installtime: typing.Optional[datetime]
  27. #licence: str
  28. #url: str
  29. #summary: str
  30. # ---- internal ----
  31. description: str
  32. default_status: str
  33. # ------------------
  34. COL_NAMES = [
  35. 'Status',
  36. 'Name',
  37. 'Version',
  38. 'Repository',
  39. 'Download Size (MB)',
  40. 'Build',
  41. 'Install',
  42. #'License',
  43. #'URL',
  44. #'Summary'
  45. ]
  46. @staticmethod
  47. def build(status, entry):
  48. cli_format = '%Y-%m-%d %H:%M:%S'
  49. buildtime = datetime.strptime(entry['buildtime'], cli_format)
  50. if entry['installtime']:
  51. installtime = datetime.strptime(entry['installtime'], cli_format)
  52. else:
  53. installtime = ZERO_DATE
  54. return Template(
  55. status,
  56. entry['name'],
  57. '%s:%s-%s' % (entry['epoch'], entry['version'], entry['release']),
  58. entry['reponame'],
  59. int(entry['size']) // 1000000,
  60. buildtime,
  61. installtime,
  62. #entry['license'],
  63. #entry['url'],
  64. entry['description'],
  65. status
  66. )
  67. class Action(typing.NamedTuple):
  68. op: str
  69. name: str
  70. evr: str
  71. TYPES = [str, str, str]
  72. COL_NAMES = ['Operation', 'Name', 'Version']
  73. class TemplateStatusDelegate(PyQt5.QtWidgets.QStyledItemDelegate):
  74. OPS = [
  75. ['Installed', 'Reinstall', 'Remove'],
  76. ['Extra', 'Remove'],
  77. ['Upgradable', 'Upgrade', 'Remove'],
  78. ['Downgradable', 'Downgrade', 'Remove'],
  79. ['Available', 'Install']
  80. ]
  81. def createEditor(self, parent, option, index):
  82. _ = option # unused
  83. editor = PyQt5.QtWidgets.QComboBox(parent)
  84. # Otherwise the internalPointer can be overwritten with a QComboBox
  85. index = index.model().index(index.row(), index.column())
  86. kind = index.internalPointer().default_status
  87. for op_list in TemplateStatusDelegate.OPS:
  88. if op_list[0] == kind:
  89. for op in op_list:
  90. editor.addItem(op)
  91. editor.currentIndexChanged.connect(self.currentIndexChanged)
  92. editor.showPopup()
  93. return editor
  94. return None
  95. def setEditorData(self, editor, index):
  96. #pylint: disable=no-self-use
  97. cur = index.data()
  98. idx = editor.findText(cur)
  99. if idx >= 0:
  100. editor.setCurrentIndex(idx)
  101. def setModelData(self, editor, model, index):
  102. #pylint: disable=no-self-use
  103. model.setData(index, editor.currentText())
  104. def updateEditorGeometry(self, editor, option, index):
  105. #pylint: disable=no-self-use
  106. _ = index # unused
  107. editor.setGeometry(option.rect)
  108. @PyQt5.QtCore.pyqtSlot()
  109. def currentIndexChanged(self):
  110. self.commitData.emit(self.sender())
  111. class TemplateModel(PyQt5.QtCore.QAbstractItemModel):
  112. def __init__(self):
  113. super().__init__()
  114. self.children = []
  115. def flags(self, index):
  116. if index.isValid() and index.column() == 0:
  117. return super().flags(index) | PyQt5.QtCore.Qt.ItemIsEditable
  118. return super().flags(index)
  119. def sort(self, idx, order):
  120. rev = (order == PyQt5.QtCore.Qt.AscendingOrder)
  121. self.children.sort(key=lambda x: x[idx], reverse=rev)
  122. self.dataChanged.emit(*self.row_index(0, self.rowCount() - 1))
  123. def index(self, row, column, parent=PyQt5.QtCore.QModelIndex()):
  124. if not self.hasIndex(row, column, parent):
  125. return PyQt5.QtCore.QModelIndex()
  126. return self.createIndex(row, column, self.children[row])
  127. def parent(self, child):
  128. #pylint: disable=no-self-use
  129. _ = child # unused
  130. return PyQt5.QtCore.QModelIndex()
  131. def rowCount(self, parent=PyQt5.QtCore.QModelIndex()):
  132. #pylint: disable=no-self-use
  133. _ = parent # unused
  134. return len(self.children)
  135. def columnCount(self, parent=PyQt5.QtCore.QModelIndex()):
  136. #pylint: disable=no-self-use
  137. _ = parent # unused
  138. return len(Template.COL_NAMES)
  139. def hasChildren(self, index=PyQt5.QtCore.QModelIndex()):
  140. #pylint: disable=no-self-use
  141. return index == PyQt5.QtCore.QModelIndex()
  142. def data(self, index, role=PyQt5.QtCore.Qt.DisplayRole):
  143. # pylint: disable=too-many-return-statements
  144. if index.isValid():
  145. data = self.children[index.row()][index.column()]
  146. if role == PyQt5.QtCore.Qt.DisplayRole:
  147. if data is ZERO_DATE:
  148. return ''
  149. if isinstance(data, datetime):
  150. return data.strftime('%d %b %Y')
  151. return data
  152. if role == PyQt5.QtCore.Qt.FontRole:
  153. font = PyQt5.QtGui.QFont()
  154. tpl = self.children[index.row()]
  155. font.setBold(tpl.status != tpl.default_status)
  156. return font
  157. if role == PyQt5.QtCore.Qt.TextAlignmentRole:
  158. if isinstance(data, int):
  159. return PyQt5.QtCore.Qt.AlignRight
  160. return PyQt5.QtCore.Qt.AlignLeft
  161. return None
  162. def setData(self, index, value, role=PyQt5.QtCore.Qt.EditRole):
  163. if index.isValid() and role == PyQt5.QtCore.Qt.EditRole:
  164. old_list = list(self.children[index.row()])
  165. old_list[index.column()] = value
  166. new_tpl = Template(*old_list)
  167. self.children[index.row()] = new_tpl
  168. self.dataChanged.emit(index, index)
  169. return True
  170. return False
  171. def headerData(self, section, orientation,
  172. role=PyQt5.QtCore.Qt.DisplayRole):
  173. #pylint: disable=no-self-use
  174. if section < len(Template.COL_NAMES) \
  175. and orientation == PyQt5.QtCore.Qt.Horizontal \
  176. and role == PyQt5.QtCore.Qt.DisplayRole:
  177. return Template.COL_NAMES[section]
  178. return None
  179. def removeRows(self, row, count, parent=PyQt5.QtCore.QModelIndex()):
  180. _ = parent # unused
  181. self.beginRemoveRows(PyQt5.QtCore.QModelIndex(), row, row + count)
  182. del self.children[row:row+count]
  183. self.endRemoveRows()
  184. self.dataChanged.emit(*self.row_index(row, row + count))
  185. def row_index(self, low, high):
  186. return self.createIndex(low, 0), \
  187. self.createIndex(high, self.columnCount())
  188. def set_templates(self, templates):
  189. self.removeRows(0, self.rowCount())
  190. cnt = sum(len(g) for _, g in templates.items())
  191. self.beginInsertRows(PyQt5.QtCore.QModelIndex(), 0, cnt - 1)
  192. for status, grp in templates.items():
  193. for tpl in grp:
  194. self.children.append(Template.build(status, tpl))
  195. self.endInsertRows()
  196. self.dataChanged.emit(*self.row_index(0, self.rowCount() - 1))
  197. def get_actions(self):
  198. actions = []
  199. for tpl in self.children:
  200. if tpl.status != tpl.default_status:
  201. actions.append(Action(tpl.status, tpl.name, tpl.evr))
  202. return actions
  203. async def refresh(self, refresh=True):
  204. cmd = BASE_CMD[:]
  205. if refresh:
  206. # Force refresh if triggered by button press
  207. cmd.append('--refresh')
  208. cmd.extend(['info', '--machine-readable-json', '--installed',
  209. '--available', '--upgrades', '--extras'])
  210. proc = await asyncio.create_subprocess_exec(
  211. *cmd,
  212. stdout=asyncio.subprocess.PIPE,
  213. stderr=asyncio.subprocess.PIPE)
  214. output, stderr = await proc.communicate()
  215. output = output.decode('ASCII')
  216. if proc.returncode != 0:
  217. stderr = stderr.decode('ASCII')
  218. return False, stderr
  219. # Default type is dict as we're going to replace the lists with
  220. # dicts shortly after
  221. tpls = collections.defaultdict(dict, json.loads(output))
  222. # Remove duplicates
  223. # Should this be done in qvm-template?
  224. # TODO: Merge templates with same name?
  225. # If so, we may need to have a separate UI to force versions.
  226. local_names = set(x['name'] for x in tpls['installed'])
  227. # Convert to dict for easier subtraction
  228. for key in tpls:
  229. tpls[key] = {
  230. (x['name'], x['epoch'], x['version'], x['release']): x
  231. for x in tpls[key]}
  232. tpls['installed'] = {
  233. k: v for k, v in tpls['installed'].items()
  234. if k not in tpls['extra'] and k not in tpls['upgradable']}
  235. tpls['available'] = {
  236. k: v for k, v in tpls['available'].items()
  237. if k not in tpls['installed']
  238. and k not in tpls['upgradable']}
  239. # If the package name is installed but the specific version is
  240. # neither installed or an upgrade, then it must be a downgrade
  241. tpls['downgradable'] = {
  242. k: v for k, v in tpls['available'].items()
  243. if k[0] in local_names}
  244. tpls['available'] = {
  245. k: v for k, v in tpls['available'].items()
  246. if k not in tpls['downgradable']}
  247. # Convert back to list
  248. tpls = {k.title(): list(v.values()) for k, v in tpls.items()}
  249. self.set_templates(tpls)
  250. return True, None
  251. class TemplateInstallConfirmDialog(
  252. ui_templateinstallconfirmdlg.Ui_TemplateInstallConfirmDlg,
  253. PyQt5.QtWidgets.QDialog):
  254. def __init__(self, actions):
  255. super().__init__()
  256. self.setupUi(self)
  257. model = PyQt5.QtGui.QStandardItemModel()
  258. model.setHorizontalHeaderLabels(Action.COL_NAMES)
  259. self.treeView.setModel(model)
  260. for act in actions:
  261. model.appendRow([PyQt5.QtGui.QStandardItem(x) for x in act])
  262. class TemplateInstallProgressDialog(
  263. ui_templateinstallprogressdlg.Ui_TemplateInstallProgressDlg,
  264. PyQt5.QtWidgets.QDialog):
  265. def __init__(self, actions):
  266. super().__init__()
  267. self.setupUi(self)
  268. self.actions = actions
  269. self.buttonBox.hide()
  270. @staticmethod
  271. def _process_cr(text):
  272. """Reduce lines replaced using CR character (\r)"""
  273. while '\r' in text:
  274. prefix, suffix = text.rsplit('\r', 1)
  275. if '\n' in prefix:
  276. prefix = prefix.rsplit('\n', 1)[0]
  277. prefix += '\n'
  278. else:
  279. prefix = ''
  280. text = prefix + suffix
  281. return text
  282. def install(self):
  283. async def coro():
  284. self.actions.sort()
  285. for oper, grp in itertools.groupby(self.actions, lambda x: x[0]):
  286. oper = oper.lower()
  287. # No need to specify versions for local operations
  288. if oper in ('remove', 'purge'):
  289. specs = [x.name for x in grp]
  290. else:
  291. specs = [x.name + '-' + x.evr for x in grp]
  292. # FIXME: (C)Python versions before 3.9 fully-buffers stderr in
  293. # this context, cf. https://bugs.python.org/issue13601
  294. # Forcing it to be unbuffered for the time being so that
  295. # the messages can be displayed in time.
  296. envs = os.environ.copy()
  297. envs['PYTHONUNBUFFERED'] = '1'
  298. proc = await asyncio.create_subprocess_exec(
  299. *(BASE_CMD + [oper, '--'] + specs),
  300. stdout=asyncio.subprocess.PIPE,
  301. stderr=asyncio.subprocess.STDOUT,
  302. env=envs)
  303. #pylint: disable=cell-var-from-loop
  304. status_text = ''
  305. while True:
  306. line = await proc.stdout.read(100)
  307. if line == b'':
  308. break
  309. line = line.decode('UTF-8')
  310. status_text = self._process_cr(status_text + line)
  311. self.textEdit.setPlainText(status_text)
  312. if await proc.wait() != 0:
  313. self.buttonBox.show()
  314. self.progressBar.setMaximum(100)
  315. self.progressBar.setValue(0)
  316. return False
  317. self.progressBar.setMaximum(100)
  318. self.progressBar.setValue(100)
  319. self.buttonBox.show()
  320. return True
  321. asyncio.create_task(coro())
  322. class QvmTemplateWindow(
  323. ui_qvmtemplate.Ui_QubesTemplateManager,
  324. PyQt5.QtWidgets.QMainWindow):
  325. def __init__(self, qt_app, qubes_app, dispatcher, parent=None):
  326. _ = parent # unused
  327. super().__init__()
  328. self.setupUi(self)
  329. self.listing.header().setSectionResizeMode(
  330. PyQt5.QtWidgets.QHeaderView.ResizeToContents)
  331. self.qubes_app = qubes_app
  332. self.qt_app = qt_app
  333. self.dispatcher = dispatcher
  334. self.listing_model = TemplateModel()
  335. self.listing_delegate = TemplateStatusDelegate(self.listing)
  336. self.listing.setModel(self.listing_model)
  337. self.listing.setItemDelegateForColumn(0, self.listing_delegate)
  338. self.refresh(False)
  339. self.listing.setItemDelegateForColumn(0, self.listing_delegate)
  340. self.listing.selectionModel() \
  341. .selectionChanged.connect(self.update_info)
  342. self.actionRefresh.triggered.connect(lambda: self.refresh(True))
  343. self.actionInstall.triggered.connect(self.do_install)
  344. def update_info(self, selected):
  345. _ = selected # unused
  346. indices = [
  347. x
  348. for x in self.listing.selectionModel().selectedIndexes()
  349. if x.column() == 0]
  350. if len(indices) == 0:
  351. return
  352. self.infobox.clear()
  353. cursor = PyQt5.QtGui.QTextCursor(self.infobox.document())
  354. bold_fmt = PyQt5.QtGui.QTextCharFormat()
  355. bold_fmt.setFontWeight(PyQt5.QtGui.QFont.Bold)
  356. norm_fmt = PyQt5.QtGui.QTextCharFormat()
  357. if len(indices) > 1:
  358. cursor.insertText('Selected templates:\n', bold_fmt)
  359. for idx in indices:
  360. tpl = self.listing_model.children[idx.row()]
  361. cursor.insertText(tpl.name + '-' + tpl.evr + '\n', norm_fmt)
  362. else:
  363. idx = indices[0]
  364. tpl = self.listing_model.children[idx.row()]
  365. cursor.insertText('Name: ', bold_fmt)
  366. cursor.insertText(tpl.name + '\n', norm_fmt)
  367. cursor.insertText('Description:\n', bold_fmt)
  368. cursor.insertText(tpl.description + '\n', norm_fmt)
  369. def refresh(self, refresh=True):
  370. self.progressBar.show()
  371. async def coro():
  372. ok, stderr = await self.listing_model.refresh(refresh)
  373. self.infobox.clear()
  374. if not ok:
  375. cursor = PyQt5.QtGui.QTextCursor(self.infobox.document())
  376. fmt = PyQt5.QtGui.QTextCharFormat()
  377. fmt.setFontWeight(PyQt5.QtGui.QFont.Bold)
  378. cursor.insertText('Failed to fetch template list:\n', fmt)
  379. fmt.setFontWeight(PyQt5.QtGui.QFont.Normal)
  380. cursor.insertText(stderr, fmt)
  381. self.progressBar.hide()
  382. asyncio.create_task(coro())
  383. def do_install(self):
  384. actions = self.listing_model.get_actions()
  385. confirm = TemplateInstallConfirmDialog(actions)
  386. if confirm.exec_():
  387. progress = TemplateInstallProgressDialog(actions)
  388. progress.install()
  389. progress.exec_()
  390. self.refresh()
  391. def main():
  392. utils.run_asynchronous(QvmTemplateWindow)
  393. if __name__ == '__main__':
  394. main()