Browse Source

Extensible suites

This previous "ui-mode" is replaced by an entrypoint specification
driven mechanism that enables extensions to provide their own
modes (command suites) for Gooey.

They are picked up at startup. The "Suites" menu is populated
automatically. The configuration mechanism stay the same, but the
variable is now `datalad.gooey.active-suite`.

Gooey itsef provides its two suites via the entrypoint mechanism,
so we can know that it works.

New features of suites (vs previous UI modes):

- define their own titles
- define their own descriptions
- can customize a single command differently depending on the API
  context (dataset, directory, file, annexed-file)

The last feature is the most significant one (demo'ed with `get()`).

There are some TODO for later still:

- The descriptions don't show. Menu actions do not show their tooltips.
  Only in toolbars and elsewhere.
- The PathParamWidget directly queries the config variable to behave
  differently in simple mode. This should become a suite setting
  that could be used by others too.

Closes datalad/datalad-gooey#200
Michael Hanke 1 year ago
parent
commit
a29477902e

+ 15 - 6
datalad_gooey/__init__.py

@@ -22,16 +22,25 @@ command_suite = (
 )
 
 from datalad.support.extensions import register_config
-from datalad.support.constraints import EnsureChoice
+from datalad.support.constraints import (
+    EnsureChoice,
+    EnsureStr,
+)
 register_config(
-    'datalad.gooey.ui-mode',
-    'Which user interface mode to use in the application',
+    'datalad.gooey.active-suite',
+    'Which user interface suite to use in the application',
     description=\
-    "In 'simplified' mode advanced operations operations are hidden "
+    "A suite is a particular set of commands that is available through "
+    "the application. The command interface can be customized, such that "
+    "different features and different levels of complexity can be exposed "
+    "for the same command in different suites. "
+    "Two standard suites are provided, but extension package may provide "
+    "additional suites that can be configured. "
+    "In the 'simplified' suite advanced operations operations are hidden "
     "in the user interface. In 'complete' mode, all functionality "
     'is exposed.',
-    type=EnsureChoice('simplified', 'complete'),
-    default='simplified',
+    type=EnsureStr() | EnsureChoice('gooey-simplified', 'gooey-complete'),
+    default='gooey-simplified',
     scope='global')
 register_config(
     'datalad.gooey.ui-theme',

+ 0 - 63
datalad_gooey/active_api.py

@@ -1,63 +0,0 @@
-from datalad import cfg
-
-#
-# API specifications
-#
-# superset of all API scopes, full set of all supported commands
-# the order in this dict (and generally also those below) defines
-# the order in which commands appear in the respective places
-# the API is listed
-api = None
-# commands that operate on datasets
-dataset_api = None
-# commands that operate on any directory
-directory_api = None
-# commands that operate on directories in datasets
-directory_in_ds_api = None
-# commands that operate on any file
-file_api = None
-# commands that operate on any file in a dataset
-file_in_ds_api = None
-# command that operate on annex'ed files
-annexed_file_api = None
-
-# names of parameters to exclude for any command
-exclude_parameters = set()
-
-# mapping of parameter names to display names
-# to be applied across all commands
-parameter_display_names = {}
-
-# mapping of group name/title to sort index
-api_group_order = {}
-
-
-ui_mode = cfg.obtain('datalad.gooey.ui-mode')
-if ui_mode == 'simplified':
-    from .simplified_api import (
-        api,
-        dataset_api,
-        directory_api,
-        directory_in_ds_api,
-        file_api,
-        file_in_ds_api,
-        annexed_file_api,
-        exclude_parameters,
-        parameter_display_names,
-        api_group_order,
-    )
-elif ui_mode == 'complete':
-    from .complete_api import (
-        api,
-        dataset_api,
-        directory_api,
-        directory_in_ds_api,
-        file_api,
-        file_in_ds_api,
-        annexed_file_api,
-        exclude_parameters,
-        parameter_display_names,
-        api_group_order,
-    )
-else:
-    raise NotImplementedError

+ 58 - 0
datalad_gooey/active_suite.py

@@ -0,0 +1,58 @@
+from types import MappingProxyType
+from datalad import cfg
+
+spec = None
+# names of parameters to exclude for any command
+# exclude_parameters = set()
+
+# mapping of parameter names to display names
+# to be applied across all commands
+# parameter_display_names = {}
+
+# mapping of group name/title to sort index
+# api_group_order = {}
+
+#
+# API specifications
+#
+# commands that operate on datasets
+dataset_api = None
+# commands that operate on any directory
+directory_api = None
+# commands that operate on directories in datasets
+directory_in_ds_api = None
+# commands that operate on any file
+file_api = None
+# commands that operate on any file in a dataset
+file_in_ds_api = None
+# command that operate on annex'ed files
+annexed_file_api = None
+# commands that have no specific target type, or another than
+# dataset, dir, file etc from above
+other_api = None
+
+
+active_suite = cfg.obtain('datalad.gooey.active-suite')
+
+from datalad.support.entrypoints import iter_entrypoints
+for sname, _, sload in iter_entrypoints(
+        'datalad.gooey.suites', load=False):
+    if sname != active_suite:
+        continue
+
+    # deposit the spec in read-only form
+    spec = MappingProxyType(sload())
+
+    # deploy convenience importable symbols
+    for apiname, api in spec.get('apis', {}).items():
+        globals()[f"{apiname}_api"] = api
+
+if spec is None:
+    raise RuntimeError(
+        f'No active Gooey suite {active_suite!r}! Imploding...')
+
+    api = dict()
+    print(active_suite)
+    for a in active_suite.get('apis', {}).values():
+        if a:
+            api.update(a)

+ 22 - 2
datalad_gooey/app.py

@@ -57,6 +57,7 @@ class GooeyApp(QObject):
         'errorLog': QPlainTextEdit,
         'menuDataset': QMenu,
         'menuView': QMenu,
+        'menuSuite': QMenu,
         'menuUtilities': QMenu,
         'statusbar': QStatusBar,
         'actionCheck_for_new_version': QAction,
@@ -155,6 +156,8 @@ class GooeyApp(QObject):
         #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):
@@ -243,7 +246,7 @@ class GooeyApp(QObject):
 
     def _populate_dataset_menu(self):
         """Private slot to populate connected QMenus with dataset actions"""
-        from .active_api import dataset_api
+        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
@@ -272,7 +275,7 @@ class GooeyApp(QObject):
 
     def _connect_menu_view(self, menu: QMenu):
         for cfgvar, menuname, subject in (
-                ('datalad.gooey.ui-mode', 'menuInterface', 'interface mode'),
+                ('datalad.gooey.active-suite', 'menuSuite', 'suite'),
                 ('datalad.gooey.ui-theme', 'menuTheme', 'theme'),
         ):
             mode = dlcfg.obtain(cfgvar)
@@ -289,6 +292,7 @@ class GooeyApp(QObject):
         action = self.sender()
         cfgvar, subject = action.data()
         mode = action.objectName().split('_')[-1]
+        assert mode
         dlcfg.set(cfgvar, mode, scope='global')
         QMessageBox.information(
             self.main_window, 'Note',
@@ -314,6 +318,22 @@ class GooeyApp(QObject):
                 return
             qtapp.setStyleSheet(qdarktheme.load_stylesheet(uitheme))
 
+    def _setup_suites(self):
+        # put known suites in menu
+        suite_menu = self.get_widget('menuSuite')
+        from datalad.support.entrypoints import iter_entrypoints
+        for sname, _, suite in iter_entrypoints(
+                'datalad.gooey.suites', load=True):
+            title = suite.get('title')
+            if not title:
+                title = sname.capitalize()
+            description = suite.get('description')
+            action = QAction(title, parent=suite_menu)
+            action.setObjectName(f"actionSetGooeySuite_{sname}")
+            if description:
+                action.setToolTip(description)
+            suite_menu.addAction(action)
+
 
 def main():
     qtapp = QApplication(sys.argv)

+ 4 - 5
datalad_gooey/cmd_actions.py

@@ -1,9 +1,7 @@
 from PySide6.QtGui import QAction
 from PySide6.QtWidgets import QMenu
 
-from .active_api import (
-    api_group_order,
-)
+from .active_suite import spec as active_suite
 
 
 def add_cmd_actions_to_menu(parent, receiver, api, menu=None, cmdkwargs=None):
@@ -38,7 +36,7 @@ def add_cmd_actions_to_menu(parent, receiver, api, menu=None, cmdkwargs=None):
         # the name of the command is injected into the action
         # as user data. We wrap it in a dict to enable future
         # additional payload
-        adata = dict(__cmd_name__=cmdname)
+        adata = dict(__cmd_name__=cmdname, __api__=api)
         # put on record, if we are generating actions for a specific
         # dataset
         if cmdkwargs is not None:
@@ -61,7 +59,8 @@ def add_cmd_actions_to_menu(parent, receiver, api, menu=None, cmdkwargs=None):
     for group, submenu in sorted(
             submenus.items(),
             # sort items with no sorting indicator last
-            key=lambda x: api_group_order.get(x[0], ('zzzz'))):
+            key=lambda x: active_suite.get('api_group_order', {}).get(
+                x[0], ('zzzz'))):
         # skip menus without actions
         if not submenu.actions():
             continue

+ 28 - 28
datalad_gooey/complete_api.py

@@ -84,31 +84,31 @@ dataset_api = {
     if name in api
 }
 
-# commands that operate on any directory
-directory_api = api
-# commands that operate on directories in datasets
-directory_in_ds_api = api
-# commands that operate on any file
-file_api = api
-# commands that operate on any file in a dataset
-file_in_ds_api = api
-# command that operate on annex'ed files
-annexed_file_api = api
-
-# these generic parameters never make sense
-exclude_parameters = set((
-    # cmd execution wants a generator
-    'return_type',
-    # could be useful internally, but a user cannot chain commands
-    'result_filter',
-    # we cannot deal with non-dict results, and override any transform
-    'result_xfm',
-))
-
-# generic name overrides
-parameter_display_names = {}
-
-# mapping of group name/title to sort index
-api_group_order = {
-    spec[1]: spec[0] for spec in get_interface_groups()
-}
+gooey_suite = dict(
+    title='Complete',
+    description='Generic access to all command available in this DataLad installation',
+    apis=dict(
+        dataset=dataset_api,
+        directory=api,
+        directory_in_ds=api,
+        file=api,
+        file_in_ds=api,
+        annexed_file=api,
+        other=api,
+    ),
+    # mapping of group name/title to sort index
+    api_group_order={
+        spec[1]: spec[0] for spec in get_interface_groups()
+    },
+    # these generic parameters never make sense
+    exclude_parameters=set((
+        # cmd execution wants a generator
+        'return_type',
+        # could be useful internally, but a user cannot chain commands
+        'result_filter',
+        # we cannot deal with non-dict results, and override any transform
+        'result_xfm',
+    )),
+    # generic name overrides
+    parameter_display_names={},
+)

+ 3 - 0
datalad_gooey/constraints.py

@@ -31,3 +31,6 @@ class EnsureExistingDirectory(Constraint):
             raise ValueError(
                 f"{value} is not an existing directory")
         return value
+
+    def short_description(self):
+        return 'existing directory'

+ 9 - 4
datalad_gooey/dataladcmd_ui.py

@@ -18,7 +18,7 @@ from PySide6.QtWidgets import (
 
 from .param_form_utils import populate_form_w_params
 from .api_utils import get_cmd_displayname
-from .active_api import api
+from .active_suite import spec as active_suite
 
 
 class GooeyDataladCmdUI(QObject):
@@ -63,6 +63,7 @@ class GooeyDataladCmdUI(QObject):
     @Slot(str, dict)
     def configure(
             self,
+            api=None,
             cmdname: str = None,
             cmdkwargs: Dict or None = None):
         if cmdkwargs is None:
@@ -73,14 +74,17 @@ class GooeyDataladCmdUI(QObject):
         # we can use this to update the method parameter values
         # with information from menu-items, or tree nodes clicked
         sender = self.sender()
-        if sender is not None:
-            if cmdname is None and isinstance(sender, QAction):
+        if sender is not None and isinstance(sender, QAction):
+            if api is None:
+                api = sender.data().get('__api__')
+            if cmdname is None:
                 cmdname = sender.data().get('__cmd_name__')
                 # pull in any signal-provided kwargs for the command
                 # unless they have been also specified directly to the method
                 cmdkwargs = {
                     k: v for k, v in sender.data().items()
-                    if k != '__cmd_name__' and k not in cmdkwargs
+                    if k not in ('__cmd_name__', '__api')
+                    and k not in cmdkwargs
                 }
 
         assert cmdname is not None, \
@@ -90,6 +94,7 @@ class GooeyDataladCmdUI(QObject):
 
         self.reset_form()
         populate_form_w_params(
+            api,
             self._app.rootpath,
             self.pform,
             cmdname,

+ 6 - 6
datalad_gooey/fsbrowser.py

@@ -402,7 +402,7 @@ class GooeyFilesystemBrowser(QObject):
         cmdkwargs = dict()
         context = QMenu(parent=self._tree)
         if path_type == 'dataset':
-            from .active_api import dataset_api as cmdapi
+            from .active_suite import dataset_api as cmdapi
             submenu = context.addMenu('Dataset commands')
             cmdkwargs['dataset'] = ipath
         elif path_type == 'directory':
@@ -410,23 +410,23 @@ class GooeyFilesystemBrowser(QObject):
             # path the directory path to the command's `path` argument
             cmdkwargs['path'] = ipath
             if dsroot:
-                from .active_api import directory_in_ds_api as cmdapi
+                from .active_suite import directory_in_ds_api as cmdapi
                 # also pass dsroot
                 cmdkwargs['dataset'] = dsroot
             else:
-                from .active_api import directory_api as cmdapi
+                from .active_suite import directory_api as cmdapi
             submenu = context.addMenu('Directory commands')
         elif path_type in ('file', 'symlink', 'annexed-file'):
             dsroot = get_dataset_root(ipath)
             cmdkwargs['path'] = ipath
             if dsroot:
                 if path_type == 'annexed-file':
-                    from .active_api import annexed_file_api as cmdapi
+                    from .active_suite import annexed_file_api as cmdapi
                 else:
-                    from .active_api import file_in_ds_api as cmdapi
+                    from .active_suite import file_in_ds_api as cmdapi
                 cmdkwargs['dataset'] = dsroot
             else:
-                from .active_api import file_api as cmdapi
+                from .active_suite import file_api as cmdapi
             submenu = context.addMenu('File commands')
         # TODO context menu for annex'ed files
 

+ 4 - 7
datalad_gooey/param_form_utils.py

@@ -26,11 +26,7 @@ from datalad.utils import (
 
 from . import param_widgets as pw
 from .param_multival_widget import MultiValueInputWidget
-from .active_api import (
-    api,
-    exclude_parameters,
-    parameter_display_names,
-)
+from .active_suite import spec as active_suite
 from .api_utils import get_cmd_params
 from .utils import _NoValue
 from .constraints import (
@@ -42,6 +38,7 @@ __all__ = ['populate_form_w_params']
 
 
 def populate_form_w_params(
+        api,
         basedir: Path,
         formlayout: QFormLayout,
         cmdname: str,
@@ -83,7 +80,7 @@ def populate_form_w_params(
                 cmd_api_spec.get(
                     'parameter_order', {}).get(x[0], 99),
                 x[0])):
-        if pname in exclude_parameters:
+        if pname in active_suite.get('exclude_parameters', []):
             continue
         if pname in cmd_api_spec.get('exclude_parameters', []):
             continue
@@ -107,7 +104,7 @@ def populate_form_w_params(
         display_name = cmd_param_display_names.get(
             pname,
             # fallback to API specific override
-            parameter_display_names.get(
+            active_suite.get('parameter_display_names', {}).get(
                 pname,
                 # last resort:
                 # use capitalized orginal with _ removed as default

+ 2 - 1
datalad_gooey/param_widgets.py

@@ -298,7 +298,8 @@ class PathParamWidget(QWidget, GooeyParamWidgetMixin):
 
         # the main widget is a simple line edit
         self._edit = QLineEdit(self)
-        if dlcfg.obtain('datalad.gooey.ui-mode') == 'simplified':
+        # TODO this must be configurable in the suite specification
+        if dlcfg.obtain('datalad.gooey.active-suite') == 'gooey-simplified':
             # in simplified mode we do not allow manual entry of paths
             # to avoid confusions re interpretation of relative paths
             # https://github.com/datalad/datalad-gooey/issues/106

+ 3 - 25
datalad_gooey/resources/ui/main_window.ui

@@ -235,12 +235,10 @@
     <property name="title">
      <string>&amp;View</string>
     </property>
-    <widget class="QMenu" name="menuInterface">
+    <widget class="QMenu" name="menuSuite">
      <property name="title">
-      <string>&amp;Interface mode</string>
+      <string>&amp;Suite</string>
      </property>
-     <addaction name="actionInterfaceMode_simplified"/>
-     <addaction name="actionInterfaceMode_complete"/>
     </widget>
     <widget class="QMenu" name="menuTheme">
      <property name="title">
@@ -250,7 +248,7 @@
      <addaction name="actionViewTheme_light"/>
      <addaction name="actionViewTheme_dark"/>
     </widget>
-    <addaction name="menuInterface"/>
+    <addaction name="menuSuite"/>
     <addaction name="menuTheme"/>
    </widget>
    <addaction name="menuFile"/>
@@ -267,31 +265,11 @@
     <string>Check for new version</string>
    </property>
   </action>
-  <action name="actionRun_stuff">
-   <property name="text">
-    <string>Run stuff</string>
-   </property>
-  </action>
-  <action name="actionConfigure_stuff">
-   <property name="text">
-    <string>Configure stuff</string>
-   </property>
-  </action>
   <action name="action_Quit">
    <property name="text">
     <string>&amp;Quit</string>
    </property>
   </action>
-  <action name="actionInterfaceMode_simplified">
-   <property name="text">
-    <string>Simplified</string>
-   </property>
-  </action>
-  <action name="actionInterfaceMode_complete">
-   <property name="text">
-    <string>Complete</string>
-   </property>
-  </action>
   <action name="actionViewTheme_system">
    <property name="text">
     <string>System</string>

+ 34 - 18
datalad_gooey/simplified_api.py

@@ -202,22 +202,6 @@ api = dict(
     ),
 )
 
-exclude_parameters = set((
-    'result_renderer',
-    'return_type',
-    'result_filter',
-    'result_xfm',
-    'on_failure',
-    'jobs',
-    'recursion_limit',
-))
-
-parameter_display_names = dict(
-    annex='Dataset with file annex',
-    cfg_proc='Configuration procedure(s)',
-    dataset='Dataset location',
-)
-
 dataset_api = {
     c: s for c, s in api.items()
     if c in (
@@ -242,6 +226,38 @@ annexed_file_api = {
     c: s for c, s in api.items()
     if c in ('drop', 'get', 'push', 'save')
 }
+# get of a single annexed files can be simpler
+from copy import deepcopy
+annexed_file_get = deepcopy(annexed_file_api['get'])
+annexed_file_get['exclude_parameters'].add('recursive')
+annexed_file_api['get'] = annexed_file_get
+
 
-# simplified API has no groups
-api_group_order = {}
+gooey_suite = dict(
+    title='Essential',
+    description='Simplified access to the most essential operations',
+    apis=dict(
+        dataset=dataset_api,
+        directory=directory_api,
+        directory_in_ds=directory_in_ds_api,
+        file=file_api,
+        file_in_ds=file_in_ds_api,
+        annexed_file=annexed_file_api,
+    ),
+    # simplified API has no groups
+    api_group_order={},
+    exclude_parameters=set((
+        'result_renderer',
+        'return_type',
+        'result_filter',
+        'result_xfm',
+        'on_failure',
+        'jobs',
+        'recursion_limit',
+    )),
+    parameter_display_names=dict(
+        annex='Dataset with file annex',
+        cfg_proc='Configuration procedure(s)',
+        dataset='Dataset location',
+    ),
+)

+ 3 - 0
setup.cfg

@@ -40,6 +40,9 @@ datalad.extensions =
     # the entrypoint can point to any symbol of any name, as long it is
     # valid datalad interface specification (see demo in this extensions)
     gooey = datalad_gooey:command_suite
+datalad.gooey.suites =
+    gooey-simplified = datalad_gooey.simplified_api:gooey_suite
+    gooey-complete = datalad_gooey.complete_api:gooey_suite
 # install the GUI starter as a direct entrypoint to avoid the datalad CLI
 # overhead
 gui_scripts =