From 888a76c8e4c2fe2020dbca7aab135a9957bbd1fa Mon Sep 17 00:00:00 2001 From: Gonzalo Casas Date: Fri, 11 Sep 2020 02:04:24 +0200 Subject: [PATCH] Use plugins to define rhino installable packages --- docs/devguide.rst | 13 ++++ docs/gettingstarted/cad/rhino.rst | 1 + docs/index.rst | 1 + docs/plugins.rst | 32 +++++++++ src/compas/plugins.py | 68 ++++++++++++++----- src/compas_ghpython/__init__.py | 1 + src/compas_ghpython/install.py | 6 ++ src/compas_rhino/__init__.py | 8 ++- .../geometry/transformations/xforms.py | 2 - src/compas_rhino/install.py | 36 +++++++++- src/compas_rhino/uninstall.py | 7 +- 11 files changed, 149 insertions(+), 26 deletions(-) create mode 100644 docs/plugins.rst create mode 100644 src/compas_ghpython/install.py diff --git a/docs/devguide.rst b/docs/devguide.rst index be7a49d0016..37ea9f68922 100644 --- a/docs/devguide.rst +++ b/docs/devguide.rst @@ -203,6 +203,19 @@ Once a package is found, the metadata in ``__all_plugins__`` is read and all mod listed are analyzed to look for functions decorated with the :meth:`compas.plugins.plugin` decorator. +Two kinds of extension points +----------------------------- + +An extension point, or ``pluggable`` interface can be declared as being one of two types +based on how they select which implementation to pick if there are multiple available. + +* ``selector='first_match'``: this type of extension point will pick the first plugin + implementation that satisfies the requirements. +* ``selector='collect_all'``: extension points defined with this selector will instead + collect all plugin implementations and execute them all, collecting the return + values into a list. An example of this is the Rhino install extension + point: :meth:`compas_rhino.install.installable_rhino_packages`. + A complete example ------------------ diff --git a/docs/gettingstarted/cad/rhino.rst b/docs/gettingstarted/cad/rhino.rst index 92218683b52..7aae665c5b5 100644 --- a/docs/gettingstarted/cad/rhino.rst +++ b/docs/gettingstarted/cad/rhino.rst @@ -1,4 +1,5 @@ .. _cad_rhino: + ******************************************************************************** Rhino ******************************************************************************** diff --git a/docs/index.rst b/docs/index.rst index 5227ddbc5df..44b5f5b58ea 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -48,6 +48,7 @@ Table of Contents gettingstarted tutorial api + plugins devguide changelog license diff --git a/docs/plugins.rst b/docs/plugins.rst new file mode 100644 index 00000000000..df2c2a8145b --- /dev/null +++ b/docs/plugins.rst @@ -0,0 +1,32 @@ +================ +Extension points +================ + +COMPAS has an extensible architecture based on plugins that allows to +customize and extend the functionality of the core framework. + +The following **extension points** are currently defined: + +Category: ``booleans`` +^^^^^^^^^^^^^^^^^^^^^^ + +.. currentmodule:: compas.geometry +.. autosummary:: + :toctree: generated/ + :nosignatures: + + boolean_union_mesh_mesh + boolean_difference_mesh_mesh + boolean_intersection_mesh_mesh + +Category: ``install`` +^^^^^^^^^^^^^^^^^^^^^ + +.. currentmodule:: compas_rhino.install +.. autosummary:: + :toctree: generated/ + :nosignatures: + + installable_rhino_packages + +Check out the developer guide to :ref:`plugins` for additional details. diff --git a/src/compas/plugins.py b/src/compas/plugins.py index 05d8c2d5144..b18e6c3c01f 100644 --- a/src/compas/plugins.py +++ b/src/compas/plugins.py @@ -239,7 +239,7 @@ def _parse_plugin_opts(self, plugin_method): return res -def pluggable(pluggable_method=None, category=None, domain='https://plugins.compas.dev/'): +def pluggable(pluggable_method=None, category=None, selector='first_match', domain='https://plugins.compas.dev/'): """Decorator to mark a method as a pluggable extension point. A pluggable interface is uniquely identifiable/locatable via a URL @@ -252,9 +252,15 @@ def pluggable(pluggable_method=None, category=None, domain='https://plugins.comp ---------- pluggable_method : callable The method to decorate as ``pluggable``. - category : str, optional + category : :obj:`str`, optional An optional string to group or categorize extension points. - domain : str, optional + selector : :obj:`str`, optional + String that determines the selection mode of extension points. + + - ``"first_match"``: (:obj:`str`) Execute the first matching implementation. + - ``"collect_all"``: (:obj:`str`) Executes all matching implementations and return list of its return values. + + domain : :obj:`str`, optional Domain name that "owns" the pluggable extension point. This is useful to avoid name collisions between extension points of different packages. @@ -270,11 +276,23 @@ def pluggable_decorator(func): def wrapper(*args, **kwargs): extension_point_url = _get_extension_point_url_from_method(domain, category, func) - # Select matching plugin - plugin_impl = _select_plugin(extension_point_url) + # Select first matching plugin + if selector == 'first_match': + plugin_impl = _select_plugin(extension_point_url) + + # Invoke plugin + return plugin_impl.method(*args, **kwargs) - # Invoke plugin - return plugin_impl.method(*args, **kwargs) + # Collect all matching plugins + elif selector == 'collect_all': + results = [] + + for plugin_impl in _collect_plugins(extension_point_url): + results.append(plugin_impl.method(*args, **kwargs)) + + return results + else: + raise ValueError('Unexpected selector type. Must be either: first_match or collect_all') return wrapper @@ -392,25 +410,39 @@ def check_importable(self, module_name): return self._cache[module_name] +def is_plugin_selectable(plugin, manager): + if plugin.opts['requires']: + importable_requirements = (manager.importer.check_importable(name) for name in plugin.opts['requires']) + + if not all(importable_requirements): + if manager.DEBUG: + print('Requirements not satisfied. Plugin will not be used: {}'.format(plugin.id)) + return False + + return True + + def select_plugin(extension_point_url, manager): if manager.DEBUG: print('Extension Point URL {} invoked. Will select a matching plugin'.format(extension_point_url)) - plugins = manager.registry.get(extension_point_url) - for plugin in plugins or []: - if plugin.opts['requires']: - importable_requirements = (manager.importer.check_importable(name) for name in plugin.opts['requires']) - - if not all(importable_requirements): - if manager.DEBUG: - print('Requirements not satisfied. Plugin will not be used: {}'.format(plugin.id)) - continue - - return plugin + plugins = manager.registry.get(extension_point_url) or [] + for plugin in plugins: + if is_plugin_selectable(plugin, manager): + return plugin # Nothing found, raise raise PluginNotInstalledError('Plugin not found for extension point URL: {}'.format(extension_point_url)) +def collect_plugins(extension_point_url, manager): + if manager.DEBUG: + print('Extension Point URL {} invoked. Will select a matching plugin'.format(extension_point_url)) + + plugins = manager.registry.get(extension_point_url) or [] + return [plugin for plugin in plugins if is_plugin_selectable(plugin, manager)] + + plugin_manager = PluginManager() _select_plugin = functools.partial(select_plugin, manager=plugin_manager) +_collect_plugins = functools.partial(collect_plugins, manager=plugin_manager) diff --git a/src/compas_ghpython/__init__.py b/src/compas_ghpython/__init__.py index 285ccf8d29f..3726ec3d9e8 100644 --- a/src/compas_ghpython/__init__.py +++ b/src/compas_ghpython/__init__.py @@ -21,4 +21,5 @@ __version__ = '0.16.2' +__all_plugins__ = ['compas_ghpython.install'] __all__ = [name for name in dir() if not name.startswith('_')] diff --git a/src/compas_ghpython/install.py b/src/compas_ghpython/install.py new file mode 100644 index 00000000000..4fdcf725b7d --- /dev/null +++ b/src/compas_ghpython/install.py @@ -0,0 +1,6 @@ +import compas.plugins + + +@compas.plugins.plugin(category='install') +def installable_rhino_packages(category='install'): + return ['compas_ghpython'] diff --git a/src/compas_rhino/__init__.py b/src/compas_rhino/__init__.py index b627270578b..996004f6829 100644 --- a/src/compas_rhino/__init__.py +++ b/src/compas_rhino/__init__.py @@ -18,8 +18,9 @@ """ from __future__ import absolute_import -import os import io +import os + import compas import compas._os @@ -195,5 +196,8 @@ def _try_remove_bootstrapper(path): return True -__all_plugins__ = ['compas_rhino.geometry.booleans'] __all__ = [name for name in dir() if not name.startswith('_')] +__all_plugins__ = [ + 'compas_rhino.geometry.booleans', + 'compas_rhino.install', +] diff --git a/src/compas_rhino/geometry/transformations/xforms.py b/src/compas_rhino/geometry/transformations/xforms.py index 8f17e29425c..06ca91dfc72 100644 --- a/src/compas_rhino/geometry/transformations/xforms.py +++ b/src/compas_rhino/geometry/transformations/xforms.py @@ -7,8 +7,6 @@ if compas.RHINO: from Rhino.Geometry import Transform -# TODO: This file should actually move to compas_rhino - __all__ = [ 'xform_from_transformation', 'xform_from_transformation_matrix', diff --git a/src/compas_rhino/install.py b/src/compas_rhino/install.py index f3d8a0bbcf2..bb567dbfef3 100644 --- a/src/compas_rhino/install.py +++ b/src/compas_rhino/install.py @@ -3,12 +3,14 @@ from __future__ import print_function import importlib +import itertools import os import sys import compas_rhino import compas._os +import compas.plugins __all__ = ['install'] @@ -109,6 +111,37 @@ def install(version=None, packages=None): sys.exit(exit_code) +@compas.plugins.plugin(category='install', pluggable_name='installable_rhino_packages', tryfirst=True) +def default_installable_rhino_packages(): + # While this list could obviously be hard-coded, I think + # eating our own dogfood and using plugins to define this, just like + # any other extension/plugin would be is a better way to ensure consistent behavior. + return ['compas', 'compas_rhino'] + + +@compas.plugins.pluggable(category='install', selector='collect_all') +def installable_rhino_packages(): + """Provide a list of packages to make available inside Rhino. + + Extensions providing Rhino or Grasshopper features + can implement this pluggable interface to automatically + have their packages made available inside Rhino when + COMPAS is installed into it. + + Examples + -------- + >>> import compas.plugins + >>> @compas.plugins.plugin(category='install') + ... def installable_rhino_packages(): + ... return ['compas_fab'] + + Returns + ------- + :obj:`list` of :obj:`str` + List of package names to make available inside Rhino. + """ + pass + def _update_bootstrapper(install_path, packages): # Take either the CONDA environment directory or the current Python executable's directory python_directory = os.environ.get('CONDA_PREFIX', None) or os.path.dirname(sys.executable) @@ -135,7 +168,8 @@ def _filter_installable_packages(version, packages): ghpython_incompatible = True if not packages: - packages = compas_rhino.INSTALLABLE_PACKAGES + # Flatten list of results (resulting from collect_all pluggable) + packages = list(itertools.chain.from_iterable(installable_rhino_packages())) elif 'compas_ghpython' in packages and ghpython_incompatible: print('Skipping installation of compas_ghpython since it\'s not supported for Rhino 5 for Mac') diff --git a/src/compas_rhino/uninstall.py b/src/compas_rhino/uninstall.py index db177083050..f7f6cbcce3a 100644 --- a/src/compas_rhino/uninstall.py +++ b/src/compas_rhino/uninstall.py @@ -1,15 +1,16 @@ -from __future__ import print_function from __future__ import absolute_import from __future__ import division +from __future__ import print_function +import itertools import os import sys import compas_rhino +from compas_rhino.install import installable_rhino_packages import compas._os - __all__ = ['uninstall'] @@ -111,7 +112,7 @@ def _filter_installed_packages(version, packages): # No info, fall back to installable packages list if packages is None: - packages = compas_rhino.INSTALLABLE_PACKAGES + packages = itertools.chain.from_iterable(installable_rhino_packages()) # Handle legacy install legacy_bootstrapper = compas_rhino._get_bootstrapper_path(ipylib_path)