app.py 13 KB

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