app.py 12 KB

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