import logging import sys from types import MappingProxyType from os import environ from outdated import check_outdated from pathlib import Path from PySide6.QtWidgets import ( QApplication, QMenu, QPlainTextEdit, QPushButton, QStatusBar, QTabWidget, QTreeWidget, QWidget, QMessageBox, QFileDialog, ) from PySide6.QtCore import ( QObject, Qt, Signal, Slot, ) from PySide6.QtGui import ( QAction, QCursor, QGuiApplication, ) from datalad import cfg as dlcfg from datalad import __version__ as dlversion import datalad.ui as dlui from datalad.interface.base import Interface from datalad.local.wtf import ( _render_report, WTF, ) from datalad.utils import chpwd from .utils import ( load_ui, render_cmd_call, ) from .datalad_ui import GooeyUI from .dataladcmd_exec import GooeyDataladCmdExec from .dataladcmd_ui import GooeyDataladCmdUI from .cmd_actions import add_cmd_actions_to_menu from .fsbrowser import GooeyFilesystemBrowser from .resource_provider import gooey_resources lgr = logging.getLogger('datalad.ext.gooey.app') class GooeyApp(QObject): # Mapping of key widget names used in the main window to their widget # classes. This mapping is used (and needs to be kept up-to-date) to look # up widget (e.g. to connect their signals/slots) _main_window_widgets = { 'contextTabs': QTabWidget, 'cmdTab': QWidget, 'fsBrowser': QTreeWidget, 'commandLog': QPlainTextEdit, 'errorLog': QPlainTextEdit, 'menuDataset': QMenu, 'menuView': QMenu, 'menuSuite': QMenu, 'menuUtilities': QMenu, 'menuHelp': QMenu, 'statusbar': QStatusBar, 'actionCheck_for_new_version': QAction, 'actionReport_a_problem': QAction, 'actionAbout': QAction, 'actionGetHelp': QAction, 'actionDiagnostic_infos': QAction, } execute_dataladcmd = Signal(str, MappingProxyType, MappingProxyType) configure_dataladcmd = Signal(str, MappingProxyType) def __init__(self, path: Path = None): super().__init__() # bend datalad to our needs # we cannot handle ANSI coloring dlcfg.set('datalad.ui.color', 'off', scope='override', force=True) # capture what env vars we modified, None means did not exist self._restore_env = { name: environ.get(name) for name in ( 'GIT_TERMINAL_PROMPT', 'SSH_ASKPASS_REQUIRE', 'SSH_ASKPASS', ) } # prevent any terminal-based interaction of Git # do it here, not just for command execution to also catch any possible # ad-hoc Git calls environ['GIT_TERMINAL_PROMPT'] = '0' # force asking passwords via Gooey # we use SSH* because also Git falls back onto it environ['SSH_ASKPASS_REQUIRE'] = 'force' environ['SSH_ASKPASS'] = 'datalad-gooey-askpass' # setup themeing before the first dialog goes up self._setup_looknfeel() self._dlapi = None self._main_window = None self._cmdexec = GooeyDataladCmdExec() self._cmdui = GooeyDataladCmdUI(self, self.get_widget('cmdTab')) # setup UI self._fsbrowser = GooeyFilesystemBrowser( self, self.get_widget('fsBrowser'), ) # set path for root item and PWD to give relative paths a reference # that makes sense within the app self._set_root_path(path) # remember what backend was in use self._prev_ui_backend = dlui.ui.backend # ask datalad to use our UI # looks silly with the uiuiuiuiui, but these are the real names ;-) dlui.KNOWN_BACKENDS['gooey'] = GooeyUI dlui.ui.set_backend('gooey') uibridge = dlui.ui.ui.set_app(self) self.get_widget('statusbar').addPermanentWidget(uibridge.progress_bar) # connect the generic cmd execution signal to the handler self.execute_dataladcmd.connect(self._cmdexec.execute) # connect the generic cmd configuration signal to the handler self.configure_dataladcmd.connect(self._cmdui.configure) # when a command was configured, pass it to the executor self._cmdui.configured_dataladcmd.connect(self._cmdexec.execute) self.get_widget('statusbar').addPermanentWidget( self._cmdexec.activity_widget) # connect execution handler signals to the setup methods self._cmdexec.execution_started.connect(self._setup_ongoing_cmdexec) self._cmdexec.execution_finished.connect(self._setup_stopped_cmdexec) self._cmdexec.execution_failed.connect(self._setup_stopped_cmdexec) # arrange for the dataset menu to populate itself lazily once # necessary self.get_widget('menuDataset').aboutToShow.connect( self._populate_dataset_menu) self.main_window.actionSetBaseDirectory.triggered.connect( self._set_root_path) self.main_window.actionCheck_for_new_version.triggered.connect( self._check_new_version) self.main_window.actionReport_a_problem.triggered.connect( self._get_issue_template) self.main_window.actionGetHelp.triggered.connect( self._get_help) self.main_window.actionAbout.triggered.connect( self._get_info) self.main_window.actionDiagnostic_infos.triggered.connect( self._get_diagnostic_info) # connect the diagnostic WTF helper self._cmdexec.results_received.connect( self._app_cmdexec_results_handler) # reset the command configuration tab whenever the item selection in # tree view changed. # This behavior was originally requested in # https://github.com/datalad/datalad-gooey/issues/57 # but proved to be undesirabled soon after # https://github.com/datalad/datalad-gooey/issues/105 #self._fsbrowser._tree.currentItemChanged.connect( # lambda cur, prev: self._cmdui.reset_form()) # TODO could be done lazily to save in entrypoint iteration self._setup_suites() self._connect_menu_view(self.get_widget('menuView')) def _setup_ongoing_cmdexec(self, thread_id, cmdname, cmdargs, exec_params): self.get_widget('statusbar').showMessage(f'Started `{cmdname}`') self.main_window.setCursor(QCursor(Qt.BusyCursor)) # and give a persistent visual indication of what exactly is happening # in the log if cmdname.startswith('gooey_'): # but not for internal calls # https://github.com/datalad/datalad-gooey/issues/182 return self.get_widget('commandLog').appendHtml( f"
{ce.format_standard()}' ) if not self._cmdexec.n_running: self.main_window.setCursor(QCursor(Qt.ArrowCursor)) def deinit(self): dlui.ui.set_backend(self._prev_ui_backend) # restore any possible term prompt setup for var, val in self._restore_env.items(): if val is not None: environ[var] = val #@cached_property not available for PY3.7 @property def main_window(self): if not self._main_window: self._main_window = load_ui('main_window') return self._main_window def get_widget(self, name): wgt_cls = GooeyApp._main_window_widgets.get(name) if not wgt_cls: raise ValueError(f"Unknown widget {name}") wgt = self.main_window.findChild(wgt_cls, name=name) if not wgt: # if this happens, our internal _widgets is out of sync # with the UI declaration raise RuntimeError( f"Could not locate widget {name} ({wgt_cls.__name__})") return wgt def _set_root_path(self, path: Path): """Store the application root path and change PWD to it Right now this method can only be called once and only before the GUI is actually up. """ # TODO we might want to enable *changing* the root dir by calling this # see https://github.com/datalad/datalad-gooey/issues/130 # for a use case. # to make this possible, we would need to be able to adjust or reset the # treeview if not path: # first check if this was called as a slot action = self.sender() if action is not None: path = action.data() if not path: # start root path still not given, ask user path = QFileDialog.getExistingDirectory( caption="Select a base directory for DataLad", options=QFileDialog.ShowDirsOnly, ) if not path: # user aborted root path selection, start in HOME. # HOME is a better choice than CWD in most environments path = Path.home() chpwd(path) self._path = path # (re)init the browser self._fsbrowser.set_root(path) @property def rootpath(self): return self._path def _populate_dataset_menu(self): """Private slot to populate connected QMenus with dataset actions""" from .active_suite import dataset_api add_cmd_actions_to_menu( self, self._cmdui.configure, dataset_api, self.sender()) # immediately sever the connection to avoid repopulating the menu # over and over self.get_widget('menuDataset').aboutToShow.disconnect( self._populate_dataset_menu) def _check_new_version(self): self.get_widget('statusbar').showMessage( 'Checking latest version', timeout=2000) try: is_outdated, latest = check_outdated('datalad', dlversion) except ValueError: # thrown when one is in a development version (ie., more # recent than the most recent release) is_outdated = False pass mbox = QMessageBox.information title = 'Version check' msg = 'Your DataLad version is up to date.' if is_outdated: mbox = QMessageBox.warning msg = f'A newer DataLad version {latest} ' \ f'is available (installed: {dlversion}).' mbox(self.main_window, title, msg) def _get_issue_template(self): mbox = QMessageBox.warning title = 'Oooops' msg = 'Please report unexpected or faulty behavior to us. File a ' \ 'report with ' \ 'datalad-gooey or with ' \ 'DataLad' mbox(self.main_window, title, msg) def _get_help(self): mbox = QMessageBox.information title = 'I need help!' msg = 'Find resources to learn more or ask questions here: