Source code for fsleyes.plugins

#
# __init__.py - Accessing installed FSLeyes plugins.
#
# Author: Paul McCarthy <pauldmccarthy@gmail.com>
#
"""This package provides access to installed and built-in FSLeyes plugins.


FSLeyes uses a simple plugin architecture for loading custom views, controls,
and tools. Plugins can be installed from Python libraries (e.g. as hosted on
`PyPi <https://pypi.org/>`_), or installed directly from a ``.py`` file.


In both cases, FSLeyes uses ``setuptools`` `entry points
<https://setuptools.readthedocs.io/en/latest/pkg_resources.html#entry-points>`__
to locate the items provided by plugin library/files.


Things plugins can provide
--------------------------


FSLeyes plugins can provide custom *views*, *controls* and *tools*:

 - A *view* is a top level panel, such as an :class:`.OrthoPanel`,
   :class:`.Scene3DPanel`, or :class:`.TimeSeriesPanel`. Views provided
   by plugins are added to the top level *Views* menu.

 - A *control* is a secondary panel, or toolbar, which is embedded within a
   view, such as an :class:`.OverlayListPanel`, :class:`.OrthoToolBar`, or
   :class:`.MelodicClassificationPanel`. Controls provided by plugins are
   added to the *Settings* menu for each active view.

 - A *tool* is an :class:`.Action` which is associated with a menu item under
   the top-level *Tools* menu, such as the :class:`.ApplyFlirtXfmAction`, the
   :class:`.CropImageAction`, and the :class:`.ResampleAction`.


Loading/installing FSLeyes plugins
----------------------------------


FSLeyes plugins are loaded into a running FSLeyes as follows:

 - Any Python libraries (e.g. installed from ``PyPi``) which are present the
   environment that FSLeyes is running in, and which have a name beginning
   with ``fsleyes-plugin-`` will automatically be detected by FSLeyes.

 - Plugin ``.py`` files, which contain view, control, and/or tool definitions,
   can be passed directly to the :func:`loadPlugin` function.

 - Plugin ``.py`` files which are present in the FSLeyes settings directory,
   or which are found in the ``FSLEYES_PLUGIN_PATH`` environment variable, will
   be loaded by the :func:`initialise` function.

 - Built-in plugins located within the ``fsleyes.plugins`` package.


A plugin can be installed permanently into FSLeyes as follows:


 - Any Python libraries (e.g. installed from ``PyPi``) which are present the
   environment that FSLeyes is running in, and which have a name beginning
   with ``fsleyes-plugin-`` will automatically be detected by FSLeyes.

 - ``.py`` plugin files can be passed to the :func:`installPlugin`
   function. This file will be saved into the FSLeyes settings directory
   (e.g. ``~/.fsleyes/plugins/``).


Writing a FSLeyes plugin
------------------------


.. note:: A minimal example of a FSLeyes plugin library can be found in
          ``tests/testdata/fsleyes_plugin_example/``, and a range of
          built-in plugins can be found in ``fsleyes/plugins/``.


.. warning:: FSLeyes assumes that all views, controls, and tools have unique
             class names.  So expect problems if, for example, you define your
             own FSLeyes control with the name ``OverlayListPanel``.


A FSLeyes plugin is a Python library, or a ``.py`` file, which contains
definitions for custom views, controls, and tools.

 - Views must be sub-classes of the :class:`.ViewPanel` class.

 - Controls must be sub-classes of the :class:`.ControlPanel` or
   :class:`.ControlToolBar` classes.

 - Tools must be sub-classes of the :class:`.Action` class.


To write a ``.py`` file which can be loaded as a FSLeyes plugin, simply
define your views, controls, and tools in the file. The file path can then
be passed to the :func:`loadPlugin` or :func:`installPlugin` function.


To release a FSLeyes plugin as a library, you need to organise your code
as a Python library. Minimally, this requires the following:

 - Arrange your ``.py`` file(s) into a Python package.

 - Write a ``setup.py`` file.

 - Give your library a name (the ``name`` argument to the ``setup``
   function) which begins with ``fsleyes-plugin-``.

 - Expose your custom views, controls, and tools as `entry points
   <https://packaging.python.org/specifications/entry-points/>`__ (the
   ``entry_points`` argument to the ``setup`` function).


A minimal ``setup.py`` file for a FSLeyes plugin might look like this::

    import setuptools

    setup(
        # the name must begin with "fsleyes-plugin-"
        name='fsleyes-plugin-my-cool-plugin',

        # Views, controls, and tools must be exposed
        # as entry points within groups called
        # "fsleyes_views", "fsleyes_controls" and
        # "fsleyes_tools" respectively.
        entry_points={
            'fsleyes_views' : [
                'My cool view = myplugin:MyView'
            ]
            'fsleyes_controls' : [
                'My cool control = myplugin:MyControl'
            ]
            'fsleyes_tools' : [
                'My cool tool = myplugin:MyTool'
            ]
        }
    )


See the `Python Packaging guide
<https://packaging.python.org/tutorials/packaging-projects/>`_ for more
details on writing a ``setup.py`` file.


Module contents
---------------


The following functions can be used to load/install new plugins:

.. autosummary::
   :nosignatures:

   initialise
   loadPlugin
   installPlugin


The following functions can be used to access plugins:

.. autosummary::
   :nosignatures:

   listPlugins
   listViews
   listControls
   listTools
   lookupView
   lookupControl
   lookupTool
   pluginTitle
"""


import os.path        as op
import                   os
import                   sys
import                   glob
import                   pkgutil
import                   logging
import                   importlib
import importlib.util as imputil
import                   collections
import                   pkg_resources

from typing import List, Dict, Union, Type, Optional
from types  import ModuleType

import fsl.utils.settings            as fslsettings
import fsleyes.actions               as actions
import fsleyes.strings               as strings
import fsleyes.views.viewpanel       as viewpanel
import fsleyes.views.canvaspanel     as canvaspanel
import fsleyes.controls.controlpanel as ctrlpanel


log = logging.getLogger(__name__)

View    = Type[viewpanel.ViewPanel]
Control = Type[ctrlpanel.ControlPanel]
Tool    = Type[actions.Action]
Plugin  = Union[View, Control, Tool]


[docs]def class_defines_method(cls, methname): """Check to see whether ``methname`` is implemented on ``cls``, and not on a base-class. :meth:`.Action.ignoreTool`, :meth:`.ControlMixin.ignoreControl`, and :meth:`.ControlMixin.supportSubClasses` need to be implemented on the specific class - inherited base class implementations are not considered. """ return methname in cls.__dict__
[docs]def initialise(): """Loads all plugins, including built-ins, plugin files in the FSLeyes settings directory, and those found on the ``FSLEYES_PLUGIN_PATH`` environment variable. """ _loadBuiltIns() # plugins in fsleyes settings dir pluginFiles = list(fslsettings.listFiles('plugins/*.py')) pluginFiles = [fslsettings.filePath(p) for p in pluginFiles] # plugins on path fpp = os.environ.get('FSLEYES_PLUGIN_PATH', None) if fpp is not None: for dirname in fpp.split(op.pathsep): pluginFiles.extend(glob.glob(op.join(dirname, '*.py'))) for fname in pluginFiles: try: loadPlugin(fname) except Exception as e: log.warning('Failed to load plugin file %s: %s', fname, e)
[docs]def _pluginGroup(cls : Plugin) -> Optional[str]: """Returns the type/group of the given plugin, one of ``'views'``, ``'controls'``, or ``'tools'``. """ if issubclass(cls, viewpanel.ViewPanel): return 'views' elif issubclass(cls, ctrlpanel.ControlPanel): return 'controls' elif issubclass(cls, ctrlpanel.ControlToolBar): return 'controls' elif issubclass(cls, actions.Action): return 'tools' return None
[docs]def _loadBuiltIns(): """Called by :func:`initialise`. Loads all bulit-in plugins, from sub-modules of the ``fsleyes.plugins`` directory. """ import fsleyes.plugins.views as views import fsleyes.controls as controls import fsleyes.plugins.controls as pcontrols import fsleyes.plugins.tools as tools for mod in (views, controls, pcontrols, tools): submods = pkgutil.iter_modules(mod.__path__, mod.__name__ + '.') for _, name, _ in submods: log.debug('Loading built-in plugin module %s', name) mod = importlib.import_module(name) name = name.split('.')[-1] _registerEntryPoints(name, mod, False)
[docs]def listPlugins() -> List[str]: """Returns a list containing the names of all installed FSLeyes plugins. """ plugins = [] for dist in pkg_resources.working_set: if dist.project_name.startswith('fsleyes-plugin-'): plugins.append(dist.project_name) return list(sorted(plugins))
[docs]def _listEntryPoints(group : str) -> Dict[str, Plugin]: """Returns a dictionary containing ``{name : type}`` entry points for the given entry point group. https://setuptools.readthedocs.io/en/latest/pkg_resources.html#entry-points """ items = collections.OrderedDict() for plugin in listPlugins(): for name, ep in pkg_resources.get_entry_map(plugin, group).items(): if name in items: log.debug('Overriding entry point %s [%s] with entry point ' 'of the same name from %s', name, group, plugin) items[name] = ep.load() return items
[docs]def listViews() -> Dict[str, View]: """Returns a dictionary of ``{name : ViewPanel}`` mappings containing the custom views provided by all installed FSLeyes plugins. """ views = _listEntryPoints('fsleyes_views') for name, cls in list(views.items()): if not issubclass(cls, viewpanel.ViewPanel): log.debug('Ignoring fsleyes_views entry point ' '{} - not a ViewPanel'.format(name)) views.pop(name) continue return views
[docs]def listControls(viewType : Optional[View] = None) -> Dict[str, Control]: """Returns a dictionary of ``{name : ControlPanel}`` mappings containing the custom controls provided by all installed FSLeyes plugins. :arg viewType: Sub-class of :class:`.ViewPanel` - if provided, only controls which are compatible with this view type are returned (as determined by :meth:`.ControlMixin.supportedViews.`). """ ctrls = _listEntryPoints('fsleyes_controls') for name, cls in list(ctrls.items()): if not issubclass(cls, (ctrlpanel.ControlPanel, ctrlpanel.ControlToolBar)): log.debug('Ignoring fsleyes_controls entry point {} - ' 'not a ControlPanel/ToolBar'.format(name)) ctrls.pop(name) continue # views that this control supports - might be None, # in which case the control is assumed to support # all views. supported = cls.supportedViews() # does the control support sub-classes of the # views that it supports, or only the specific # view classes returned by supportedViews? subclassok = True if class_defines_method(cls, 'supportSubClasses'): subclassok = cls.supportSubClasses() if viewType is not None and \ supported is not None: if subclassok: if not issubclass(viewType, tuple(supported)): ctrls.pop(name) elif viewType not in supported: ctrls.pop(name) return ctrls
[docs]def listTools(viewType : Optional[View] = None) -> Dict[str, Tool]: """Returns a dictionary of ``{name : Action}`` mappings containing the custom tools provided by all installed FSLeyes plugins. :arg viewType: Sub-class of :class:`.ViewPanel` - if provided, only tools which are compatible with this view type are returned (as determined by :meth:`.Action.supportedViews.`). """ tools = _listEntryPoints('fsleyes_tools') for name, cls in list(tools.items()): if not issubclass(cls, actions.Action): log.debug('Ignoring fsleyes_tools entry point ' '{} - not an Action'.format(name)) tools.pop(name) continue supported = cls.supportedViews() if viewType is not None: # If a viewType is provided, we don't # return view-independent views if (supported is None) or \ (not issubclass(viewType, tuple(supported))): tools.pop(name) return tools
[docs]def _lookupPlugin(clsname : str, group : str) -> Optional[Plugin]: """Looks up the FSLeyes plugin with the given class name. """ entries = _listEntryPoints('fsleyes_{}'.format(group)) for cls in entries.values(): if cls.__name__ == clsname: return cls return None
[docs]def lookupView(clsName : str) -> View: """Looks up the FSLeyes view with the given class name. """ return _lookupPlugin(clsName, 'views')
[docs]def lookupControl(clsName : str) -> Control: """Looks up the FSLeyes control with the given class name. """ return _lookupPlugin(clsName, 'controls')
[docs]def lookupTool(clsName : str) -> Tool: """Looks up the FSLeyes tool with the given class name. """ return _lookupPlugin(clsName, 'tools')
[docs]def pluginTitle(plugin : Plugin) -> Optional[str]: """Looks and returns up the title under which the given ``plugin`` is registered. """ group = _pluginGroup(plugin) entries = _listEntryPoints('fsleyes_{}'.format(group)) for title, cls in entries.items(): if cls is plugin: return title
[docs]def _importModule(filename : str, modname : str) -> ModuleType: """Used by :func:`loadPlugin`. Imports the given Python file, setting the module name to ``modname``. """ log.debug('Importing %s as %s', filename, modname) spec = imputil.spec_from_file_location(modname, filename) mod = imputil.module_from_spec(spec) spec.loader.exec_module(mod) sys.modules[modname] = mod return mod
[docs]def _findEntryPoints(mod : ModuleType, ignoreBuiltins : bool) -> Dict[str, Dict[str, Plugin]]: """Used by :func:`loadPlugin`. Finds the FSLeyes entry points (views, controls, or tools) that are defined within the given module. :arg mod: The module to search :arg ignoreBuiltins: If ``True``, all, views, controls and tools which are built into FSLeyes will be ignored. The :class:`.ViewPanel`, :class:`.ControlPanel`, :class:`.ControlToolBar` and :class:`.Action` base classes are always ignored. """ entryPoints = collections.defaultdict(dict) for name in dir(mod): item = getattr(mod, name) if not isinstance(item, type): continue bases = [viewpanel.ViewPanel, canvaspanel.CanvasPanel, ctrlpanel.ControlPanel, ctrlpanel.ControlToolBar, ctrlpanel.SettingsPanel, actions.Action] # avoid base-classes and built-ins if item in bases: continue if ignoreBuiltins and str(item.__module__).startswith('fsleyes.'): continue # ignoreControl/ignoreTool may be overridden # to tell us to ignore this plugin if issubclass(item, ctrlpanel.ControlMixin) and \ class_defines_method(item, 'ignoreControl') and \ item.ignoreControl(): continue if issubclass(item, actions.Action) and \ class_defines_method(item, 'ignoreTool') and \ item.ignoreTool(): continue group = _pluginGroup(item) if group is not None: log.debug('Found %s entry point: %s', group, name) entryPoints['fsleyes_{}'.format(group)][name] = item return entryPoints
[docs]def _registerEntryPoints(name : str, module : ModuleType, ignoreBuiltins : bool): """Called by :func:`loadPlugin`. Finds and registers all FSLeyes entry points defined within the given module. """ modname = module.__name__ filename = module.__file__ distname = 'fsleyes-plugin-{}'.format(name) if distname in listPlugins(): log.debug('Plugin %s is already in environment - skipping', distname) return log.debug('Registering plugin %s [dist name %s]', filename, distname) entryPoints = _findEntryPoints(module, ignoreBuiltins) dist = pkg_resources.Distribution( project_name=distname, location=filename, version='0.0.0') # Here I'm relying on the fact that # Distribution.get_entry_map returns # the actual dict that it uses to # store entry points. entryMap = dist.get_entry_map() for group, entries in entryPoints.items(): entryMap[group] = {} for name, cls in entries.items(): label = cls.title() # Look up label for built-in plugins if label is None: if group == 'fsleyes_tools': label = strings.actions.get(name, name) else: label = strings.titles.get(name, name) ep = '{} = {}:{}'.format(label, modname, name) ep = pkg_resources.EntryPoint.parse(ep, dist=dist) entryMap[group][label] = ep pkg_resources.working_set.add(dist)
[docs]def loadPlugin(filename : str): """Loads the given Python file as a FSLeyes plugin. """ # strip underscores to handle e.g. __init__.py, # as pkg_resources might otherwise have trouble name = op.splitext(op.basename(filename))[0].strip('_') modname = 'fsleyes_plugin_{}'.format(name) mod = _importModule(filename, modname) _registerEntryPoints(name, mod, True)
[docs]def installPlugin(filename : str): """Copies the given Python file into the FSLeyes settings directory, within a sub-directory called ``plugins``. After the file has been copied, the path to the copy is passed to :func:`loadPlugin`. """ basename = op.splitext(op.basename(filename))[0] dest = 'plugins/{}.py'.format(basename) log.debug('Installing plugin %s', filename) with open(filename, 'rt') as inf, \ fslsettings.writeFile(dest) as outf: outf.write(inf.read()) dest = fslsettings.filePath(dest) try: loadPlugin(dest) except Exception: fslsettings.deleteFile(dest) raise