Repository: pandas-dev/pandas

Issue:
ENH: Upgrade Excel I/O module to use OpenPyXL 2
[OpenPyXL](https://pypi.python.org/pypi/openpyxl) v2.0 was released on May 13th, breaking backwards compatibility in some parts of its API, including those upon which pandas depends. Notably, v2 changes [the style API](https://openpyxl.readthedocs.org/en/2.0.2/styles.html) and makes `Style` objects immutable. This incompatibility is evident at 0.14.0-rc1 in [`pandas.io.excel._OpenpyxlWriter._convert_to_style`](https://github.com/pydata/pandas/blob/v0.14.0rc1/pandas/io/excel.py#L600) and the associated test case, [`pandas.io.tests.test_excel.OpenyxlTests.test_to_excel_styleconverter`](https://github.com/pydata/pandas/blob/v0.14.0rc1/pandas/io/tests/test_excel.py#L1131).

In addition to teaching `pandas` how to use the new API, the project may wish to retain support for the OpenPyXL v1 API, necessitating the design and development of a compatibility shim.



Discussion:
dupe of #7169
would love to have u work on it!

py3.4 build already has 2.02 (and so fails)

Could you re-open this issue and reassign it to the 0.14.1 milestone?

any thoughts on this? (getting it to work with >= 2.0)

I've been spending my pandas time allocation on [this](https://bugs.freebsd.org/bugzilla/show_bug.cgi?id=190850).

My thoughts, in case anybody else is driven to work on this before I can get to it, are as follows (caveat: I haven't thought about this for a couple weeks, so my recollection about some of these details may betray me).

There appear to be two main pieces to this feature. One is simply the conversion of a style dict to a native openpyxl `Style` object. This should be fairly rote, and by implementing to the new-style `Style` protocol, which is the only option in v2, backwards compatibility with v1 will be quite tractable, because it offers both old- and new-style protocols for forwards compatibility.

There are some differences in the structure of the `Style` class from v1 to v2, which pandas will have to handle. My preference would be to try, but not very hard, to map whatever style information the user passes onto the underlying representation, and to attach commentary about the openpyxl limitations we know of to whatever exceptions bubble up. However, I don't know how people tend to use this, and I haven't yet found a clear spec in the pandas docs for an engine-agnostic, user-facing style API (maybe there isn't and shouldn't be one).

The other main piece is the merge behavior. I've had more trouble wrapping my head around how this should work. The new-style `Style` class has a `copy` method that accepts coarse substitutions. For example, conceptually if not correct syntactically, consider this snippet.

```
from openpyxl.style import *
s1 = Style(font=Font(name='Helvetica'), color=Color(rgb='00FF0000'))
s2 = s1.copy(font=Font(sz=12))
```

`s2` will get the red `Color` object from `s1` but its `font` attribute will have been overridden with a completely new `Font` object that has the default face, `'Calibri'`, at a non-default size, 12 pt. Make sense?

I think this is a fine set of semantics to adopt for the internal style merging behavior of pandas, because it keeps the padding between the user and the engine implementation thin. I'm not sure what that implies for the openpyxl v1 case. At the least, it may be convenient to model a compatibility shim as a backport of the `copy` method applied to v1 `Style` objects.

It occurred to me belatedly to wonder, since I have never used `pandas.io.excel` or its main consumer, the `to_excel` method, whether the styling capability of `ExcelWriter` is really intended to be user facing? The [documentation](https://github.com/pydata/pandas/blob/master/doc/source/io.rst#excel-files) does not mention it, though the [docstring for the `ExcelWriter.write_cells` method](https://github.com/pydata/pandas/blob/v0.14.0/pandas/io/excel.py#L448-L459) clearly indicates acceptance of formatted (i.e. styled) cells. In what follows, I will be assuming that this is supposed to be a user-facing capability that is simply not yet documented as such. If it isn't, please say so because it might provide some useful wiggle room to address the other areas where I'm getting stuck.

The other thing I finally realized I didn't really know for sure, and need to, is whether the style dict those formatted cells carry around is required to be engine-agnostic, or if it can vary from engine to engine. I guessed that the intention was the former, and the uniformity of the usage examples --- [the only internal user in `pandas.core.format`](https://github.com/pydata/pandas/blob/v0.14.0/pandas/core/format.py#L1450-L1455) and the applicable test cases for the [openpyxl](https://github.com/pydata/pandas/blob/v0.14.0/pandas/io/tests/test_excel.py#L1102-L1127) and [xlwt](https://github.com/pydata/pandas/blob/v0.14.0/pandas/io/tests/test_excel.py#L1135-L1154) engines --- seem to substantiate my guess. However, the style dicts in the test cases are insufficient for me to tell what I should write if I were to write a style dict spec/doc in the spirit of earlier authors. For example, I can tell that the spec has to allow the font to be set to bold or not, but I cannot tell whether font colors are to be supported, and, if so, how they are to be encoded.

If it were up to me, I think I would choose a very limited subset of all possible styling available in the underlying Excel, and call that the engine-agnostic, `pandas` dialect. By making the subset small and simple, the task gets easier of mapping it onto the supported versions of the existing engine libraries and any future versions or new engines. As I said, I don't know how users tend to use `pandas.io.excel` so I'm basing this judgement on the notion that somebody who doesn't wish to specify a particular engine, probably won't wish to specify a great deal of formatting information either, if any.

To accommodate users who _do_ want to exert a great deal of control over cell styling, an additional, separable feature could be to teach `ExcelWriter.write_cells` to accept cells that come with attached style objects that are native to the selected engine. That is, if you want access to the full styling capability of the underlying library, then you have to build the style objects yourself instead of describing them to `pandas` as dicts.

A related question. Does pandas have some sort of policy, roadmap, or procedure for determining version support? That is, how will we know when we can drop support for the old version of openpyxl?

I welcome discussion or even just direction.

to your last points

we try maintain support for as long as possible with dep libraries until it becomes too painful :) 

if it's easy enough then no biggie

openpyxl 1.6 I think is the min version but only. 1 year old I think

it's becoming pretty easy IMHO to upgrade individual deps easily

so if their is a recommendation to drop support for something we can discuss

note that pandas does still supports numpy 1.6.1 (and this is painful!)

@jreback I'm not suggesting that we drop support for anything at the moment. I'm just trying to get a sense of how long the life of an openpyxl v1/v2 compatibility shim might be.

one way to do this might be to make a openpyxl_v2 engine (and then depending on the installation choose one or the other) 
that way u can rebuild the API as needed

Yes, that's a good point. Splitting the implementation at the engine level would probably be better. I still think that we need a spec and documentation for the style dict, both of which would be informed by whether it is intended for internal use only, or is an external feature that we also use internally.


Resolving patch:
diff --git a/pandas/compat/openpyxl_compat.py b/pandas/compat/openpyxl_compat.py
--- a/pandas/compat/openpyxl_compat.py
+++ b/pandas/compat/openpyxl_compat.py
@@ -10,15 +10,26 @@
 stop_ver = '2.0.0'
 
 
-def is_compat():
-    """Detect whether the installed version of openpyxl is supported.
+def is_compat(major_ver=1):
+    """Detect whether the installed version of openpyxl is supported
 
+    Parameters
+    ----------
+    ver : int
+        1 requests compatibility status among the 1.x.y series
+        2 requests compatibility status of 2.0.0 and later
     Returns
     -------
     compat : bool
-        ``True`` if openpyxl is installed and is between versions 1.6.1 and
-        2.0.0, ``False`` otherwise.
+        ``True`` if openpyxl is installed and is a compatible version.
+        ``False`` otherwise.
     """
     import openpyxl
     ver = LooseVersion(openpyxl.__version__)
-    return LooseVersion(start_ver) <= ver < LooseVersion(stop_ver)
+    if major_ver == 1:
+        return LooseVersion(start_ver) <= ver < LooseVersion(stop_ver)
+    elif major_ver == 2:
+        return LooseVersion(stop_ver) <= ver
+    else:
+        raise ValueError('cannot test for openpyxl compatibility with ver {0}'
+                .format(major_ver))
diff --git a/pandas/io/excel.py b/pandas/io/excel.py
--- a/pandas/io/excel.py
+++ b/pandas/io/excel.py
@@ -46,6 +46,20 @@ def register_writer(klass):
 
 
 def get_writer(engine_name):
+    if engine_name == 'openpyxl':
+        try:
+            import openpyxl
+
+            # with version-less openpyxl engine
+            # make sure we make the intelligent choice for the user
+            if LooseVersion(openpyxl.__version__) < '2.0.0':
+                 return _writers['openpyxl1']
+            else:
+                 return _writers['openpyxl2']
+        except ImportError:
+            # fall through to normal exception handling below
+            pass
+
     try:
         return _writers[engine_name]
     except KeyError:
@@ -527,20 +541,20 @@ def close(self):
         return self.save()
 
 
-class _OpenpyxlWriter(ExcelWriter):
-    engine = 'openpyxl'
+class _Openpyxl1Writer(ExcelWriter):
+    engine = 'openpyxl1'
     supported_extensions = ('.xlsx', '.xlsm')
+    openpyxl_majorver = 1
 
     def __init__(self, path, engine=None, **engine_kwargs):
-        if not openpyxl_compat.is_compat():
+        if not openpyxl_compat.is_compat(major_ver=self.openpyxl_majorver):
             raise ValueError('Installed openpyxl is not supported at this '
-                             'time. Use >={0} and '
-                             '<{1}.'.format(openpyxl_compat.start_ver,
-                                            openpyxl_compat.stop_ver))
+                             'time. Use {0}.x.y.'
+                             .format(self.openpyxl_majorver))
         # Use the openpyxl module as the Excel writer.
         from openpyxl.workbook import Workbook
 
-        super(_OpenpyxlWriter, self).__init__(path, **engine_kwargs)
+        super(_Openpyxl1Writer, self).__init__(path, **engine_kwargs)
 
         # Create workbook object with default optimized_write=True.
         self.book = Workbook()
@@ -632,9 +646,427 @@ def _convert_to_style(cls, style_dict):
 
         return xls_style
 
+register_writer(_Openpyxl1Writer)
+
+
+class _OpenpyxlWriter(_Openpyxl1Writer):
+    engine = 'openpyxl'
+
 register_writer(_OpenpyxlWriter)
 
 
+class _Openpyxl2Writer(_Openpyxl1Writer):
+    """
+    Note: Support for OpenPyxl v2 is currently EXPERIMENTAL (GH7565).
+    """
+    engine = 'openpyxl2'
+    openpyxl_majorver = 2
+
+    def write_cells(self, cells, sheet_name=None, startrow=0, startcol=0):
+        # Write the frame cells using openpyxl.
+        from openpyxl.cell import get_column_letter
+
+        sheet_name = self._get_sheet_name(sheet_name)
+
+        if sheet_name in self.sheets:
+            wks = self.sheets[sheet_name]
+        else:
+            wks = self.book.create_sheet()
+            wks.title = sheet_name
+            self.sheets[sheet_name] = wks
+
+        for cell in cells:
+            colletter = get_column_letter(startcol + cell.col + 1)
+            xcell = wks.cell("%s%s" % (colletter, startrow + cell.row + 1))
+            xcell.value = _conv_value(cell.val)
+            style_kwargs = {}
+
+            # Apply format codes before cell.style to allow override
+            if isinstance(cell.val, datetime.datetime):
+                style_kwargs.update(self._convert_to_style_kwargs({
+                        'number_format':{'format_code': self.datetime_format}}))
+            elif isinstance(cell.val, datetime.date):
+                style_kwargs.update(self._convert_to_style_kwargs({
+                        'number_format':{'format_code': self.date_format}}))
+
+            if cell.style:
+                style_kwargs.update(self._convert_to_style_kwargs(cell.style))
+
+            if style_kwargs:
+                xcell.style = xcell.style.copy(**style_kwargs)
+
+            if cell.mergestart is not None and cell.mergeend is not None:
+                cletterstart = get_column_letter(startcol + cell.col + 1)
+                cletterend = get_column_letter(startcol + cell.mergeend + 1)
+
+                wks.merge_cells('%s%s:%s%s' % (cletterstart,
+                                               startrow + cell.row + 1,
+                                               cletterend,
+                                               startrow + cell.mergestart + 1))
+
+                # Excel requires that the format of the first cell in a merged
+                # range is repeated in the rest of the merged range.
+                if style:
+                    first_row = startrow + cell.row + 1
+                    last_row = startrow + cell.mergestart + 1
+                    first_col = startcol + cell.col + 1
+                    last_col = startcol + cell.mergeend + 1
+
+                    for row in range(first_row, last_row + 1):
+                        for col in range(first_col, last_col + 1):
+                            if row == first_row and col == first_col:
+                                # Ignore first cell. It is already handled.
+                                continue
+                            colletter = get_column_letter(col)
+                            xcell = wks.cell("%s%s" % (colletter, row))
+                            xcell.style = xcell.style.copy(**style_kwargs)
+
+    @classmethod
+    def _convert_to_style_kwargs(cls, style_dict):
+        """
+        Convert a style_dict to a set of kwargs suitable for initializing
+        or updating-on-copy an openpyxl v2 style object
+        Parameters
+        ----------
+        style_dict : dict
+            A dict with zero or more of the following keys (or their synonyms).
+                'font'
+                'fill'
+                'border' ('borders')
+                'alignment'
+                'number_format'
+                'protection'
+        Returns
+        -------
+        style_kwargs : dict
+            A dict with the same, normalized keys as ``style_dict`` but each
+            value has been replaced with a native openpyxl style object of the
+            appropriate class.
+        """
+
+        _style_key_map = {
+            'borders': 'border',
+        }
+
+        style_kwargs = {}
+        for k, v in style_dict.items():
+            if k in _style_key_map:
+                k = _style_key_map[k]
+            _conv_to_x = getattr(cls, '_convert_to_{0}'.format(k),
+                    lambda x: None)
+            new_v = _conv_to_x(v)
+            if new_v:
+                style_kwargs[k] = new_v
+
+        return style_kwargs
+
+
+    @classmethod
+    def _convert_to_color(cls, color_spec):
+        """
+        Convert ``color_spec`` to an openpyxl v2 Color object
+        Parameters
+        ----------
+        color_spec : str, dict
+            A 32-bit ARGB hex string, or a dict with zero or more of the
+            following keys.
+                'rgb'
+                'indexed'
+                'auto'
+                'theme'
+                'tint'
+                'index'
+                'type'
+        Returns
+        -------
+        color : openpyxl.styles.Color
+        """
+
+        from openpyxl.styles import Color
+
+        if isinstance(color_spec, str):
+            return Color(color_spec)
+        else:
+            return Color(**color_spec)
+
+
+    @classmethod
+    def _convert_to_font(cls, font_dict):
+        """
+        Convert ``font_dict`` to an openpyxl v2 Font object
+        Parameters
+        ----------
+        font_dict : dict
+            A dict with zero or more of the following keys (or their synonyms).
+                'name'
+                'size' ('sz')
+                'bold' ('b')
+                'italic' ('i')
+                'underline' ('u')
+                'strikethrough' ('strike')
+                'color'
+                'vertAlign' ('vertalign')
+                'charset'
+                'scheme'
+                'family'
+                'outline'
+                'shadow'
+                'condense'
+        Returns
+        -------
+        font : openpyxl.styles.Font
+        """
+
+        from openpyxl.styles import Font
+
+        _font_key_map = {
+            'sz': 'size',
+            'b': 'bold',
+            'i': 'italic',
+            'u': 'underline',
+            'strike': 'strikethrough',
+            'vertalign': 'vertAlign',
+        }
+
+        font_kwargs = {}
+        for k, v in font_dict.items():
+            if k in _font_key_map:
+                k = _font_key_map[k]
+            if k == 'color':
+                v = cls._convert_to_color(v)
+            font_kwargs[k] = v
+
+        return Font(**font_kwargs)
+
+
+    @classmethod
+    def _convert_to_stop(cls, stop_seq):
+        """
+        Convert ``stop_seq`` to a list of openpyxl v2 Color objects,
+        suitable for initializing the ``GradientFill`` ``stop`` parameter.
+        Parameters
+        ----------
+        stop_seq : iterable
+            An iterable that yields objects suitable for consumption by
+            ``_convert_to_color``.
+        Returns
+        -------
+        stop : list of openpyxl.styles.Color
+        """
+
+        return map(cls._convert_to_color, stop_seq)
+
+
+    @classmethod
+    def _convert_to_fill(cls, fill_dict):
+        """
+        Convert ``fill_dict`` to an openpyxl v2 Fill object
+        Parameters
+        ----------
+        fill_dict : dict
+            A dict with one or more of the following keys (or their synonyms),
+                'fill_type' ('patternType', 'patterntype')
+                'start_color' ('fgColor', 'fgcolor')
+                'end_color' ('bgColor', 'bgcolor')
+            or one or more of the following keys (or their synonyms).
+                'type' ('fill_type')
+                'degree'
+                'left'
+                'right'
+                'top'
+                'bottom'
+                'stop'
+        Returns
+        -------
+        fill : openpyxl.styles.Fill
+        """
+
+        from openpyxl.styles import PatternFill, GradientFill
+
+        _pattern_fill_key_map = {
+            'patternType': 'fill_type',
+            'patterntype': 'fill_type',
+            'fgColor': 'start_color',
+            'fgcolor': 'start_color',
+            'bgColor': 'end_color',
+            'bgcolor': 'end_color',
+        }
+
+        _gradient_fill_key_map = {
+            'fill_type': 'type',
+        }
+
+        pfill_kwargs = {}
+        gfill_kwargs = {}
+        for k, v in fill_dict.items():
+            pk = gk = None
+            if k in _pattern_fill_key_map:
+                pk = _pattern_fill_key_map[k]
+            if k in _gradient_fill_key_map:
+                gk = _gradient_fill_key_map[k]
+            if pk in ['start_color', 'end_color']:
+                v = cls._convert_to_color(v)
+            if gk == 'stop':
+                v = cls._convert_to_stop(v)
+            if pk:
+                pfill_kwargs[pk] = v
+            elif gk:
+                gfill_kwargs[gk] = v
+            else:
+                pfill_kwargs[k] = v
+                gfill_kwargs[k] = v
+
+        try:
+            return PatternFill(**pfill_kwargs)
+        except TypeError:
+            return GradientFill(**gfill_kwargs)
+
+
+    @classmethod
+    def _convert_to_side(cls, side_spec):
+        """
+        Convert ``side_spec`` to an openpyxl v2 Side object
+        Parameters
+        ----------
+        side_spec : str, dict
+            A string specifying the border style, or a dict with zero or more
+            of the following keys (or their synonyms).
+                'style' ('border_style')
+                'color'
+        Returns
+        -------
+        side : openpyxl.styles.Side
+        """
+
+        from openpyxl.styles import Side
+
+        _side_key_map = {
+            'border_style': 'style',
+        }
+
+        if isinstance(side_spec, str):
+            return Side(style=side_spec)
+
+        side_kwargs = {}
+        for k, v in side_spec.items():
+            if k in _side_key_map:
+                k = _side_key_map[k]
+            if k == 'color':
+                v = cls._convert_to_color(v)
+            side_kwargs[k] = v
+
+        return Side(**side_kwargs)
+
+
+    @classmethod
+    def _convert_to_border(cls, border_dict):
+        """
+        Convert ``border_dict`` to an openpyxl v2 Border object
+        Parameters
+        ----------
+        border_dict : dict
+            A dict with zero or more of the following keys (or their synonyms).
+                'left'
+                'right'
+                'top'
+                'bottom'
+                'diagonal'
+                'diagonal_direction'
+                'vertical'
+                'horizontal'
+                'diagonalUp' ('diagonalup')
+                'diagonalDown' ('diagonaldown')
+                'outline'
+        Returns
+        -------
+        border : openpyxl.styles.Border
+        """
+
+        from openpyxl.styles import Border
+
+        _border_key_map = {
+            'diagonalup': 'diagonalUp',
+            'diagonaldown': 'diagonalDown',
+        }
+
+        border_kwargs = {}
+        for k, v in border_dict.items():
+            if k in _border_key_map:
+                k = _border_key_map[k]
+            if k == 'color':
+                v = cls._convert_to_color(v)
+            if k in ['left', 'right', 'top', 'bottom', 'diagonal']:
+                v = cls._convert_to_side(v)
+            border_kwargs[k] = v
+
+        return Border(**border_kwargs)
+
+
+    @classmethod
+    def _convert_to_alignment(cls, alignment_dict):
+        """
+        Convert ``alignment_dict`` to an openpyxl v2 Alignment object
+        Parameters
+        ----------
+        alignment_dict : dict
+            A dict with zero or more of the following keys (or their synonyms).
+                'horizontal'
+                'vertical'
+                'text_rotation'
+                'wrap_text'
+                'shrink_to_fit'
+                'indent'
+        Returns
+        -------
+        alignment : openpyxl.styles.Alignment
+        """
+
+        from openpyxl.styles import Alignment
+
+        return Alignment(**alignment_dict)
+
+
+    @classmethod
+    def _convert_to_number_format(cls, number_format_dict):
+        """
+        Convert ``number_format_dict`` to an openpyxl v2 NumberFormat object.
+        Parameters
+        ----------
+        number_format_dict : dict
+            A dict with zero or more of the following keys.
+                'format_code'
+        Returns
+        -------
+        number_format : openpyxl.styles.NumberFormat
+        """
+
+        from openpyxl.styles import NumberFormat
+
+        return NumberFormat(**number_format_dict)
+
+
+    @classmethod
+    def _convert_to_protection(cls, protection_dict):
+        """
+        Convert ``protection_dict`` to an openpyxl v2 Protection object.
+        Parameters
+        ----------
+        protection_dict : dict
+            A dict with zero or more of the following keys.
+                'locked'
+                'hidden'
+        Returns
+        -------
+        """
+
+        from openpyxl.styles import Protection
+
+        return Protection(**protection_dict)
+
+
+register_writer(_Openpyxl2Writer)
+
+
 class _XlwtWriter(ExcelWriter):
     engine = 'xlwt'
     supported_extensions = ('.xls',)


Test patch:
diff --git a/pandas/io/tests/test_excel.py b/pandas/io/tests/test_excel.py
--- a/pandas/io/tests/test_excel.py
+++ b/pandas/io/tests/test_excel.py
@@ -17,8 +17,8 @@
 from pandas import DataFrame, Index, MultiIndex
 from pandas.io.parsers import read_csv
 from pandas.io.excel import (
-    ExcelFile, ExcelWriter, read_excel, _XlwtWriter, _OpenpyxlWriter,
-    register_writer, _XlsxWriter
+    ExcelFile, ExcelWriter, read_excel, _XlwtWriter, _Openpyxl1Writer,
+    _Openpyxl2Writer, register_writer, _XlsxWriter
 )
 from pandas.io.common import URLError
 from pandas.util.testing import ensure_clean
@@ -1127,35 +1127,39 @@ def test_swapped_columns(self):
             tm.assert_series_equal(write_frame['B'], read_frame['B'])
 
 
-def raise_wrapper(orig_method):
-    @functools.wraps(orig_method)
-    def wrapped(self, *args, **kwargs):
-        _skip_if_no_openpyxl()
-        if openpyxl_compat.is_compat():
-            orig_method(self, *args, **kwargs)
-        else:
-            msg = 'Installed openpyxl is not supported at this time\. Use.+'
-            with tm.assertRaisesRegexp(ValueError, msg):
+def raise_wrapper(major_ver):
+    def versioned_raise_wrapper(orig_method):
+        @functools.wraps(orig_method)
+        def wrapped(self, *args, **kwargs):
+            _skip_if_no_openpyxl()
+            if openpyxl_compat.is_compat(major_ver=major_ver):
                 orig_method(self, *args, **kwargs)
-    return wrapped
+            else:
+                msg = 'Installed openpyxl is not supported at this time\. Use.+'
+                with tm.assertRaisesRegexp(ValueError, msg):
+                    orig_method(self, *args, **kwargs)
+        return wrapped
+    return versioned_raise_wrapper
 
 
-def raise_on_incompat_version(cls):
-    methods = filter(operator.methodcaller('startswith', 'test_'), dir(cls))
-    for method in methods:
-        setattr(cls, method, raise_wrapper(getattr(cls, method)))
-    return cls
+def raise_on_incompat_version(major_ver):
+    def versioned_raise_on_incompat_version(cls):
+        methods = filter(operator.methodcaller('startswith', 'test_'), dir(cls))
+        for method in methods:
+            setattr(cls, method, raise_wrapper(major_ver)(getattr(cls, method)))
+        return cls
+    return versioned_raise_on_incompat_version
 
 
-@raise_on_incompat_version
+@raise_on_incompat_version(1)
 class OpenpyxlTests(ExcelWriterBase, tm.TestCase):
     ext = '.xlsx'
-    engine_name = 'openpyxl'
+    engine_name = 'openpyxl1'
     check_skip = staticmethod(lambda *args, **kwargs: None)
 
     def test_to_excel_styleconverter(self):
         _skip_if_no_openpyxl()
-        if not openpyxl_compat.is_compat():
+        if not openpyxl_compat.is_compat(major_ver=1):
             raise nose.SkipTest('incompatiable openpyxl version')
 
         import openpyxl
@@ -1167,7 +1171,7 @@ def test_to_excel_styleconverter(self):
                               "left": "thin"},
                   "alignment": {"horizontal": "center", "vertical": "top"}}
 
-        xlsx_style = _OpenpyxlWriter._convert_to_style(hstyle)
+        xlsx_style = _Openpyxl1Writer._convert_to_style(hstyle)
         self.assertTrue(xlsx_style.font.bold)
         self.assertEqual(openpyxl.style.Border.BORDER_THIN,
                          xlsx_style.borders.top.border_style)
@@ -1183,6 +1187,70 @@ def test_to_excel_styleconverter(self):
                          xlsx_style.alignment.vertical)
 
 
+@raise_on_incompat_version(2)
+class Openpyxl2Tests(ExcelWriterBase, tm.TestCase):
+    ext = '.xlsx'
+    engine_name = 'openpyxl2'
+    check_skip = staticmethod(lambda *args, **kwargs: None)
+
+    def test_to_excel_styleconverter(self):
+        _skip_if_no_openpyxl()
+        if not openpyxl_compat.is_compat(major_ver=2):
+            raise nose.SkipTest('incompatiable openpyxl version')
+
+        from openpyxl import styles
+
+        hstyle = {
+            "font": {
+                "color": '00FF0000',
+                "bold": True,
+            },
+            "borders": {
+                "top": "thin",
+                "right": "thin",
+                "bottom": "thin",
+                "left": "thin",
+            },
+            "alignment": {
+                "horizontal": "center",
+                "vertical": "top",
+            },
+            "fill": {
+                "patternType": 'solid',
+                'fgColor': {
+                    'rgb': '006666FF',
+                    'tint': 0.3,
+                },
+            },
+            "number_format": {
+                "format_code": "0.00"
+            },
+            "protection": {
+                "locked": True,
+                "hidden": False,
+            },
+        }
+
+        font_color = styles.Color('00FF0000')
+        font = styles.Font(bold=True, color=font_color)
+        side = styles.Side(style=styles.borders.BORDER_THIN)
+        border = styles.Border(top=side, right=side, bottom=side, left=side)
+        alignment = styles.Alignment(horizontal='center', vertical='top')
+        fill_color = styles.Color(rgb='006666FF', tint=0.3)
+        fill = styles.PatternFill(patternType='solid', fgColor=fill_color)
+        number_format = styles.NumberFormat(format_code='0.00')
+        protection = styles.Protection(locked=True, hidden=False)
+
+        kw = _Openpyxl2Writer._convert_to_style_kwargs(hstyle)
+        self.assertEqual(kw['font'], font)
+        self.assertEqual(kw['border'], border)
+        self.assertEqual(kw['alignment'], alignment)
+        self.assertEqual(kw['fill'], fill)
+        self.assertEqual(kw['number_format'], number_format)
+        self.assertEqual(kw['protection'], protection)
+
+
+
 class XlwtTests(ExcelWriterBase, tm.TestCase):
     ext = '.xls'
     engine_name = 'xlwt'
@@ -1216,7 +1284,6 @@ class XlsxWriterTests(ExcelWriterBase, tm.TestCase):
     check_skip = staticmethod(_skip_if_no_xlsxwriter)
 
 
-@raise_on_incompat_version
 class OpenpyxlTests_NoMerge(ExcelWriterBase, tm.TestCase):
     ext = '.xlsx'
     engine_name = 'openpyxl'
@@ -1254,9 +1321,9 @@ def test_ExcelWriter_dispatch(self):
             writer_klass = _XlsxWriter
         except ImportError:
             _skip_if_no_openpyxl()
-            if not openpyxl_compat.is_compat():
+            if not openpyxl_compat.is_compat(major_ver=1):
                 raise nose.SkipTest('incompatible openpyxl version')
-            writer_klass = _OpenpyxlWriter
+            writer_klass = _Openpyxl1Writer
 
         with ensure_clean('.xlsx') as path:
             writer = ExcelWriter(path)