app.py 15 KB

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