Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 50 additions & 9 deletions PYME/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,28 @@
<plugin-name>.yaml config file as detailed below.


pyproject.toml entry points (recommended for all packages)
-------------------------------------------------------------

The recommended approach for pip-installable plugins is to declare ``importlib.metadata`` entry points in
``pyproject.toml``. PYME will discover them automatically on startup without any file copying or user action,
and this works correctly for both regular and editable installs.

.. code-block:: toml

[project.entry-points."pyme.plugins.recipes"]
myplugin = "mypackage.recipe_modules"
myplugin_extra = "mypackage.extra_modules"

[project.entry-points."pyme.plugins.visgui"]
myplugin = "mypackage.visgui_modules"

Supported groups are ``pyme.plugins.visgui``, ``pyme.plugins.dsviewer``, ``pyme.plugins.recipes``,
and ``pyme.plugins.fit_factories``. The entry point *name* (left-hand side) is ignored; the *value*
(right-hand side) is the fully qualified module path to import. A ``:attr`` suffix is accepted but
ignored — only the module path is used.


plugins/<plugin-name>.yaml
--------------------------

Expand Down Expand Up @@ -252,6 +274,7 @@
import sys
import glob
import importlib
from importlib.metadata import entry_points

site_config_directory = '/etc/PYME'
site_config_file = '/etc/PYME/config.yaml'
Expand Down Expand Up @@ -462,6 +485,14 @@ def _get_app_txt_plugins(application):
for app in ['visgui', 'dsviewer', 'recipes']:
plugins[app] = plugins.get(app, set()) | set(_get_app_txt_plugins(app))

# discover plugins registered via importlib.metadata entry points (pip/wheel packages)
for app in ['visgui', 'dsviewer', 'recipes', 'fit_factories']:
for ep in entry_points(group='pyme.plugins.%s' % app):
try:
plugins[app].add(ep.value.split(':')[0])
except Exception:
logger.warning('Failed to register entry point plugin %r for %s' % (ep, app))

_parse_plugin_config()


Expand All @@ -473,15 +504,25 @@ def get_plugin_template_paths():

def get_plugins(application):
"""
Get a list of plugins for a given application
Get a list of plugins for a given application.

Plugins are discovered from three sources, all merged into the returned set:

1. **importlib.metadata entry points** (recommended — works for pip, conda, and editable installs).
Declare in ``pyproject.toml``::

[project.entry-points."pyme.plugins.recipes"]
myplugin = "mypackage.recipe_modules"

Supported groups: ``pyme.plugins.visgui``, ``pyme.plugins.dsviewer``,
``pyme.plugins.recipes``, ``pyme.plugins.fit_factories``.

2. **YAML config files** (``plugins/<name>.yaml`` in any config directory): required for ``reports``
plugins; otherwise legacy.

3. **Legacy .txt files** (``plugins/<app>/<name>.txt``): supported for backwards compatibility only.

Modules are registered by adding fully resolved module paths (one per line) to a text file in the relevant directory.
The code searches **all** files in the relevant directories, and the intention is that there is one registration file
for each standalone package that provides modules and can e.g. be conda or pip-installed which contains a list of all
the plugins that package provides. The registration filename should ideally be the same as the package name, although
further subdivision for large packages is fine. registration filenames should however be unique - e.g. by prefixing
with the package name. By structuring it this way, a package can add this file to the ``anaconda/etc/PYME/plugins/XXX/``
folder through the standard conda packaging tools and it will be automatically discovered without conflicts
In all cases the module is *not* imported here; callers perform the actual import at load time.

Parameters
----------
Expand All @@ -490,7 +531,7 @@ def get_plugins(application):

Returns
-------
list of fully resolved module paths
set of fully resolved module paths

"""
return plugins[application]
Expand Down
4 changes: 2 additions & 2 deletions docs/ExtendingDsviewer.rst
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,8 @@ is located in within that directory will be automatically detected and treated a

.. note::

``PYME.DSView.modules`` and ``PYMEnf.DSView.modules`` [#pymenf]_ are currently the only locations where modules will be detected.
A more flexible mechanism of module discovery is high on the TODO list.
``PYME.DSView.modules`` and ``PYMEnf.DSView.modules`` [#pymenf]_ are scanned automatically as built-in locations.
External packages should register dsviewer plugins via the mechanisms described in :ref:`plugins`.

Plugins **must** implement a function called ``Plug(dsviewer)`` which takes an instance of the current
:class:`PYME.DSView.dsviewer.DSViewFrame`, and can implement any additional python logic. It is good practice not to put
Expand Down
3 changes: 2 additions & 1 deletion docs/ExtendingVisGUI.rst
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,8 @@ automatically found in ``PYME.LMVis.Extras`` or ``PYMEnf.LMVis.Extras`` [#pymenf

.. note::

A more flexible method for discovering VisGUI plugins is on the TODO list.
External packages should register VisGUI plugins via the mechanisms described in :ref:`plugins`.
Plugins placed in ``PYME.LMVis.Extras`` or ``PYMEnf.LMVis.Extras`` [#pymenf]_ continue to be discovered automatically.


Plugins which use the output of the pipeline
Expand Down
1 change: 1 addition & 0 deletions docs/hacking.rst
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ Extending PYME and writing plugins
Contributing
DataModel
WritingRecipeModules
plugins
ExtendingDsviewer
ExtendingVisGUI
api/PYME.config
Expand Down
82 changes: 82 additions & 0 deletions docs/plugins.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
.. _plugins:

Plugin Registration and Discovery
**********************************

PYME supports four plugin groups:

.. csv-table::
:header: Group, Used by, Plug() argument type
:widths: auto

``visgui``, PYMEVisualise (VisGUI), :class:`~PYME.LMVis.VisGUI.VisGUIFrame`
``dsviewer``, PYMEImage / dh5view, :class:`~PYME.DSView.dsviewer.DSViewFrame`
``recipes``, Recipes / all apps, *(imported for side-effects; no Plug)*
``fit_factories``,Localisation analysis, *(imported for side-effects; no Plug)*

Plugins in all groups are discovered from the sources below and merged — in every case the module is *not* imported
during discovery, only at the point the relevant application component initialises.


importlib.metadata entry points (recommended)
=============================================

Declare entry points in your package's ``pyproject.toml``. PYME discovers them automatically on startup with no
file copying. This works identically for pip, conda, and editable installs.

.. code-block:: toml

[project.entry-points."pyme.plugins.recipes"]
myplugin = "mypackage.recipe_modules"
myplugin_extra = "mypackage.extra_modules"

[project.entry-points."pyme.plugins.visgui"]
myplugin = "mypackage.visgui_modules"

[project.entry-points."pyme.plugins.dsviewer"]
myplugin = "mypackage.dsviewer_modules"

[project.entry-points."pyme.plugins.fit_factories"]
myplugin = "mypackage.fit_factories"

The entry point *name* (left-hand side) is arbitrary and ignored by PYME; the *value* is the fully qualified module
path. An optional ``:attr`` suffix (e.g. ``mypackage.module:some_func``) is accepted but only the module path is used.


YAML config files (required for report plugins; otherwise legacy)
=================================================================

YAML config files are the only current mechanism for registering ``reports`` plugins (templates and filters).
For the four main plugin groups, prefer entry points instead.

Drop a ``<plugin-name>.yaml`` file into any PYME config directory
(``~/.PYME/plugins/``, ``/etc/PYME/plugins/``, or ``<sys.prefix>/etc/PYME/plugins/``).

.. code-block:: yaml

# visgui/dsviewer/recipes/fit_factories sections work but entry points are preferred.

reports:
templates: mypackage.report_templates
filters:
mypackage.report_filters:
- myfilter1

See :func:`PYME.config.get_plugins` and :mod:`PYME.config` for config directory locations.


Legacy .txt files
=================

Individual ``<name>.txt`` files placed in ``plugins/visgui/``, ``plugins/dsviewer/``, or ``plugins/recipes/``
subdirectories of any config directory are still supported. Each line of the file should be a fully qualified module
path. New plugins should use one of the mechanisms above instead.


Writing plugins
===============

``visgui`` and ``dsviewer`` plugins must implement a top-level ``Plug(parent)`` function. ``recipes`` and
``fit_factories`` modules self-register during import via decorator or class-level code — no ``Plug`` is needed.

See :ref:`extendingdsviewer`, :ref:`extendingvisgui`, and :ref:`writingrecipemodules` for details.
100 changes: 100 additions & 0 deletions tests/PYME/test_config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import pytest
from unittest.mock import patch, MagicMock


def _make_ep(name, value):
ep = MagicMock()
ep.name = name
ep.value = value
return ep


def _fresh_parse():
"""Re-run _parse_plugin_config against a clean plugins dict."""
import PYME.config as cfg
cfg.plugins = {app: set() for app in ['visgui', 'dsviewer', 'recipes', 'fit_factories']}
cfg._parse_plugin_config()
return cfg


class TestEntryPointDiscovery:
def test_recipe_entry_point_added(self):
eps = {'pyme.plugins.recipes': [_make_ep('myplugin', 'mypackage.recipe_modules')]}

def fake_entry_points(group):
return eps.get(group, [])

with patch('PYME.config.entry_points', side_effect=fake_entry_points, create=True):
cfg = _fresh_parse()

assert 'mypackage.recipe_modules' in cfg.get_plugins('recipes')

def test_visgui_entry_point_added(self):
eps = {'pyme.plugins.visgui': [_make_ep('myplugin', 'mypackage.visgui_modules')]}

def fake_entry_points(group):
return eps.get(group, [])

with patch('PYME.config.entry_points', side_effect=fake_entry_points, create=True):
cfg = _fresh_parse()

assert 'mypackage.visgui_modules' in cfg.get_plugins('visgui')

def test_colon_attr_suffix_stripped(self):
"""Only the module path before ':' should be used."""
eps = {'pyme.plugins.recipes': [_make_ep('myplugin', 'mypackage.recipe_modules:some_attr')]}

def fake_entry_points(group):
return eps.get(group, [])

with patch('PYME.config.entry_points', side_effect=fake_entry_points, create=True):
cfg = _fresh_parse()

assert 'mypackage.recipe_modules' in cfg.get_plugins('recipes')
assert 'mypackage.recipe_modules:some_attr' not in cfg.get_plugins('recipes')

def test_multiple_groups_and_entries(self):
eps = {
'pyme.plugins.recipes': [
_make_ep('a', 'pkg.recipes_a'),
_make_ep('b', 'pkg.recipes_b'),
],
'pyme.plugins.fit_factories': [_make_ep('c', 'pkg.fitters')],
}

def fake_entry_points(group):
return eps.get(group, [])

with patch('PYME.config.entry_points', side_effect=fake_entry_points, create=True):
cfg = _fresh_parse()

assert {'pkg.recipes_a', 'pkg.recipes_b'} <= cfg.get_plugins('recipes')
assert 'pkg.fitters' in cfg.get_plugins('fit_factories')

def test_bad_entry_point_does_not_crash(self):
"""A malformed entry point should log a warning but not abort discovery."""
bad = MagicMock()
bad.name = 'bad'
type(bad).value = property(lambda self: (_ for _ in ()).throw(RuntimeError('broken')))
good = _make_ep('good', 'pkg.good_module')

eps = {'pyme.plugins.recipes': [bad, good]}

def fake_entry_points(group):
return eps.get(group, [])

with patch('PYME.config.entry_points', side_effect=fake_entry_points, create=True):
cfg = _fresh_parse()

assert 'pkg.good_module' in cfg.get_plugins('recipes')

def test_no_entry_points_leaves_dict_intact(self):
"""With no entry points declared, existing empty sets remain."""
def fake_entry_points(group):
return []

with patch('PYME.config.entry_points', side_effect=fake_entry_points, create=True):
cfg = _fresh_parse()

for app in ['visgui', 'dsviewer', 'recipes', 'fit_factories']:
assert isinstance(cfg.get_plugins(app), set)
Loading