diff --git a/doc/data_analytics_example/data_analytics/__init__.py b/doc/data_analytics_example/data_analytics/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/doc/data_analytics_example/data_analytics/data_analytics_button.png b/doc/data_analytics_example/data_analytics/data_analytics_button.png
new file mode 100644
index 0000000..11e26a0
Binary files /dev/null and b/doc/data_analytics_example/data_analytics/data_analytics_button.png differ
diff --git a/doc/data_analytics_example/data_analytics/pyxll_extension.py b/doc/data_analytics_example/data_analytics/pyxll_extension.py
new file mode 100644
index 0000000..7193c4a
--- /dev/null
+++ b/doc/data_analytics_example/data_analytics/pyxll_extension.py
@@ -0,0 +1,16 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+
+
+def load(submit_ribbon_tab=None, **kw):
+ """ Loads the PyXLL modules to be exposed to Excel. """
+
+ # Try to submit our ribbon piece to PyXLL
+
+ if submit_ribbon_tab:
+ import os
+ import pkgutil
+ tab_template = pkgutil.get_data('data_analytics', 'ribbon_tab.xml')
+ root_path = os.path.join(os.path.dirname(__file__), '')
+ tab = tab_template.format(ROOT=root_path)
+ submit_ribbon_tab('data_analytics', tab)
diff --git a/doc/data_analytics_example/data_analytics/ribbon_tab.xml b/doc/data_analytics_example/data_analytics/ribbon_tab.xml
new file mode 100644
index 0000000..7e1cb0e
--- /dev/null
+++ b/doc/data_analytics_example/data_analytics/ribbon_tab.xml
@@ -0,0 +1,9 @@
+
+
+
+
+
diff --git a/doc/data_analytics_example/setup.py b/doc/data_analytics_example/setup.py
new file mode 100644
index 0000000..f6fad49
--- /dev/null
+++ b/doc/data_analytics_example/setup.py
@@ -0,0 +1,16 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+
+from setuptools import setup, find_packages
+
+setup(
+ name='data_analytics',
+ version='1.0',
+ packages=find_packages(),
+ package_data={'data_analytics': ['ribbon_tab.xml', '*.png']},
+ entry_points={
+ 'pyxll.extensions': [
+ 'data_analytics = data_analytics.pyxll_extension:load'
+ ]
+ }
+)
diff --git a/doc/source/extension_loader.rst b/doc/source/extension_loader.rst
index 447b637..6dc318a 100644
--- a/doc/source/extension_loader.rst
+++ b/doc/source/extension_loader.rst
@@ -9,7 +9,7 @@ always practical and does not scale in the context of corporate deployment.
The extension loader for PyXLL allows developer to contribute functionalities
to PyXLL without the need to modify the configuration file. By exposing an
-entry point, any Python package is allowed to contribute PyxLL function, macros
+entry point, any Python package is allowed to contribute PyXLL function, macros
and menus in a very practical way. The extension loader relies on standard
Python deployment process which makes it a good choice for deployment.
@@ -27,16 +27,30 @@ the list of modules::
modules =
...
pyxll_utils.extension_loader
-
+
The next time PyXLL will be started all the Python packages contributing to the
`pyxll.extensions` extension point will be loaded by PyXLL.
+2. Adjust the `ribbon` configuration value.
+
+As of this writing, PyXLL reads the configuration setting for the ribbon file
+prior to loading extensions. The solution is to rename the `ribbon`
+configuration key to `default_ribbon`, and to create a new `ribbon` with a
+value that is safe to modify. This file doesn't need to exist yet and will be
+overwritten. We suggest setting this new `ribbon` file to be right next to the
+`default_ribbon` like so::
+
+ [PYXLL]
+ ...
+ default_ribbon = ./examples/ribbon/ribbon.xml
+ ribbon = ./examples/ribbon/ribbon_extended.xml
+
Developers
----------
The PyXLL utils extension loader uses the `stevedore` package that leverages
-the `setuptools` entry point machinerie to discover extensions. To build a
+the `setuptools` entry point machinery to discover extensions. To build a
Python package that contributes to PyXLL, one just need to add the following
entry point to the `setup.py` of the package::
@@ -50,15 +64,25 @@ entry point to the `setup.py` of the package::
)
In this code, the PyXLL extension loader will automatically call
-`mypackage.myodule.callable()` when PyXLL will start. If the callable activates
-some `@xl_func`, or `@xl_macro` or `@xl_menu` decorators, they will be exposed to
-Excel.
-
-Some advanced checkes can be added in the callable (dependencies verification,
-version checks, access rights, etc.).
-
-Example
--------
+`mypackage.myodule.callable(**kw)` when PyXLL will start. The callable must be
+prepared to accept arbitrary keyword arguments in order to avoid forward
+compatibility issues as this extension mechanism grows new features. If the
+callable activates some `@xl_func`, or `@xl_macro` or `@xl_menu` decorators,
+they will be exposed to Excel.
+
+Some advanced checks can be added in the callable (dependencies verification,
+version checks, access rights, etc.).
+
+The ribbon workaround is due to PyXLL reading the config file prior to loading
+any extensions. To work around this, the extension mechanism must
+write directly over the file marked as `ribbon` so that PyXLL will load it. Our
+solution is specify a second "default" ribbon file, which will serve as the
+base to which other extensions can contribute. PyXLL will combine the
+`default_ribbon` with any additional ribbon features contributed by extensions
+before writing out a finalized ribbon to `ribbon`.
+
+Basic Example
+-------------
A developer wants to expose the `ewma` functions from the Pandas library to
Excel. The function will belong to a package `data_analytics`.
@@ -68,9 +92,9 @@ The package will contain the following file:
1. `data_analytics/stats.py`::
from pandas.stats.moments import ewma as pandas_ewma
-
+
from pyxll import xl_func
-
+
@xl_func('dataframe df, float span: dataframe')
def ewma(df, span):
""" Exponentially-weighted moving average. """
@@ -78,13 +102,13 @@ The package will contain the following file:
2. `data_analytics/pyxll_extension.py`::
- def load():
+ def load(**kw):
""" Loads the PyXLL modules to be exposed to Excel. """
# load PyXLL utilities dependencies
-
- # required to support the dataframe type converters
- import pyxll_utils.pandastypes
+
+ # required to support the dataframe type converters
+ import pyxll_utils.pandastypes
# load all the local modules that we want to expose to Excel
import data_analytics.stats
@@ -99,18 +123,79 @@ The package will contain the following file:
packages=find_packages(),
entry_points = {
'pyxll.extensions' : [
- 'data_analytics_extension = data_analytics.pyxll_extension:load'
+ 'data_analytics = data_analytics.pyxll_extension:load'
]
}
)
-Then build and install your egg, start Excel and the ewna function will be
+Then build and install your egg, start Excel and the ewma function will be
available. The PyXLL log file will list your extension::
2015-02-03 16:15:34,510 - INFO : Extensions loaded:
- data_analytics_extension
+ data_analytics
...
-
-
+Ribbon Extension Example
+------------------------
+
+A developer has written an extension which should contribute a tab to the excel
+ribbon (requires PyXLL >= 3.0). If the `tab` element's `id` attribute matches
+an existing tab, the two tabs will be combined by appended the contributed
+tab's groups to the original.
+
+The package will contain the following files:
+
+1. `data_analytics/pyxll_extension.py`::
+
+ def load(submit_ribbon_tab=None, **kw):
+ """ Loads the PyXLL modules to be exposed to Excel. """
+
+ # Try to submit our ribbon piece to PyXLL
+ if submit_ribbon_tab:
+ import os
+ import pkgutil
+ tab_template = pkgutil.get_data('data_analytics', 'ribbon_tab.xml')
+ root_path = os.path.dirname(__file__)
+ tab = tab_template.format(ROOT=root_path)
+ submit_ribbon_tab('data_analytics', tab)
+
+2. `setup.py`::
+
+ from setuptools import setup, find_packages
+
+ setup(
+ name='data_analytics',
+ version='1.0',
+ packages=find_packages(),
+ package_data={'data_analytics': ['ribbon_tab.xml', '*.png']},
+ entry_points={
+ 'pyxll.extensions': [
+ 'data_analytics = data_analytics.pyxll_extension:load'
+ ]
+ }
+ )
+
+3. `data_analytics/ribbon_tab.xml`::
+
+
+
+
+
+
+
+4. `data_analytics/data_analytics_button.png` - An image file
+
+
+Then build and install your egg, start Excel and the new ribbon button will be
+available. The PyXLL log file will announce your extension's ribbon fragment::
+
+ 2016-03-05 12:22:48,867 - INFO : Adding ribbon fragment: data_analytics
+ 2016-03-05 12:22:48,867 - INFO : Wrote extended ribbon to C:\Users\EnUser\AppData\Local\Enthught\Canopy32\User\lib\site-packages\pyxll\examples\ribbon\ribbon_extended.xml
+ 2016-03-05 12:22:48,867 - INFO : Extensions loaded:
+ data_analytics
+ ...
diff --git a/endist.dat b/endist.dat
index 4e01bd2..e9b01d9 100644
--- a/endist.dat
+++ b/endist.dat
@@ -4,4 +4,5 @@ packages = [
'pandas',
'pytz',
'stevedore',
+ 'lxml',
]
diff --git a/pyxll_utils/data/ribbon.xml b/pyxll_utils/data/ribbon.xml
new file mode 100755
index 0000000..de9bae4
--- /dev/null
+++ b/pyxll_utils/data/ribbon.xml
@@ -0,0 +1,16 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/pyxll_utils/extension_loader.py b/pyxll_utils/extension_loader.py
index 2c6a2d0..6b15413 100644
--- a/pyxll_utils/extension_loader.py
+++ b/pyxll_utils/extension_loader.py
@@ -5,22 +5,58 @@
the installed packages that contributes to PyXLL through setuptools.
"""
+import errno
import logging
+import os
from stevedore import extension
+from pyxll import get_config
+from .ribbon_synthesizer import RibbonSynthesizer
+
logger = logging.getLogger(__name__)
+# Keyword args to pass to plugin initializers
+invoke_kwds = {}
+
+config = dict(get_config().items('PYXLL'))
+default_ribbon_path = config.get('default_ribbon', '')
+ribbon_path = config.get('ribbon', '')
+
+should_make_ribbon = (default_ribbon_path and ribbon_path and
+ default_ribbon_path != ribbon_path)
+ribbon_synthesizer = RibbonSynthesizer.from_file(default_ribbon_path)
+
+if should_make_ribbon:
+ invoke_kwds['submit_ribbon_tab'] = ribbon_synthesizer.submit_ribbon_tab
+else:
+ logger.info("The ribbon will not be modified because your config does"
+ " not define both PYXLL::ribbon and PYXLL::default_ribbon")
+
extension_manager = extension.ExtensionManager(
- namespace='pyxll.extensions',
- invoke_on_load=True,
+ namespace='pyxll.extensions',
+ invoke_on_load=True,
+ invoke_kwds=invoke_kwds,
)
+extension_names = extension_manager.names()
-if len(extension_manager.names()) > 0:
+if len(extension_names) > 0:
logger.info(
- 'Extensions loaded:\n{}'.format('\n'.join(extension_manager.names()))
+ 'Extensions loaded:\n{}'.format('\n'.join(extension_names))
)
else:
logger.info('No extension loaded')
+if should_make_ribbon:
+ target_dir = os.path.dirname(ribbon_path)
+ try:
+ # If the ribbon dirs don't exist, make them.
+ os.makedirs(target_dir)
+ logger.info("Created ribbon directory: {}".format(target_dir))
+ except Exception as e:
+ if not e.errno == errno.EEXIST:
+ raise
+ with open(ribbon_path, 'w') as f:
+ f.write(ribbon_synthesizer.to_bytes())
+ logger.info("Wrote extended ribbon to {}".format(ribbon_path))
diff --git a/pyxll_utils/ribbon_synthesizer.py b/pyxll_utils/ribbon_synthesizer.py
new file mode 100644
index 0000000..ffc62f3
--- /dev/null
+++ b/pyxll_utils/ribbon_synthesizer.py
@@ -0,0 +1,123 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+
+from __future__ import division, print_function
+
+import logging
+import os
+import sys
+from collections import OrderedDict
+
+from lxml import etree
+
+
+PY2 = sys.version_info[0] == 2
+PY3 = sys.version_info[0] == 3
+
+if PY3:
+ string_types = str
+else:
+ string_types = basestring # noqa
+
+
+logging.basicConfig(
+ format='%(asctime)s %(levelname)-8.8s [%(name)s:%(lineno)s] %(message)s',
+ datefmt='%Y-%m-%d %H:%M:%S',
+ level='INFO')
+logger = logging.getLogger(__name__)
+
+
+EMPTY_RIBBON = '''\
+
+
+
+
+
+''' # noqa
+
+SAMPLE_RIBBON_FRAGMENT = '''\
+
+
+
+
+'''
+
+
+class RibbonSynthesizer(object):
+
+ @classmethod
+ def from_file(cls, filename):
+ if os.path.exists(filename):
+ with open(filename, 'rb') as f:
+ default_ribbon = f.read()
+ else:
+ default_ribbon = None
+ return cls(default_ribbon=default_ribbon)
+
+ def __init__(self, default_ribbon=None):
+ self.ribbon = default_ribbon or EMPTY_RIBBON
+ self._elements_to_insert = OrderedDict()
+
+ def to_bytes(self, names=None):
+ if names is None:
+ names = sorted(self._elements_to_insert.keys())
+ ribbon = self.parse(self.ribbon)
+ tabs = self.get_tabs(ribbon)
+ for name in names:
+ try:
+ tab = self._elements_to_insert[name]
+ except KeyError:
+ logger.info("skip {}".format(name))
+ # This isn't an error. It probably just means that the
+ # requested extension didn't submit a fragment.
+ continue
+ self.upsert_by_attribute(tabs, tab)
+ return self.element_as_bytes(ribbon)
+
+ @staticmethod
+ def parse(buf):
+ return etree.fromstring(buf)
+
+ @staticmethod
+ def element_as_bytes(element):
+ return etree.tostring(element, pretty_print=True)
+
+ @staticmethod
+ def get_tabs(root):
+ return root.find('.//{*}ribbon/{*}tabs')
+
+ def submit_ribbon_tab(self, extension_name, tab_buffer):
+ root_elem = self.parse(tab_buffer)
+ if root_elem.tag != 'tab':
+ msg = ("Ignoring fragment {}:\n{}\n"
+ "Ribbons must be elements like this:\n{}")
+ logger.warning(
+ msg.format(extension_name, tab_buffer, SAMPLE_RIBBON_FRAGMENT))
+ return
+ else:
+ logger.info("Adding ribbon fragment: {}".format(extension_name))
+ self._elements_to_insert[extension_name] = root_elem
+
+ @staticmethod
+ def upsert_by_attribute(parent, element, attr='id'):
+ """Add or append `element` to parent depending on `attr`.
+
+ If `parent` contains a child such that:
+ (element.tag, element.get(attr)) == (child.tag, child.get(attr))
+ then we add the contents of element to child.
+ Otherwise, we add element as a sibling of child.
+
+ This allows us to extend existing elements by matching on attr.
+ """
+
+ tag = element.tag
+ attr_val = element.get(attr)
+ query = '{tag}[@{attr}="{attr_val}"]'.format(**locals())
+ matches = parent.findall("{*}" + query)
+ if matches:
+ match = matches[0]
+ match.extend(element.getchildren())
+ else:
+ parent.append(element)
+ return parent
diff --git a/setup.py b/setup.py
index bf4050a..d3e3439 100644
--- a/setup.py
+++ b/setup.py
@@ -4,7 +4,6 @@
version='1.0',
author='PyXLL Ltd, Enthought Inc.',
packages=find_packages(),
- # Provides a namespace for extension points to contribute to. This
- # functionnality is required by the pyxll_addons.extension_loader module
- provides=['pyxll.modules']
-)
+ # Provides a namespace for extension points to contribute to. This
+ # functionality is required by the pyxll_addons.extension_loader module
+ provides=['pyxll.extensions'])