Scheduled service maintenance on November 22


On Friday, November 22, 2024, between 06:00 CET and 18:00 CET, GIN services will undergo planned maintenance. Extended service interruptions should be expected. We will try to keep downtimes to a minimum, but recommend that users avoid critical tasks, large data uploads, or DOI requests during this time.

We apologize for any inconvenience.

app.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401
  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. 'menuUtilities': QMenu,
  64. 'statusbar': QStatusBar,
  65. 'actionCheck_for_new_version': QAction,
  66. 'actionReport_a_problem': QAction,
  67. 'actionAbout': QAction,
  68. 'actionGetHelp': QAction,
  69. 'actionDiagnostic_infos': QAction,
  70. }
  71. execute_dataladcmd = Signal(str, MappingProxyType, MappingProxyType)
  72. configure_dataladcmd = Signal(str, MappingProxyType)
  73. def __init__(self, path: Path = None):
  74. super().__init__()
  75. # bend datalad to our needs
  76. # we cannot handle ANSI coloring
  77. dlcfg.set('datalad.ui.color', 'off', scope='override', force=True)
  78. # prevent any terminal-based interaction of Git
  79. # do it here, not just for command execution to also catch any possible
  80. # ad-hoc Git calls
  81. self._gittermprompt = environ.get('GIT_TERMINAL_PROMPT')
  82. environ['GIT_TERMINAL_PROMPT'] = '0'
  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. self.main_window.actionReport_a_problem.triggered.connect(
  134. self._get_issue_template)
  135. self.main_window.actionGetHelp.triggered.connect(
  136. self._get_help)
  137. self.main_window.actionAbout.triggered.connect(
  138. self._get_info)
  139. self.main_window.actionDiagnostic_infos.triggered.connect(
  140. self._get_diagnostic_info)
  141. # connect the diagnostic WTF helper
  142. self._cmdexec.results_received.connect(
  143. self._app_cmdexec_results_handler)
  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. self._connect_menu_view(self.get_widget('menuView'))
  153. def _setup_ongoing_cmdexec(self, thread_id, cmdname, cmdargs, exec_params):
  154. self.get_widget('statusbar').showMessage(f'Started `{cmdname}`')
  155. self.main_window.setCursor(QCursor(Qt.BusyCursor))
  156. # and give a persistent visual indication of what exactly is happening
  157. # in the log
  158. if cmdname.startswith('gooey_'):
  159. # but not for internal calls
  160. # https://github.com/datalad/datalad-gooey/issues/182
  161. return
  162. self.get_widget('commandLog').appendHtml(
  163. f"<hr>{render_cmd_call(cmdname, cmdargs)}<hr>"
  164. )
  165. def _setup_stopped_cmdexec(
  166. self, thread_id, cmdname, cmdargs, exec_params, ce=None):
  167. if ce is None:
  168. self.get_widget('statusbar').showMessage(f'Finished `{cmdname}`',
  169. timeout=1000)
  170. else:
  171. failed_msg = f"{render_cmd_call(cmdname, cmdargs)} <b>failed!</b>"
  172. # if a command crashes, state it in the statusbar
  173. self.get_widget('statusbar').showMessage(
  174. f'`{cmdname}` failed (see error log for details)')
  175. if not cmdname.startswith('gooey_'):
  176. # leave a brief note in the main log (if this was not a helper
  177. # call)
  178. # this alone would not be enough, because we do not know
  179. # whether the command log is visible
  180. self.get_widget('commandLog').appendHtml(
  181. f"<br>{failed_msg} (see error log for details)"
  182. )
  183. # but also barf the error into the logviewer
  184. lv = self.get_widget('errorLog')
  185. lv.appendHtml(failed_msg)
  186. lv.appendHtml(
  187. f'<font color="red"><pre>{ce.format_standard()}</pre></font>'
  188. )
  189. if not self._cmdexec.n_running:
  190. self.main_window.setCursor(QCursor(Qt.ArrowCursor))
  191. def deinit(self):
  192. dlui.ui.set_backend(self._prev_ui_backend)
  193. # restore any possible term prompt setup
  194. if self._gittermprompt:
  195. environ['GIT_TERMINAL_PROMPT'] = self._gittermprompt
  196. #@cached_property not available for PY3.7
  197. @property
  198. def main_window(self):
  199. if not self._main_window:
  200. self._main_window = load_ui('main_window')
  201. return self._main_window
  202. def get_widget(self, name):
  203. wgt_cls = GooeyApp._main_window_widgets.get(name)
  204. if not wgt_cls:
  205. raise ValueError(f"Unknown widget {name}")
  206. wgt = self.main_window.findChild(wgt_cls, name=name)
  207. if not wgt:
  208. # if this happens, our internal _widgets is out of sync
  209. # with the UI declaration
  210. raise RuntimeError(
  211. f"Could not locate widget {name} ({wgt_cls.__name__})")
  212. return wgt
  213. def _set_root_path(self, path: Path):
  214. """Store the application root path and change PWD to it
  215. Right now this method can only be called once and only before the GUI
  216. is actually up.
  217. """
  218. # TODO we might want to enable *changing* the root dir by calling this
  219. # see https://github.com/datalad/datalad-gooey/issues/130
  220. # for a use case.
  221. # to make this possible, we would need to be able to adjust or reset the
  222. # treeview
  223. chpwd(path)
  224. self._path = path
  225. @property
  226. def rootpath(self):
  227. return self._path
  228. def _populate_dataset_menu(self):
  229. """Private slot to populate connected QMenus with dataset actions"""
  230. from .active_api import dataset_api
  231. add_cmd_actions_to_menu(
  232. self, self._cmdui.configure, dataset_api, self.sender())
  233. # immediately sever the connection to avoid repopulating the menu
  234. # over and over
  235. self.get_widget('menuDataset').aboutToShow.disconnect(
  236. self._populate_dataset_menu)
  237. def _check_new_version(self):
  238. self.get_widget('statusbar').showMessage(
  239. 'Checking latest version', timeout=2000)
  240. try:
  241. is_outdated, latest = check_outdated('datalad', dlversion)
  242. except ValueError:
  243. # thrown when one is in a development version (ie., more
  244. # recent than the most recent release)
  245. is_outdated = False
  246. pass
  247. mbox = QMessageBox.information
  248. title = 'Version check'
  249. msg = 'Your DataLad version is up to date.'
  250. if is_outdated:
  251. mbox = QMessageBox.warning
  252. msg = f'A newer DataLad version {latest} ' \
  253. f'is available (installed: {dlversion}).'
  254. mbox(self.main_window, title, msg)
  255. def _get_issue_template(self):
  256. mbox = QMessageBox.warning
  257. title = 'Oooops'
  258. msg = 'Please report unexpected or faulty behavior to us. File a ' \
  259. 'report with <a href=https://github.com/datalad/datalad-gooey/issues/new?assignees=&labels=&template=issue_template.yml>' \
  260. 'datalad-gooey </a> or with <a href=https://github.com/datalad/datalad-gooey/issues/new?assignees=&labels=&template=issue_template.yml>' \
  261. 'DataLad</a>'
  262. mbox(self.main_window, title, msg)
  263. def _get_help(self):
  264. mbox = QMessageBox.information
  265. title = 'I need help!'
  266. msg = 'Find resources to learn more or ask questions here: <ul><li>' \
  267. 'About this tool:<a href=http://docs.datalad.org/projects/gooey/en/latest>DataLad Gooey Docs</a> </li>' \
  268. '<li>General DataLad user tutorials: <a href=http://handbook.datalad.org> handbook.datalad.org </a> </li>' \
  269. '<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>' \
  270. 'Join us on Matrix </li></ul>'
  271. mbox(self.main_window, title, msg)
  272. def _get_info(self):
  273. mbox = QMessageBox.information
  274. title = 'About'
  275. msg = 'DataLad and DataLad Gooey are free and open source software. ' \
  276. 'Read the <a href=https://doi.org/10.21105/joss.03262> paper' \
  277. '</a>, or find out more at <a href=http://datalad.org>' \
  278. 'datalad.org</a>.'
  279. mbox(self.main_window, title, msg)
  280. def _get_diagnostic_info(self):
  281. self.execute_dataladcmd.emit(
  282. 'wtf',
  283. MappingProxyType(dict(
  284. result_renderer='disabled',
  285. on_failure='ignore',
  286. return_type='generator',
  287. )),
  288. MappingProxyType(dict(
  289. preferred_result_interval=0.2,
  290. result_override=dict(
  291. secret_handshake=True,
  292. ),
  293. )),
  294. )
  295. @Slot(Interface, list)
  296. def _app_cmdexec_results_handler(self, cls, res):
  297. if cls != WTF:
  298. return
  299. for r in res:
  300. self._wtf_result_receiver(r)
  301. def _wtf_result_receiver(self, res):
  302. if not res['action'] == 'wtf':
  303. return
  304. if not res.get('secret_handshake'):
  305. return
  306. if res['status'] != 'ok':
  307. msg = "Internal error creating diagnostic information"
  308. else:
  309. msg = "Diagnostic information was copied to clipboard"
  310. infos = _render_report(res)
  311. clipboard = QGuiApplication.clipboard()
  312. clipboard.setText(
  313. f'<details><summary>Diagnostic infos</summary>\n\n'
  314. f'```\n {infos}```\n</details>')
  315. mbox = QMessageBox.information
  316. mbox(self.main_window, 'Diagnostic infos', msg)
  317. def _connect_menu_view(self, menu: QMenu):
  318. for cfgvar, menuname, subject in (
  319. ('datalad.gooey.ui-mode', 'menuInterface', 'interface mode'),
  320. ('datalad.gooey.ui-theme', 'menuTheme', 'theme'),
  321. ):
  322. mode = dlcfg.obtain(cfgvar)
  323. submenu = menu.findChild(QMenu, menuname)
  324. for a in submenu.actions():
  325. a.triggered.connect(self._set_mode_cfg)
  326. a.setData((cfgvar, subject))
  327. if a.objectName().split('_')[-1] == mode:
  328. a.setDisabled(True)
  329. def _set_mode_cfg(self):
  330. # this works for specially crafted actions with names that
  331. # have trailing `_<mode-label>` component in their name
  332. action = self.sender()
  333. cfgvar, subject = action.data()
  334. mode = action.objectName().split('_')[-1]
  335. dlcfg.set(cfgvar, mode, scope='global')
  336. QMessageBox.information(
  337. self.main_window, 'Note',
  338. f'The new {subject} is enabled at the next application start.'
  339. )
  340. def _setup_looknfeel(self):
  341. # set application icon
  342. qtapp = QApplication.instance()
  343. qtapp.setWindowIcon(gooey_resources.get_icon('app_icon_32'))
  344. uitheme = dlcfg.obtain('datalad.gooey.ui-theme')
  345. if uitheme not in ('system', 'light', 'dark'):
  346. lgr.warning('Unsupported UI theme label %r', uitheme)
  347. return
  348. if uitheme != 'system':
  349. # go custom, if supported
  350. try:
  351. import qdarktheme
  352. except ImportError:
  353. lgr.warning('Custom UI theme not supported. '
  354. 'Missing `pyqtdarktheme` installation.')
  355. return
  356. qtapp.setStyleSheet(qdarktheme.load_stylesheet(uitheme))
  357. def main():
  358. qtapp = QApplication(sys.argv)
  359. gooey = GooeyApp()
  360. gooey.main_window.show()
  361. sys.exit(qtapp.exec())