app.py 17 KB

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