Skip to content

Commit

Permalink
Use plugins to define rhino installable packages
Browse files Browse the repository at this point in the history
  • Loading branch information
gonzalocasas committed Sep 11, 2020
1 parent 4d1101c commit 888a76c
Show file tree
Hide file tree
Showing 11 changed files with 149 additions and 26 deletions.
13 changes: 13 additions & 0 deletions docs/devguide.rst
Expand Up @@ -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
------------------

Expand Down
1 change: 1 addition & 0 deletions docs/gettingstarted/cad/rhino.rst
@@ -1,4 +1,5 @@
.. _cad_rhino:

********************************************************************************
Rhino
********************************************************************************
Expand Down
1 change: 1 addition & 0 deletions docs/index.rst
Expand Up @@ -48,6 +48,7 @@ Table of Contents
gettingstarted
tutorial
api
plugins
devguide
changelog
license
Expand Down
32 changes: 32 additions & 0 deletions 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.
68 changes: 50 additions & 18 deletions src/compas/plugins.py
Expand Up @@ -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
Expand All @@ -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.
Expand All @@ -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

Expand Down Expand Up @@ -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)
1 change: 1 addition & 0 deletions src/compas_ghpython/__init__.py
Expand Up @@ -21,4 +21,5 @@
__version__ = '0.16.2'


__all_plugins__ = ['compas_ghpython.install']
__all__ = [name for name in dir() if not name.startswith('_')]
6 changes: 6 additions & 0 deletions 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']
8 changes: 6 additions & 2 deletions src/compas_rhino/__init__.py
Expand Up @@ -18,8 +18,9 @@
"""
from __future__ import absolute_import

import os
import io
import os

import compas
import compas._os

Expand Down Expand Up @@ -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',
]
2 changes: 0 additions & 2 deletions src/compas_rhino/geometry/transformations/xforms.py
Expand Up @@ -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',
Expand Down
36 changes: 35 additions & 1 deletion src/compas_rhino/install.py
Expand Up @@ -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']

Expand Down Expand Up @@ -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)
Expand All @@ -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')

Expand Down
7 changes: 4 additions & 3 deletions 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']


Expand Down Expand Up @@ -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)
Expand Down

0 comments on commit 888a76c

Please sign in to comment.