#
# __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 _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 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