diff --git a/doc/source/io.rst b/doc/source/io.rst index 5ad9af310225d..aea9a80f86735 100644 --- a/doc/source/io.rst +++ b/doc/source/io.rst @@ -2230,6 +2230,10 @@ Writing Excel Files to Memory Pandas supports writing Excel files to buffer-like objects such as ``StringIO`` or ``BytesIO`` using :class:`~pandas.io.excel.ExcelWriter`. +.. versionadded:: 0.17 + +Added support for Openpyxl >= 2.2 + .. code-block:: python # Safe import for either Python 2.x or 3.x @@ -2279,14 +2283,15 @@ config options ` ``io.excel.xlsx.writer`` and files if `Xlsxwriter`_ is not available. .. _XlsxWriter: http://xlsxwriter.readthedocs.org -.. _openpyxl: http://packages.python.org/openpyxl/ +.. _openpyxl: http://openpyxl.readthedocs.org/ .. _xlwt: http://www.python-excel.org To specify which writer you want to use, you can pass an engine keyword argument to ``to_excel`` and to ``ExcelWriter``. The built-in engines are: -- ``openpyxl``: This includes stable support for OpenPyxl 1.6.1 up to but - not including 2.0.0, and experimental support for OpenPyxl 2.0.0 and later. +- ``openpyxl``: This includes stable support for Openpyxl from 1.6.1. However, + it is advised to use version 2.2 and higher, especially when working with + styles. - ``xlsxwriter`` - ``xlwt`` diff --git a/pandas/io/excel.py b/pandas/io/excel.py index b113cbf057f39..5767af1ad3862 100644 --- a/pandas/io/excel.py +++ b/pandas/io/excel.py @@ -57,8 +57,10 @@ def get_writer(engine_name): # make sure we make the intelligent choice for the user if LooseVersion(openpyxl.__version__) < '2.0.0': return _writers['openpyxl1'] + elif LooseVersion(openpyxl.__version__) < '2.2.0': + return _writers['openpyxl20'] else: - return _writers['openpyxl2'] + return _writers['openpyxl22'] except ImportError: # fall through to normal exception handling below pass @@ -760,11 +762,11 @@ class _OpenpyxlWriter(_Openpyxl1Writer): register_writer(_OpenpyxlWriter) -class _Openpyxl2Writer(_Openpyxl1Writer): +class _Openpyxl20Writer(_Openpyxl1Writer): """ Note: Support for OpenPyxl v2 is currently EXPERIMENTAL (GH7565). """ - engine = 'openpyxl2' + engine = 'openpyxl20' openpyxl_majorver = 2 def write_cells(self, cells, sheet_name=None, startrow=0, startcol=0): @@ -1172,8 +1174,76 @@ def _convert_to_protection(cls, protection_dict): return Protection(**protection_dict) -register_writer(_Openpyxl2Writer) +register_writer(_Openpyxl20Writer) +class _Openpyxl22Writer(_Openpyxl20Writer): + """ + Note: Support for OpenPyxl v2.2 is currently EXPERIMENTAL (GH7565). + """ + engine = 'openpyxl22' + openpyxl_majorver = 2 + + def write_cells(self, cells, sheet_name=None, startrow=0, startcol=0): + # Write the frame cells using openpyxl. + from openpyxl import styles + + sheet_name = self._get_sheet_name(sheet_name) + + _style_cache = {} + + 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: + xcell = wks.cell( + row=startrow + cell.row + 1, + column=startcol + cell.col + 1 + ) + xcell.value = _conv_value(cell.val) + + style_kwargs = {} + if cell.style: + key = str(cell.style) + style_kwargs = _style_cache.get(key) + if style_kwargs is None: + style_kwargs = self._convert_to_style_kwargs(cell.style) + _style_cache[key] = style_kwargs + + if style_kwargs: + for k, v in style_kwargs.items(): + setattr(xcell, k, v) + + if cell.mergestart is not None and cell.mergeend is not None: + + wks.merge_cells( + start_row=startrow + cell.row + 1, + start_column=startcol + cell.col + 1, + end_column=startcol + cell.mergeend + 1, + end_row=startrow + cell.mergeend + 1 + ) + + # When cells are merged only the top-left cell is preserved + # The behaviour of the other cells in a merged range is undefined + if style_kwargs: + 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 + xcell = wks.cell(column=col, row=row) + for k, v in style_kwargs.items(): + setattr(xcell, k, v) + +register_writer(_Openpyxl22Writer) class _XlwtWriter(ExcelWriter): engine = 'xlwt' diff --git a/pandas/io/tests/test_excel.py b/pandas/io/tests/test_excel.py index 0aee2af6ad166..657789fe8ce9b 100644 --- a/pandas/io/tests/test_excel.py +++ b/pandas/io/tests/test_excel.py @@ -19,7 +19,7 @@ from pandas.io.parsers import read_csv from pandas.io.excel import ( ExcelFile, ExcelWriter, read_excel, _XlwtWriter, _Openpyxl1Writer, - _Openpyxl2Writer, register_writer, _XlsxWriter + _Openpyxl20Writer, _Openpyxl22Writer, register_writer, _XlsxWriter ) from pandas.io.common import URLError from pandas.util.testing import ensure_clean, makeCustomDataframe as mkdf @@ -1470,17 +1470,28 @@ def test_to_excel_styleconverter(self): xlsx_style.alignment.vertical) +def skip_openpyxl_gt21(cls): + """Skip a TestCase instance if openpyxl >= 2.2""" + + @classmethod + def setUpClass(cls): + _skip_if_no_openpyxl() + import openpyxl + ver = openpyxl.__version__ + if not (ver >= LooseVersion('2.0.0') and ver < LooseVersion('2.2.0')): + raise nose.SkipTest("openpyxl >= 2.2") + + cls.setUpClass = setUpClass + return cls + @raise_on_incompat_version(2) -class Openpyxl2Tests(ExcelWriterBase, tm.TestCase): +@skip_openpyxl_gt21 +class Openpyxl20Tests(ExcelWriterBase, tm.TestCase): ext = '.xlsx' - engine_name = 'openpyxl2' + engine_name = 'openpyxl20' 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') - import openpyxl from openpyxl import styles @@ -1532,7 +1543,7 @@ def test_to_excel_styleconverter(self): protection = styles.Protection(locked=True, hidden=False) - kw = _Openpyxl2Writer._convert_to_style_kwargs(hstyle) + kw = _Openpyxl20Writer._convert_to_style_kwargs(hstyle) self.assertEqual(kw['font'], font) self.assertEqual(kw['border'], border) self.assertEqual(kw['alignment'], alignment) @@ -1542,7 +1553,116 @@ def test_to_excel_styleconverter(self): def test_write_cells_merge_styled(self): + from pandas.core.format import ExcelCell + from openpyxl import styles + + sheet_name='merge_styled' + + sty_b1 = {'font': {'color': '00FF0000'}} + sty_a2 = {'font': {'color': '0000FF00'}} + + initial_cells = [ + ExcelCell(col=1, row=0, val=42, style=sty_b1), + ExcelCell(col=0, row=1, val=99, style=sty_a2), + ] + + sty_merged = {'font': { 'color': '000000FF', 'bold': True }} + sty_kwargs = _Openpyxl20Writer._convert_to_style_kwargs(sty_merged) + openpyxl_sty_merged = styles.Style(**sty_kwargs) + merge_cells = [ + ExcelCell(col=0, row=0, val='pandas', + mergestart=1, mergeend=1, style=sty_merged), + ] + + with ensure_clean('.xlsx') as path: + writer = _Openpyxl20Writer(path) + writer.write_cells(initial_cells, sheet_name=sheet_name) + writer.write_cells(merge_cells, sheet_name=sheet_name) + + wks = writer.sheets[sheet_name] + xcell_b1 = wks.cell('B1') + xcell_a2 = wks.cell('A2') + self.assertEqual(xcell_b1.style, openpyxl_sty_merged) + self.assertEqual(xcell_a2.style, openpyxl_sty_merged) + +def skip_openpyxl_lt22(cls): + """Skip a TestCase instance if openpyxl < 2.2""" + + @classmethod + def setUpClass(cls): _skip_if_no_openpyxl() + import openpyxl + ver = openpyxl.__version__ + if ver < LooseVersion('2.2.0'): + raise nose.SkipTest("openpyxl < 2.2") + + cls.setUpClass = setUpClass + return cls + +@raise_on_incompat_version(2) +@skip_openpyxl_lt22 +class Openpyxl22Tests(ExcelWriterBase, tm.TestCase): + ext = '.xlsx' + engine_name = 'openpyxl22' + check_skip = staticmethod(lambda *args, **kwargs: None) + + def test_to_excel_styleconverter(self): + import openpyxl + 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 = '0.00' + + protection = styles.Protection(locked=True, hidden=False) + + kw = _Openpyxl22Writer._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) + + + def test_write_cells_merge_styled(self): if not openpyxl_compat.is_compat(major_ver=2): raise nose.SkipTest('incompatiable openpyxl version') @@ -1560,23 +1680,23 @@ def test_write_cells_merge_styled(self): ] sty_merged = {'font': { 'color': '000000FF', 'bold': True }} - sty_kwargs = _Openpyxl2Writer._convert_to_style_kwargs(sty_merged) - openpyxl_sty_merged = styles.Style(**sty_kwargs) + sty_kwargs = _Openpyxl22Writer._convert_to_style_kwargs(sty_merged) + openpyxl_sty_merged = sty_kwargs['font'] merge_cells = [ ExcelCell(col=0, row=0, val='pandas', mergestart=1, mergeend=1, style=sty_merged), ] with ensure_clean('.xlsx') as path: - writer = _Openpyxl2Writer(path) + writer = _Openpyxl22Writer(path) writer.write_cells(initial_cells, sheet_name=sheet_name) writer.write_cells(merge_cells, sheet_name=sheet_name) wks = writer.sheets[sheet_name] xcell_b1 = wks.cell('B1') xcell_a2 = wks.cell('A2') - self.assertEqual(xcell_b1.style, openpyxl_sty_merged) - self.assertEqual(xcell_a2.style, openpyxl_sty_merged) + self.assertEqual(xcell_b1.font, openpyxl_sty_merged) + self.assertEqual(xcell_a2.font, openpyxl_sty_merged) class XlwtTests(ExcelWriterBase, tm.TestCase): @@ -1676,9 +1796,9 @@ def test_column_format(self): cell = read_worksheet.cell('B2') try: - read_num_format = cell.style.number_format._format_code + read_num_format = cell.number_format except: - read_num_format = cell.style.number_format + read_num_format = cell.style.number_format._format_code self.assertEqual(read_num_format, num_format) diff --git a/tox.ini b/tox.ini index b11a71f531524..9fbb15087c4d5 100644 --- a/tox.ini +++ b/tox.ini @@ -14,7 +14,6 @@ deps = python-dateutil beautifulsoup4 lxml - openpyxl<2.0.0 xlsxwriter xlrd six @@ -70,3 +69,24 @@ deps = deps = numpy==1.8.0 {[testenv]deps} + +[testenv:openpyxl1] +usedevelop = True +deps = + {[testenv]deps} + openpyxl<2.0.0 +commands = {envbindir}/nosetests {toxinidir}/pandas/io/tests/test_excel.py + +[testenv:openpyxl20] +usedevelop = True +deps = + {[testenv]deps} + openpyxl<2.2.0 +commands = {envbindir}/nosetests {posargs} {toxinidir}/pandas/io/tests/test_excel.py + +[testenv:openpyxl22] +usedevelop = True +deps = + {[testenv]deps} + openpyxl>=2.2.0 +commands = {envbindir}/nosetests {posargs} {toxinidir}/pandas/io/tests/test_excel.py