diff --git a/.gitignore b/.gitignore index a509fcf736ea8..c953020f59342 100644 --- a/.gitignore +++ b/.gitignore @@ -103,3 +103,4 @@ doc/source/index.rst doc/build/html/index.html # Windows specific leftover: doc/tmp.sv +doc/source/templates/ diff --git a/MANIFEST.in b/MANIFEST.in index b7a7e6039ac9a..31de3466cb357 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -25,3 +25,4 @@ global-exclude *.png # recursive-include LICENSES * include versioneer.py include pandas/_version.py +include pandas/formats/templates/*.tpl diff --git a/ci/requirements-3.5_DOC.run b/ci/requirements-3.5_DOC.run index 7ed60758612bb..9647ab53ab835 100644 --- a/ci/requirements-3.5_DOC.run +++ b/ci/requirements-3.5_DOC.run @@ -1,5 +1,6 @@ ipython ipykernel +ipywidgets sphinx nbconvert nbformat diff --git a/doc/source/style.ipynb b/doc/source/style.ipynb index 2b8bf35a913c1..06763b2a5e741 100644 --- a/doc/source/style.ipynb +++ b/doc/source/style.ipynb @@ -54,7 +54,7 @@ }, "outputs": [], "source": [ - "import matplotlib\n", + "import matplotlib.pyplot\n", "# We have this here to trigger matplotlib's font cache stuff.\n", "# This cell is hidden from the output" ] @@ -87,9 +87,7 @@ { "cell_type": "code", "execution_count": null, - "metadata": { - "collapsed": true - }, + "metadata": {}, "outputs": [], "source": [ "df.style" @@ -107,9 +105,7 @@ { "cell_type": "code", "execution_count": null, - "metadata": { - "collapsed": true - }, + "metadata": {}, "outputs": [], "source": [ "df.style.highlight_null().render().split('\\n')[:10]" @@ -160,9 +156,7 @@ { "cell_type": "code", "execution_count": null, - "metadata": { - "collapsed": true - }, + "metadata": {}, "outputs": [], "source": [ "s = df.style.applymap(color_negative_red)\n", @@ -208,9 +202,7 @@ { "cell_type": "code", "execution_count": null, - "metadata": { - "collapsed": true - }, + "metadata": {}, "outputs": [], "source": [ "df.style.apply(highlight_max)" @@ -234,9 +226,7 @@ { "cell_type": "code", "execution_count": null, - "metadata": { - "collapsed": true - }, + "metadata": {}, "outputs": [], "source": [ "df.style.\\\n", @@ -290,9 +280,7 @@ { "cell_type": "code", "execution_count": null, - "metadata": { - "collapsed": true - }, + "metadata": {}, "outputs": [], "source": [ "df.style.apply(highlight_max, color='darkorange', axis=None)" @@ -340,9 +328,7 @@ { "cell_type": "code", "execution_count": null, - "metadata": { - "collapsed": true - }, + "metadata": {}, "outputs": [], "source": [ "df.style.apply(highlight_max, subset=['B', 'C', 'D'])" @@ -358,9 +344,7 @@ { "cell_type": "code", "execution_count": null, - "metadata": { - "collapsed": true - }, + "metadata": {}, "outputs": [], "source": [ "df.style.applymap(color_negative_red,\n", @@ -393,9 +377,7 @@ { "cell_type": "code", "execution_count": null, - "metadata": { - "collapsed": true - }, + "metadata": {}, "outputs": [], "source": [ "df.style.format(\"{:.2%}\")" @@ -411,9 +393,7 @@ { "cell_type": "code", "execution_count": null, - "metadata": { - "collapsed": true - }, + "metadata": {}, "outputs": [], "source": [ "df.style.format({'B': \"{:0<4.0f}\", 'D': '{:+.2f}'})" @@ -429,9 +409,7 @@ { "cell_type": "code", "execution_count": null, - "metadata": { - "collapsed": true - }, + "metadata": {}, "outputs": [], "source": [ "df.style.format({\"B\": lambda x: \"±{:.2f}\".format(abs(x))})" @@ -454,9 +432,7 @@ { "cell_type": "code", "execution_count": null, - "metadata": { - "collapsed": true - }, + "metadata": {}, "outputs": [], "source": [ "df.style.highlight_null(null_color='red')" @@ -472,9 +448,7 @@ { "cell_type": "code", "execution_count": null, - "metadata": { - "collapsed": true - }, + "metadata": {}, "outputs": [], "source": [ "import seaborn as sns\n", @@ -495,9 +469,7 @@ { "cell_type": "code", "execution_count": null, - "metadata": { - "collapsed": true - }, + "metadata": {}, "outputs": [], "source": [ "# Uses the full color range\n", @@ -507,9 +479,7 @@ { "cell_type": "code", "execution_count": null, - "metadata": { - "collapsed": true - }, + "metadata": {}, "outputs": [], "source": [ "# Compress the color range\n", @@ -529,9 +499,7 @@ { "cell_type": "code", "execution_count": null, - "metadata": { - "collapsed": true - }, + "metadata": {}, "outputs": [], "source": [ "df.style.bar(subset=['A', 'B'], color='#d65f5f')" @@ -547,9 +515,7 @@ { "cell_type": "code", "execution_count": null, - "metadata": { - "collapsed": true - }, + "metadata": {}, "outputs": [], "source": [ "df.style.highlight_max(axis=0)" @@ -558,9 +524,7 @@ { "cell_type": "code", "execution_count": null, - "metadata": { - "collapsed": true - }, + "metadata": {}, "outputs": [], "source": [ "df.style.highlight_min(axis=0)" @@ -576,9 +540,7 @@ { "cell_type": "code", "execution_count": null, - "metadata": { - "collapsed": true - }, + "metadata": {}, "outputs": [], "source": [ "df.style.set_properties(**{'background-color': 'black',\n", @@ -603,9 +565,7 @@ { "cell_type": "code", "execution_count": null, - "metadata": { - "collapsed": true - }, + "metadata": {}, "outputs": [], "source": [ "df2 = -df\n", @@ -616,9 +576,7 @@ { "cell_type": "code", "execution_count": null, - "metadata": { - "collapsed": true - }, + "metadata": {}, "outputs": [], "source": [ "style2 = df2.style\n", @@ -671,9 +629,7 @@ { "cell_type": "code", "execution_count": null, - "metadata": { - "collapsed": true - }, + "metadata": {}, "outputs": [], "source": [ "with pd.option_context('display.precision', 2):\n", @@ -693,9 +649,7 @@ { "cell_type": "code", "execution_count": null, - "metadata": { - "collapsed": true - }, + "metadata": {}, "outputs": [], "source": [ "df.style\\\n", @@ -728,9 +682,7 @@ { "cell_type": "code", "execution_count": null, - "metadata": { - "collapsed": true - }, + "metadata": {}, "outputs": [], "source": [ "df.style.set_caption('Colormaps, with a caption.')\\\n", @@ -756,9 +708,7 @@ { "cell_type": "code", "execution_count": null, - "metadata": { - "collapsed": true - }, + "metadata": {}, "outputs": [], "source": [ "from IPython.display import HTML\n", @@ -854,9 +804,7 @@ { "cell_type": "code", "execution_count": null, - "metadata": { - "collapsed": true - }, + "metadata": {}, "outputs": [], "source": [ "from IPython.html import widgets\n", @@ -892,16 +840,14 @@ { "cell_type": "code", "execution_count": null, - "metadata": { - "collapsed": true - }, + "metadata": {}, "outputs": [], "source": [ "np.random.seed(25)\n", "cmap = cmap=sns.diverging_palette(5, 250, as_cmap=True)\n", - "df = pd.DataFrame(np.random.randn(20, 25)).cumsum()\n", + "bigdf = pd.DataFrame(np.random.randn(20, 25)).cumsum()\n", "\n", - "df.style.background_gradient(cmap, axis=1)\\\n", + "bigdf.style.background_gradient(cmap, axis=1)\\\n", " .set_properties(**{'max-width': '80px', 'font-size': '1pt'})\\\n", " .set_caption(\"Hover to magify\")\\\n", " .set_precision(2)\\\n", @@ -924,29 +870,157 @@ "\n", "### Subclassing\n", "\n", - "This section contains a bit of information about the implementation of `Styler`.\n", - "Since the feature is so new all of this is subject to change, even more so than the end-use API.\n", - "\n", - "As users apply styles (via `.apply`, `.applymap` or one of the builtins), we don't actually calculate anything.\n", - "Instead, we append functions and arguments to a list `self._todo`.\n", - "When asked (typically in `.render` we'll walk through the list and execute each function (this is in `self._compute()`.\n", - "These functions update an internal `defaultdict(list)`, `self.ctx` which maps DataFrame row / column positions to CSS attribute, value pairs.\n", - "\n", - "We take the extra step through `self._todo` so that we can export styles and set them on other `Styler`s.\n", - "\n", - "Rendering uses [Jinja](http://jinja.pocoo.org/) templates.\n", - "The `.translate` method takes `self.ctx` and builds another dictionary ready to be passed into `Styler.template.render`, the Jinja template.\n", - "\n", - "\n", - "### Alternate templates\n", - "\n", - "We've used [Jinja](http://jinja.pocoo.org/) templates to build up the HTML.\n", - "The template is stored as a class variable ``Styler.template.``. Subclasses can override that.\n", + "If the default template doesn't quite suit your needs, you can subclass Styler and extend or override the template.\n", + "We'll show an example of extending the default template to insert a custom header before each table." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "collapsed": true + }, + "outputs": [], + "source": [ + "from jinja2 import Environment, ChoiceLoader, FileSystemLoader\n", + "from IPython.display import HTML\n", + "from pandas.io.api import Styler" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "%mkdir templates" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "This next cell writes the custom template.\n", + "We extend the template `html.tpl`, which comes with pandas." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "%%file templates/myhtml.tpl\n", + "{% extends \"html.tpl\" %}\n", + "{% block table %}\n", + "

{{ table_title|default(\"My Table\") }}

\n", + "{{ super() }}\n", + "{% endblock table %}" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Now that we've created a template, we need to set up a subclass of ``pd.Styler`` that\n", + "knows about it." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "collapsed": true + }, + "outputs": [], + "source": [ + "class MyStyler(pd.Styler):\n", + " env = Environment(\n", + " loader=ChoiceLoader([\n", + " FileSystemLoader(\"templates\"), # contains ours\n", + " pd.Styler.loader, # the default\n", + " ])\n", + " )\n", + " template = env.get_template(\"myhtml.tpl\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Notice that we include the original loader in our environment's loader.\n", + "That's because we extend the original template, so the Jinja environment needs\n", + "to be able to find it.\n", "\n", - "```python\n", - "class CustomStyle(Styler):\n", - " template = Template(\"\"\"...\"\"\")\n", - "```" + "Now we can use that custom styler. It's `__init__` takes a DataFrame." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "MyStyler(df)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Our custom template accepts a `table_title` keyword. We can provide the value in the `.render` method." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "HTML(MyStyler(df).render(table_title=\"Extending Example\"))" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "For convenience, we provide the `Styler.from_custom_template` method that does the same as the custom subclass." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "EasyStyler = pd.Styler.from_custom_template(\"templates\", \"myhtml.tpl\")\n", + "EasyStyler(df)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Here's the template structure:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "with open(\"template_structure.html\") as f:\n", + " structure = f.read()\n", + " \n", + "HTML(structure)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "See the template in the [GitHub repo](https://github.com/pandas-dev/pandas) for more details." ] } ], diff --git a/doc/source/template_structure.html b/doc/source/template_structure.html new file mode 100644 index 0000000000000..81dbe2b7d0217 --- /dev/null +++ b/doc/source/template_structure.html @@ -0,0 +1,60 @@ + + + +
before_style
+
style +
<style type="text/css">
+
table_styles
+
before_cellstyle
+
cellstyle
+
</style>
+
+ +
before_table
+ +
table +
<table ...>
+
caption
+ +
thead +
before_head_rows
+
head_tr (loop over headers)
+
after_head_rows
+
+ +
tbody +
before_rows
+
tr (loop over data rows)
+
after_rows
+
+
</table>
+
+ +
after_table
diff --git a/doc/source/whatsnew/v0.20.0.txt b/doc/source/whatsnew/v0.20.0.txt index da32de750e7de..ffbc4d0e9796c 100644 --- a/doc/source/whatsnew/v0.20.0.txt +++ b/doc/source/whatsnew/v0.20.0.txt @@ -482,6 +482,9 @@ Other Enhancements - ``DataFrame.to_excel()`` has a new ``freeze_panes`` parameter to turn on Freeze Panes when exporting to Excel (:issue:`15160`) - ``pd.read_html()`` will parse multiple header rows, creating a multiindex header. (:issue:`13434`). - HTML table output skips ``colspan`` or ``rowspan`` attribute if equal to 1. (:issue:`15403`) +- ``pd.io.api.Styler`` template now has blocks for easier extension, :ref:`see the example notebook ` (:issue:`15649`) +- ``pd.io.api.Styler.render`` now accepts ``**kwargs`` to allow user-defined variables in the template (:issue:`15649`) + - ``pd.TimedeltaIndex`` now has a custom datetick formatter specifically designed for nanosecond level precision (:issue:`8711`) - ``pd.types.concat.union_categoricals`` gained the ``ignore_ordered`` argument to allow ignoring the ordered attribute of unioned categoricals (:issue:`13410`). See the :ref:`categorical union docs ` for more information. diff --git a/pandas/formats/style.py b/pandas/formats/style.py index e712010a8b4f2..af02077bd5b41 100644 --- a/pandas/formats/style.py +++ b/pandas/formats/style.py @@ -10,7 +10,9 @@ from collections import defaultdict, MutableMapping try: - from jinja2 import Template + from jinja2 import ( + PackageLoader, Environment, ChoiceLoader, FileSystemLoader + ) except ImportError: msg = "pandas.Styler requires jinja2. "\ "Please install with `conda install Jinja2`\n"\ @@ -68,7 +70,9 @@ class Styler(object): Attributes ---------- - template: Jinja Template + env : Jinja2 Environment + template: Jinja2 Template + loader : Jinja2 Loader Notes ----- @@ -103,56 +107,12 @@ class Styler(object): -------- pandas.DataFrame.style """ - template = Template(""" - - - - {% if caption %} - - {% endif %} - - - {% for r in head %} - - {% for c in r %} - {% if c.is_visible != False %} - <{{c.type}} class="{{c.class}}" {{ c.attributes|join(" ") }}> - {{c.value}} - {% endif %} - {% endfor %} - - {% endfor %} - - - {% for r in body %} - - {% for c in r %} - {% if c.is_visible != False %} - <{{c.type}} id="T_{{uuid}}{{c.id}}" - class="{{c.class}}" {{ c.attributes|join(" ") }}> - {{ c.display_value }} - {% endif %} - {% endfor %} - - {% endfor %} - -
{{caption}}
- """) + loader = PackageLoader("pandas", "formats/templates") + env = Environment( + loader=loader, + trim_blocks=True, + ) + template = env.get_template("html.tpl") def __init__(self, data, precision=None, table_styles=None, uuid=None, caption=None, table_attributes=None): @@ -400,12 +360,22 @@ def format(self, formatter, subset=None): self._display_funcs[(i, j)] = formatter return self - def render(self): - """ + def render(self, **kwargs): + r""" Render the built up styles to HTML .. versionadded:: 0.17.1 + Parameters + ---------- + **kwargs: + Any additional keyword arguments are passed through + to ``self.template.render``. This is useful when you + need to provide additional variables for a custom + template. + + .. versionadded:: 0.20 + Returns ------- rendered: str @@ -418,8 +388,22 @@ def render(self): last item in a Notebook cell. When calling ``Styler.render()`` directly, wrap the result in ``IPython.display.HTML`` to view the rendered HTML in the notebook. + + Pandas uses the following keys in render. Arguments passed + in ``**kwargs`` take precedence, so think carefuly if you want + to override them: + + * head + * cellstyle + * body + * uuid + * precision + * table_styles + * caption + * table_attributes """ self._compute() + # TODO: namespace all the pandas keys d = self._translate() # filter out empty styles, every cell will have a class # but the list of props may just be [['', '']]. @@ -427,6 +411,7 @@ def render(self): trimmed = [x for x in d['cellstyle'] if any(any(y) for y in x['props'])] d['cellstyle'] = trimmed + d.update(kwargs) return self.template.render(**d) def _update_ctx(self, attrs): @@ -961,6 +946,35 @@ def _highlight_extrema(data, color='yellow', max_=True): return pd.DataFrame(np.where(extrema, attr, ''), index=data.index, columns=data.columns) + @classmethod + def from_custom_template(cls, searchpath, name): + """ + Factory function for creating a subclass of ``Styler`` + with a custom template and Jinja environment. + + Parameters + ---------- + searchpath : str or list + Path or paths of directories containing the templates + name : str + Name of your custom template to use for rendering + + Returns + ------- + MyStyler : subclass of Styler + has the correct ``env`` and ``template`` class attributes set. + """ + loader = ChoiceLoader([ + FileSystemLoader(searchpath), + cls.loader, + ]) + + class MyStyler(cls): + env = Environment(loader=loader) + template = env.get_template(name) + + return MyStyler + def _is_visible(idx_row, idx_col, lengths): """ diff --git a/pandas/formats/templates/html.tpl b/pandas/formats/templates/html.tpl new file mode 100644 index 0000000000000..706db1ecdd961 --- /dev/null +++ b/pandas/formats/templates/html.tpl @@ -0,0 +1,70 @@ +{# Update the template_structure.html document too #} +{%- block before_style -%}{%- endblock before_style -%} +{% block style %} + +{%- endblock style %} +{%- block before_table %}{% endblock before_table %} +{%- block table %} + +{%- block caption %} +{%- if caption -%} + +{%- endif -%} +{%- endblock caption %} +{%- block thead %} + + {%- block before_head_rows %}{% endblock %} + {%- for r in head %} + {%- block head_tr scoped %} + + {%- for c in r %} + {%- if c.is_visible != False %} + <{{ c.type }} class="{{c.class}}" {{ c.attributes|join(" ") }}>{{c.value}} + {%- endif %} + {%- endfor %} + + {%- endblock head_tr %} + {%- endfor %} + {%- block after_head_rows %}{% endblock %} + +{%- endblock thead %} +{%- block tbody %} + + {%- block before_rows %}{%- endblock before_rows %} + {%- for r in body %} + {%- block tr scoped %} + + {%- for c in r %} + {%- if c.is_visible != False %} + <{{ c.type }} id="T_{{ uuid }}{{ c.id }}" class="{{ c.class }}" {{ c.attributes|join(" ") }}>{{ c.display_value }} + {%- endif %} + {%- endfor %} + + {%- endblock tr %} + {%- endfor %} + {%- block after_rows %}{%- endblock after_rows %} + +{%- endblock tbody %} +
{{caption}}
+{%- endblock table %} +{%- block after_table %}{% endblock after_table %} diff --git a/pandas/io/api.py b/pandas/io/api.py index e312e7bc2f300..4744d41472ff1 100644 --- a/pandas/io/api.py +++ b/pandas/io/api.py @@ -17,6 +17,23 @@ from pandas.io.pickle import read_pickle, to_pickle from pandas.io.packers import read_msgpack, to_msgpack from pandas.io.gbq import read_gbq +try: + from pandas.formats.style import Styler +except ImportError: + from pandas.compat import add_metaclass as _add_metaclass + from pandas.util.importing import _UnSubclassable + + # We want to *not* raise an ImportError upon importing this module + # We *do* want to raise an ImportError with a custom message + # when the class is instantiated or subclassed. + @_add_metaclass(_UnSubclassable) + class Styler(object): + msg = ("pandas.io.api.Styler requires jinja2. " + "Please install with `conda install jinja2` " + "or `pip install jinja2`") + def __init__(self, *args, **kargs): + raise ImportError(self.msg) + # deprecation, xref #13790 def Term(*args, **kwargs): diff --git a/pandas/tests/api/test_api.py b/pandas/tests/api/test_api.py index a15d7cf26cbea..6d92898042b23 100644 --- a/pandas/tests/api/test_api.py +++ b/pandas/tests/api/test_api.py @@ -49,7 +49,8 @@ class TestPDApi(Base, tm.TestCase): 'Period', 'PeriodIndex', 'RangeIndex', 'UInt64Index', 'Series', 'SparseArray', 'SparseDataFrame', 'SparseSeries', 'TimeGrouper', 'Timedelta', - 'TimedeltaIndex', 'Timestamp', 'Interval', 'IntervalIndex'] + 'TimedeltaIndex', 'Timestamp', 'Interval', 'IntervalIndex', + 'Styler'] # these are already deprecated; awaiting removal deprecated_classes = ['WidePanel', 'Panel4D', diff --git a/pandas/tests/formats/test_style.py b/pandas/tests/formats/test_style.py index 44af0b8ebb085..08f8f2f32763d 100644 --- a/pandas/tests/formats/test_style.py +++ b/pandas/tests/formats/test_style.py @@ -1,6 +1,7 @@ -import pytest - import copy +import textwrap + +import pytest import numpy as np import pandas as pd from pandas import DataFrame @@ -717,3 +718,32 @@ def test_background_gradient(self): result = (df.style.background_gradient(subset=pd.IndexSlice[1, 'A']) ._compute().ctx) self.assertEqual(result[(1, 0)], ['background-color: #fff7fb']) + + +def test_block_names(): + # catch accidental removal of a block + expected = { + 'before_style', 'style', 'table_styles', 'before_cellstyle', + 'cellstyle', 'before_table', 'table', 'caption', 'thead', 'tbody', + 'after_table', 'before_head_rows', 'head_tr', 'after_head_rows', + 'before_rows', 'tr', 'after_rows', + } + result = set(Styler.template.blocks) + assert result == expected + + +def test_from_custom_template(tmpdir): + p = tmpdir.mkdir("templates").join("myhtml.tpl") + p.write(textwrap.dedent("""\ + {% extends "html.tpl" %} + {% block table %} +

{{ table_title|default("My Table") }}

+ {{ super() }} + {% endblock table %}""")) + result = Styler.from_custom_template(str(tmpdir.join('templates')), + 'myhtml.tpl') + assert issubclass(result, Styler) + assert result.env is not Styler.env + assert result.template is not Styler.template + styler = result(pd.DataFrame({"A": [1, 2]})) + assert styler.render() diff --git a/pandas/util/importing.py b/pandas/util/importing.py new file mode 100644 index 0000000000000..9323fb97baac0 --- /dev/null +++ b/pandas/util/importing.py @@ -0,0 +1,10 @@ +class _UnSubclassable(type): + """ + Metaclass to raise an ImportError when subclassed + """ + msg = "" + + def __init__(cls, name, bases, clsdict): + if len(cls.mro()) > 2: + raise ImportError(cls.msg) + super(_UnSubclassable, cls).__init__(name, bases, clsdict) diff --git a/setup.py b/setup.py index 6707af7eb0908..d8ee52f9b4f43 100755 --- a/setup.py +++ b/setup.py @@ -704,7 +704,8 @@ def pxd(name): 'data/html_encoding/*.html', 'json/data/*.json'], 'pandas.tests.tools': ['data/*.csv'], - 'pandas.tests.tseries': ['data/*.pickle'] + 'pandas.tests.tseries': ['data/*.pickle'], + 'pandas.formats': ['templates/*.tpl'] }, ext_modules=extensions, maintainer_email=EMAIL,