app.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442
  1. import logging
  2. import sys
  3. from types import MappingProxyType
  4. from os import environ
  5. from outdated import check_outdated
  6. from pathlib import Path
  7. from PySide6.QtWidgets import (
  8. QApplication,
  9. QMenu,
  10. QPlainTextEdit,
  11. QPushButton,
  12. QStatusBar,
  13. QTabWidget,
  14. QTreeWidget,
  15. QWidget,
  16. QMessageBox,
  17. QFileDialog,
  18. )
  19. from PySide6.QtCore import (
  20. QObject,
  21. Qt,
  22. Signal,
  23. Slot,
  24. )
  25. from PySide6.QtGui import (
  26. QAction,
  27. QCursor,
  28. QGuiApplication,
  29. )
  30. from datalad import cfg as dlcfg
  31. from datalad import __version__ as dlversion
  32. import datalad.ui as dlui
  33. from datalad.interface.base import Interface
  34. from datalad.local.wtf import (
  35. _render_report,
  36. WTF,
  37. )
  38. from datalad.utils import chpwd
  39. from .utils import (
  40. load_ui,
  41. render_cmd_call,
  42. )
  43. from .datalad_ui import GooeyUI
  44. from .dataladcmd_exec import GooeyDataladCmdExec
  45. from .dataladcmd_ui import GooeyDataladCmdUI
  46. from .cmd_actions import add_cmd_actions_to_menu
  47. from .fsbrowser import GooeyFilesystemBrowser
  48. from .resource_provider import gooey_resources
  49. lgr = logging.getLogger('datalad.ext.gooey.app')
  50. class GooeyApp(QObject):
  51. # Mapping of key widget names used in the main window to their widget
  52. # classes. This mapping is used (and needs to be kept up-to-date) to look
  53. # up widget (e.g. to connect their signals/slots)
  54. _main_window_widgets = {
  55. 'contextTabs': QTabWidget,
  56. 'cmdTab': QWidget,
  57. 'fsBrowser': QTreeWidget,
  58. 'commandLog': QPlainTextEdit,
  59. 'errorLog': QPlainTextEdit,
  60. 'menuDataset': QMenu,
  61. 'menuView': QMenu,
  62. 'menuSuite': QMenu,
  63. 'menuUtilities': QMenu,
  64. 'menuHelp': QMenu,
  65. 'statusbar': QStatusBar,
  66. 'actionCheck_for_new_version': QAction,
  67. 'actionReport_a_problem': QAction,
  68. 'actionAbout': QAction,
  69. 'actionGetHelp': QAction,
  70. 'actionDiagnostic_infos': QAction,
  71. }
  72. execute_dataladcmd = Signal(str, MappingProxyType, MappingProxyType)
  73. configure_dataladcmd = Signal(str, MappingProxyType)
  74. def __init__(self, path: Path = None):
  75. super().__init__()
  76. # bend datalad to our needs
  77. # we cannot handle ANSI coloring
  78. dlcfg.set('datalad.ui.color', 'off', scope='override', force=True)
  79. # capture what env vars we modified, None means did not exist
  80. self._restore_env = {
  81. name: environ.get(name)
  82. for name in (
  83. 'GIT_TERMINAL_PROMPT',
  84. 'SSH_ASKPASS_REQUIRE',
  85. 'SSH_ASKPASS',
  86. )
  87. }
  88. # prevent any terminal-based interaction of Git
  89. # do it here, not just for command execution to also catch any possible
  90. # ad-hoc Git calls
  91. environ['GIT_TERMINAL_PROMPT'] = '0'
  92. # force asking passwords via Gooey
  93. # we use SSH* because also Git falls back onto it
  94. environ['SSH_ASKPASS_REQUIRE'] = 'force'
  95. environ['SSH_ASKPASS'] = 'datalad-gooey-askpass'
  96. # setup themeing before the first dialog goes up
  97. self._setup_looknfeel()
  98. self._dlapi = None
  99. self._main_window = None
  100. self._cmdexec = GooeyDataladCmdExec()
  101. self._cmdui = GooeyDataladCmdUI(self, self.get_widget('cmdTab'))
  102. # setup UI
  103. self._fsbrowser = GooeyFilesystemBrowser(
  104. self,
  105. self.get_widget('fsBrowser'),
  106. )
  107. # set path for root item and PWD to give relative paths a reference
  108. # that makes sense within the app
  109. self._set_root_path(path)
  110. # remember what backend was in use
  111. self._prev_ui_backend = dlui.ui.backend
  112. # ask datalad to use our UI
  113. # looks silly with the uiuiuiuiui, but these are the real names ;-)
  114. dlui.KNOWN_BACKENDS['gooey'] = GooeyUI
  115. dlui.ui.set_backend('gooey')
  116. uibridge = dlui.ui.ui.set_app(self)
  117. self.get_widget('statusbar').addPermanentWidget(uibridge.progress_bar)
  118. # connect the generic cmd execution signal to the handler
  119. self.execute_dataladcmd.connect(self._cmdexec.execute)
  120. # connect the generic cmd configuration signal to the handler
  121. self.configure_dataladcmd.connect(self._cmdui.configure)
  122. # when a command was configured, pass it to the executor
  123. self._cmdui.configured_dataladcmd.connect(self._cmdexec.execute)
  124. self.get_widget('statusbar').addPermanentWidget(
  125. self._cmdexec.activity_widget)
  126. # connect execution handler signals to the setup methods
  127. self._cmdexec.execution_started.connect(self._setup_ongoing_cmdexec)
  128. self._cmdexec.execution_finished.connect(self._setup_stopped_cmdexec)
  129. self._cmdexec.execution_failed.connect(self._setup_stopped_cmdexec)
  130. # arrange for the dataset menu to populate itself lazily once
  131. # necessary
  132. self.get_widget('menuDataset').aboutToShow.connect(
  133. self._populate_dataset_menu)
  134. self.main_window.actionSetBaseDirectory.triggered.connect(
  135. self._set_root_path)
  136. self.main_window.actionCheck_for_new_version.triggered.connect(
  137. self._check_new_version)
  138. self.main_window.actionReport_a_problem.triggered.connect(
  139. self._get_issue_template)
  140. self.main_window.actionGetHelp.triggered.connect(
  141. self._get_help)
  142. self.main_window.actionAbout.triggered.connect(
  143. self._get_info)
  144. self.main_window.actionDiagnostic_infos.triggered.connect(
  145. self._get_diagnostic_info)
  146. # connect the diagnostic WTF helper
  147. self._cmdexec.results_received.connect(
  148. self._app_cmdexec_results_handler)
  149. # reset the command configuration tab whenever the item selection in
  150. # tree view changed.
  151. # This behavior was originally requested in
  152. # https://github.com/datalad/datalad-gooey/issues/57
  153. # but proved to be undesirabled soon after
  154. # https://github.com/datalad/datalad-gooey/issues/105
  155. #self._fsbrowser._tree.currentItemChanged.connect(
  156. # lambda cur, prev: self._cmdui.reset_form())
  157. # TODO could be done lazily to save in entrypoint iteration
  158. self._setup_suites()
  159. self._connect_menu_view(self.get_widget('menuView'))
  160. def _setup_ongoing_cmdexec(self, thread_id, cmdname, cmdargs, exec_params):
  161. self.get_widget('statusbar').showMessage(f'Started `{cmdname}`')
  162. self.main_window.setCursor(QCursor(Qt.BusyCursor))
  163. # and give a persistent visual indication of what exactly is happening
  164. # in the log
  165. if cmdname.startswith('gooey_'):
  166. # but not for internal calls
  167. # https://github.com/datalad/datalad-gooey/issues/182
  168. return
  169. self.get_widget('commandLog').appendHtml(
  170. f"<hr>{render_cmd_call(cmdname, cmdargs)}<hr>"
  171. )
  172. def _setup_stopped_cmdexec(
  173. self, thread_id, cmdname, cmdargs, exec_params, ce=None):
  174. if ce is None:
  175. self.get_widget('statusbar').showMessage(f'Finished `{cmdname}`',
  176. timeout=1000)
  177. else:
  178. failed_msg = f"{render_cmd_call(cmdname, cmdargs)} <b>failed!</b>"
  179. # if a command crashes, state it in the statusbar
  180. self.get_widget('statusbar').showMessage(
  181. f'`{cmdname}` failed (see error log for details)')
  182. if not cmdname.startswith('gooey_'):
  183. # leave a brief note in the main log (if this was not a helper
  184. # call)
  185. # this alone would not be enough, because we do not know
  186. # whether the command log is visible
  187. self.get_widget('commandLog').appendHtml(
  188. f"<br>{failed_msg} (see error log for details)"
  189. )
  190. # but also barf the error into the logviewer
  191. lv = self.get_widget('errorLog')
  192. lv.appendHtml(failed_msg)
  193. lv.appendHtml(
  194. f'<font color="red"><pre>{ce.format_standard()}</pre></font>'
  195. )
  196. if not self._cmdexec.n_running:
  197. self.main_window.setCursor(QCursor(Qt.ArrowCursor))
  198. def deinit(self):
  199. dlui.ui.set_backend(self._prev_ui_backend)
  200. # restore any possible term prompt setup
  201. for var, val in self._restore_env.items():
  202. if val is not None:
  203. environ[var] = val
  204. #@cached_property not available for PY3.7
  205. @property
  206. def main_window(self):
  207. if not self._main_window:
  208. self._main_window = load_ui('main_window')
  209. return self._main_window
  210. def get_widget(self, name):
  211. wgt_cls = GooeyApp._main_window_widgets.get(name)
  212. if not wgt_cls:
  213. raise ValueError(f"Unknown widget {name}")
  214. wgt = self.main_window.findChild(wgt_cls, name=name)
  215. if not wgt:
  216. # if this happens, our internal _widgets is out of sync
  217. # with the UI declaration
  218. raise RuntimeError(
  219. f"Could not locate widget {name} ({wgt_cls.__name__})")
  220. return wgt
  221. def _set_root_path(self, path: Path):
  222. """Store the application root path and change PWD to it
  223. Right now this method can only be called once and only before the GUI
  224. is actually up.
  225. """
  226. # TODO we might want to enable *changing* the root dir by calling this
  227. # see https://github.com/datalad/datalad-gooey/issues/130
  228. # for a use case.
  229. # to make this possible, we would need to be able to adjust or reset the
  230. # treeview
  231. if not path:
  232. # first check if this was called as a slot
  233. action = self.sender()
  234. if action is not None:
  235. path = action.data()
  236. if not path:
  237. # start root path still not given, ask user
  238. path = QFileDialog.getExistingDirectory(
  239. caption="Select a base directory for DataLad",
  240. options=QFileDialog.ShowDirsOnly,
  241. )
  242. if not path:
  243. # user aborted root path selection, start in HOME.
  244. # HOME is a better choice than CWD in most environments
  245. path = Path.home()
  246. chpwd(path)
  247. self._path = path
  248. # (re)init the browser
  249. self._fsbrowser.set_root(path)
  250. @property
  251. def rootpath(self):
  252. return self._path
  253. def _populate_dataset_menu(self):
  254. """Private slot to populate connected QMenus with dataset actions"""
  255. from .active_suite import dataset_api
  256. add_cmd_actions_to_menu(
  257. self, self._cmdui.configure, dataset_api, self.sender())
  258. # immediately sever the connection to avoid repopulating the menu
  259. # over and over
  260. self.get_widget('menuDataset').aboutToShow.disconnect(
  261. self._populate_dataset_menu)
  262. def _check_new_version(self):
  263. self.get_widget('statusbar').showMessage(
  264. 'Checking latest version', timeout=2000)
  265. try:
  266. is_outdated, latest = check_outdated('datalad', dlversion)
  267. except ValueError:
  268. # thrown when one is in a development version (ie., more
  269. # recent than the most recent release)
  270. is_outdated = False
  271. pass
  272. mbox = QMessageBox.information
  273. title = 'Version check'
  274. msg = 'Your DataLad version is up to date.'
  275. if is_outdated:
  276. mbox = QMessageBox.warning
  277. msg = f'A newer DataLad version {latest} ' \
  278. f'is available (installed: {dlversion}).'
  279. mbox(self.main_window, title, msg)
  280. def _get_issue_template(self):
  281. mbox = QMessageBox.warning
  282. title = 'Oooops'
  283. msg = 'Please report unexpected or faulty behavior to us. File a ' \
  284. 'report with <a href="https://github.com/datalad/datalad-gooey/issues/new?template=issue_template.yml">' \
  285. 'datalad-gooey </a> or with <a href="https://github.com/datalad/datalad-gooey/issues/new?assignees=&labels=gooey&template=issue_template_gooey.yml">' \
  286. 'DataLad</a>'
  287. mbox(self.main_window, title, msg)
  288. def _get_help(self):
  289. mbox = QMessageBox.information
  290. title = 'I need help!'
  291. msg = 'Find resources to learn more or ask questions here: <ul><li>' \
  292. 'About this tool:<a href=http://docs.datalad.org/projects/gooey/en/latest>DataLad Gooey Docs</a> </li>' \
  293. '<li>General DataLad user tutorials: <a href=http://handbook.datalad.org> handbook.datalad.org </a> </li>' \
  294. '<li>Live chat and weekly office hour: <a href="https://matrix.to/#/!NaMjKIhMXhSicFdxAj:matrix.org?via=matrix.waite.eu&via=matrix.org&via=inm7.de">' \
  295. 'Join us on Matrix </li></ul>'
  296. mbox(self.main_window, title, msg)
  297. def _get_info(self):
  298. mbox = QMessageBox.information
  299. title = 'About'
  300. msg = 'DataLad and DataLad Gooey are free and open source software. ' \
  301. 'Read the <a href=https://doi.org/10.21105/joss.03262> paper' \
  302. '</a>, or find out more at <a href=http://datalad.org>' \
  303. 'datalad.org</a>.'
  304. mbox(self.main_window, title, msg)
  305. def _get_diagnostic_info(self):
  306. self.execute_dataladcmd.emit(
  307. 'wtf',
  308. MappingProxyType(dict(
  309. result_renderer='disabled',
  310. on_failure='ignore',
  311. return_type='generator',
  312. )),
  313. MappingProxyType(dict(
  314. preferred_result_interval=0.2,
  315. result_override=dict(
  316. secret_handshake=True,
  317. ),
  318. )),
  319. )
  320. @Slot(Interface, list)
  321. def _app_cmdexec_results_handler(self, cls, res):
  322. if cls != WTF:
  323. return
  324. for r in res:
  325. self._wtf_result_receiver(r)
  326. def _wtf_result_receiver(self, res):
  327. if not res['action'] == 'wtf':
  328. return
  329. if not res.get('secret_handshake'):
  330. return
  331. if res['status'] != 'ok':
  332. msg = "Internal error creating diagnostic information"
  333. else:
  334. msg = "Diagnostic information was copied to clipboard"
  335. infos = _render_report(res)
  336. clipboard = QGuiApplication.clipboard()
  337. clipboard.setText(
  338. f'<details><summary>Diagnostic infos</summary>\n\n'
  339. f'```\n {infos}```\n</details>')
  340. mbox = QMessageBox.information
  341. mbox(self.main_window, 'Diagnostic infos', msg)
  342. def _connect_menu_view(self, menu: QMenu):
  343. for cfgvar, menuname, subject in (
  344. ('datalad.gooey.active-suite', 'menuSuite', 'suite'),
  345. ('datalad.gooey.ui-theme', 'menuTheme', 'theme'),
  346. ):
  347. mode = dlcfg.obtain(cfgvar)
  348. submenu = menu.findChild(QMenu, menuname)
  349. for a in submenu.actions():
  350. a.triggered.connect(self._set_mode_cfg)
  351. a.setData((cfgvar, subject))
  352. if a.objectName().split('_')[-1] == mode:
  353. a.setDisabled(True)
  354. def _set_mode_cfg(self):
  355. # this works for specially crafted actions with names that
  356. # have trailing `_<mode-label>` component in their name
  357. action = self.sender()
  358. cfgvar, subject = action.data()
  359. mode = action.objectName().split('_')[-1]
  360. assert mode
  361. dlcfg.set(cfgvar, mode, scope='global')
  362. QMessageBox.information(
  363. self.main_window, 'Note',
  364. f'The new {subject} is enabled at the next application start.'
  365. )
  366. def _setup_looknfeel(self):
  367. # set application icon
  368. qtapp = QApplication.instance()
  369. qtapp.setWindowIcon(gooey_resources.get_icon('app_icon_32'))
  370. uitheme = dlcfg.obtain('datalad.gooey.ui-theme')
  371. if uitheme not in ('system', 'light', 'dark'):
  372. lgr.warning('Unsupported UI theme label %r', uitheme)
  373. return
  374. if uitheme != 'system':
  375. # go custom, if supported
  376. try:
  377. import qdarktheme
  378. except ImportError:
  379. lgr.warning('Custom UI theme not supported. '
  380. 'Missing `pyqtdarktheme` installation.')
  381. return
  382. qtapp.setStyleSheet(qdarktheme.load_stylesheet(uitheme))
  383. def _setup_suites(self):
  384. # put known suites in menu
  385. suite_menu = self.get_widget('menuSuite')
  386. from datalad.support.entrypoints import iter_entrypoints
  387. for sname, _, suite in iter_entrypoints(
  388. 'datalad.gooey.suites', load=True):
  389. title = suite.get('title')
  390. if not title:
  391. title = sname.capitalize()
  392. description = suite.get('description')
  393. action = QAction(title, parent=suite_menu)
  394. action.setObjectName(f"actionSetGooeySuite_{sname}")
  395. if description:
  396. action.setToolTip(description)
  397. suite_menu.addAction(action)
  398. def main():
  399. qtapp = QApplication(sys.argv)
  400. gooey = GooeyApp()
  401. gooey.main_window.show()
  402. sys.exit(qtapp.exec())