From 6391f1a3479ce356c7d257f8da8ccb0f9f43781d Mon Sep 17 00:00:00 2001 From: Charlie Clark Date: Fri, 18 Sep 2015 17:38:38 +0200 Subject: [PATCH 01/11] Create separate environments for testing openpyxl. --- tox.ini | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/tox.ini b/tox.ini index b11a71f531524..4071ca3239b96 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 {toxinidir}/pandas/io/tests/test_excel.py + +[testenv:openpyxl22] +usedevelop = True +deps = + {[testenv]deps} + openpyxl>=2.2.0 +commands = {envbindir}/nosetests {toxinidir}/pandas/io/tests/test_excel.py From 6115d89f809aeaf649f5590ca3e3ccbf730cda4c Mon Sep 17 00:00:00 2001 From: Charlie Clark Date: Fri, 18 Sep 2015 17:57:05 +0200 Subject: [PATCH 02/11] Subclass Openpyxl2Writer for >= 2.2 --- pandas/io/excel.py | 79 +++++++++++++++++++++-- pandas/io/tests/test_excel.py | 117 ++++++++++++++++++++++++++++++++-- tox.ini | 2 +- 3 files changed, 186 insertions(+), 12 deletions(-) diff --git a/pandas/io/excel.py b/pandas/io/excel.py index b113cbf057f39..0e858f5bb31af 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'] - else: - return _writers['openpyxl2'] + elif LooseVersion(openpyxl.__version__) < '2.2.0': + return _writers['openpyxl20'] + elif LooseVersion(openpyxl.__version__) < '2.2.0': + 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,75 @@ 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.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_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 + colletter = get_column_letter(col) + xcell = wks.cell("%s%s" % (colletter, row)) + xcell.style = xcell.style.copy(**style_kwargs) +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..bb93dd0ce4ce7 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 @@ -1471,9 +1471,9 @@ def test_to_excel_styleconverter(self): @raise_on_incompat_version(2) -class Openpyxl2Tests(ExcelWriterBase, tm.TestCase): +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): @@ -1532,7 +1532,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) @@ -1560,7 +1560,7 @@ def test_write_cells_merge_styled(self): ] sty_merged = {'font': { 'color': '000000FF', 'bold': True }} - sty_kwargs = _Openpyxl2Writer._convert_to_style_kwargs(sty_merged) + 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', @@ -1568,7 +1568,112 @@ def test_write_cells_merge_styled(self): ] with ensure_clean('.xlsx') as path: - writer = _Openpyxl2Writer(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) + +@raise_on_incompat_version(2) +class Openpyxl22Tests(ExcelWriterBase, tm.TestCase): + ext = '.xlsx' + engine_name = 'openpyxl22' + 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 + + 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) + + # ahh openpyxl API changes + ver = openpyxl.__version__ + number_format = '0.00' # XXX: Only works with openpyxl-2.1.0 + + 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): + _skip_if_no_openpyxl() + if not openpyxl_compat.is_compat(major_ver=2): + raise nose.SkipTest('incompatiable openpyxl version') + + 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 = _Openpyxl22Writer._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 = _Openpyxl22Writer(path) writer.write_cells(initial_cells, sheet_name=sheet_name) writer.write_cells(merge_cells, sheet_name=sheet_name) diff --git a/tox.ini b/tox.ini index 4071ca3239b96..8c5e65763159d 100644 --- a/tox.ini +++ b/tox.ini @@ -89,4 +89,4 @@ usedevelop = True deps = {[testenv]deps} openpyxl>=2.2.0 -commands = {envbindir}/nosetests {toxinidir}/pandas/io/tests/test_excel.py +commands = {envbindir}/nosetests {posargs} {toxinidir}/pandas/io/tests/test_excel.py From f266990cc9c9e4a7d10c73079b9c78b310351fe1 Mon Sep 17 00:00:00 2001 From: Charlie Clark Date: Fri, 18 Sep 2015 19:24:30 +0200 Subject: [PATCH 03/11] Add openpyxl >= 2.2 specific tests. --- pandas/io/excel.py | 43 +++--- pandas/io/tests/test_excel.py | 238 ++++++++++++++++++---------------- 2 files changed, 145 insertions(+), 136 deletions(-) diff --git a/pandas/io/excel.py b/pandas/io/excel.py index 0e858f5bb31af..0eab947a0d2f5 100644 --- a/pandas/io/excel.py +++ b/pandas/io/excel.py @@ -59,7 +59,7 @@ def get_writer(engine_name): return _writers['openpyxl1'] elif LooseVersion(openpyxl.__version__) < '2.2.0': return _writers['openpyxl20'] - elif LooseVersion(openpyxl.__version__) < '2.2.0': + else: return _writers['openpyxl22'] except ImportError: # fall through to normal exception handling below @@ -1185,7 +1185,7 @@ class _Openpyxl22Writer(_Openpyxl20Writer): 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 + from openpyxl import styles sheet_name = self._get_sheet_name(sheet_name) @@ -1197,36 +1197,35 @@ def write_cells(self, cells, sheet_name=None, startrow=0, startcol=0): 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 = wks.cell(row=startrow + cell.row + 1, column=startcol + cell.col + 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}})) + xcell.number_format = self.datetime_format + elif isinstance(cell.val, datetime.date): - style_kwargs.update(self._convert_to_style_kwargs({ - 'number_format':{'format_code': self.date_format}})) + xcell.number_format = self.date_format + style_kwargs = {} if cell.style: - style_kwargs.update(self._convert_to_style_kwargs(cell.style)) + style_kwargs = self._convert_to_style_kwargs(cell.style) if style_kwargs: - xcell.style = xcell.style.copy(**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: - 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)) + 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 + ) - # Excel requires that the format of the first cell in a merged - # range is repeated in the rest of the merged range. + # 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 @@ -1238,9 +1237,9 @@ def write_cells(self, cells, sheet_name=None, startrow=0, startcol=0): 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) + xcell = wks.cell(column=col, row=row) + for k, v in style_kwargs.items(): + setattr(xcell, k, v) register_writer(_Openpyxl22Writer) diff --git a/pandas/io/tests/test_excel.py b/pandas/io/tests/test_excel.py index bb93dd0ce4ce7..e9881874b7208 100644 --- a/pandas/io/tests/test_excel.py +++ b/pandas/io/tests/test_excel.py @@ -1470,113 +1470,119 @@ def test_to_excel_styleconverter(self): xlsx_style.alignment.vertical) -@raise_on_incompat_version(2) -class Openpyxl20Tests(ExcelWriterBase, tm.TestCase): - ext = '.xlsx' - 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 - - 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) - - # ahh openpyxl API changes - ver = openpyxl.__version__ - if ver >= LooseVersion('2.0.0') and ver < LooseVersion('2.1.0'): - number_format = styles.NumberFormat(format_code='0.00') - else: - number_format = '0.00' # XXX: Only works with openpyxl-2.1.0 - - protection = styles.Protection(locked=True, hidden=False) - - kw = _Openpyxl20Writer._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): - _skip_if_no_openpyxl() - if not openpyxl_compat.is_compat(major_ver=2): - raise nose.SkipTest('incompatiable openpyxl version') - - 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) +# @raise_on_incompat_version(2) +# class Openpyxl20Tests(ExcelWriterBase, tm.TestCase): +# ext = '.xlsx' +# engine_name = 'openpyxl20' +# check_skip = staticmethod(lambda *args, **kwargs: None) +# +# def _check_version(self): +# import openpyxl +# ver = openpyxl.__version__ +# return ver >= LooseVersion('2.0.0') and ver < LooseVersion('2.2.0') +# +# def test_to_excel_styleconverter(self): +# _skip_if_no_openpyxl() +# if not self._check_version(): +# raise nose.SkipTest('incompatiable openpyxl version') +# +# 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) +# +# # ahh openpyxl API changes +# ver = openpyxl.__version__ +# if ver >= LooseVersion('2.0.0') and ver < LooseVersion('2.1.0'): +# number_format = styles.NumberFormat(format_code='0.00') +# else: +# number_format = '0.00' # XXX: Only works with openpyxl-2.1.0 +# +# protection = styles.Protection(locked=True, hidden=False) +# +# kw = _Openpyxl20Writer._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): +# _skip_if_no_openpyxl() +# if self._check_version(): +# raise nose.SkipTest('incompatiable openpyxl version') +# +# 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) @raise_on_incompat_version(2) class Openpyxl22Tests(ExcelWriterBase, tm.TestCase): @@ -1584,9 +1590,15 @@ class Openpyxl22Tests(ExcelWriterBase, tm.TestCase): engine_name = 'openpyxl22' check_skip = staticmethod(lambda *args, **kwargs: None) + def _check_version(self): + import openpyxl + ver = openpyxl.__version__ + return ver >= LooseVersion('2.2.0') + def test_to_excel_styleconverter(self): _skip_if_no_openpyxl() - if not openpyxl_compat.is_compat(major_ver=2): + + if not self._check_version(): raise nose.SkipTest('incompatiable openpyxl version') import openpyxl @@ -1631,9 +1643,7 @@ def test_to_excel_styleconverter(self): fill_color = styles.Color(rgb='006666FF', tint=0.3) fill = styles.PatternFill(patternType='solid', fgColor=fill_color) - # ahh openpyxl API changes - ver = openpyxl.__version__ - number_format = '0.00' # XXX: Only works with openpyxl-2.1.0 + number_format = '0.00' protection = styles.Protection(locked=True, hidden=False) @@ -1666,7 +1676,7 @@ def test_write_cells_merge_styled(self): sty_merged = {'font': { 'color': '000000FF', 'bold': True }} sty_kwargs = _Openpyxl22Writer._convert_to_style_kwargs(sty_merged) - openpyxl_sty_merged = styles.Style(**sty_kwargs) + openpyxl_sty_merged = sty_kwargs['font'] merge_cells = [ ExcelCell(col=0, row=0, val='pandas', mergestart=1, mergeend=1, style=sty_merged), @@ -1680,8 +1690,8 @@ def test_write_cells_merge_styled(self): 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): From 63fb961e780c2a8bc1ec8638acfbd83f47f9a7e6 Mon Sep 17 00:00:00 2001 From: Charlie Clark Date: Sat, 19 Sep 2015 15:22:02 +0200 Subject: [PATCH 04/11] Use class decorator for skipping TestClass --- pandas/io/tests/test_excel.py | 229 +++++++++++++++++----------------- tox.ini | 2 +- 2 files changed, 118 insertions(+), 113 deletions(-) diff --git a/pandas/io/tests/test_excel.py b/pandas/io/tests/test_excel.py index e9881874b7208..4452c6626ed8a 100644 --- a/pandas/io/tests/test_excel.py +++ b/pandas/io/tests/test_excel.py @@ -1470,137 +1470,143 @@ def test_to_excel_styleconverter(self): xlsx_style.alignment.vertical) -# @raise_on_incompat_version(2) -# class Openpyxl20Tests(ExcelWriterBase, tm.TestCase): -# ext = '.xlsx' -# engine_name = 'openpyxl20' -# check_skip = staticmethod(lambda *args, **kwargs: None) -# -# def _check_version(self): -# import openpyxl -# ver = openpyxl.__version__ -# return ver >= LooseVersion('2.0.0') and ver < LooseVersion('2.2.0') -# -# def test_to_excel_styleconverter(self): -# _skip_if_no_openpyxl() -# if not self._check_version(): -# raise nose.SkipTest('incompatiable openpyxl version') -# -# 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) +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) +@skip_openpyxl_gt21 +class Openpyxl20Tests(ExcelWriterBase, tm.TestCase): + ext = '.xlsx' + engine_name = 'openpyxl20' + 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, + }, + } # -# # ahh openpyxl API changes -# ver = openpyxl.__version__ -# if ver >= LooseVersion('2.0.0') and ver < LooseVersion('2.1.0'): -# number_format = styles.NumberFormat(format_code='0.00') -# else: -# number_format = '0.00' # XXX: Only works with openpyxl-2.1.0 + 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) # -# protection = styles.Protection(locked=True, hidden=False) + # ahh openpyxl API changes + ver = openpyxl.__version__ + if ver >= LooseVersion('2.0.0') and ver < LooseVersion('2.1.0'): + number_format = styles.NumberFormat(format_code='0.00') + else: + number_format = '0.00' # XXX: Only works with openpyxl-2.1.0 # -# kw = _Openpyxl20Writer._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) + protection = styles.Protection(locked=True, hidden=False) # + kw = _Openpyxl20Writer._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): -# _skip_if_no_openpyxl() -# if self._check_version(): -# raise nose.SkipTest('incompatiable openpyxl version') # -# from pandas.core.format import ExcelCell -# from openpyxl import styles + def test_write_cells_merge_styled(self): + from pandas.core.format import ExcelCell + from openpyxl import styles # -# sheet_name='merge_styled' + sheet_name='merge_styled' # -# sty_b1 = {'font': {'color': '00FF0000'}} -# sty_a2 = {'font': {'color': '0000FF00'}} + 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), -# ] + 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), -# ] + 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) + 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) + 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 _check_version(self): - import openpyxl - ver = openpyxl.__version__ - return ver >= LooseVersion('2.2.0') - def test_to_excel_styleconverter(self): - _skip_if_no_openpyxl() - - if not self._check_version(): - raise nose.SkipTest('incompatiable openpyxl version') - import openpyxl from openpyxl import styles @@ -1657,7 +1663,6 @@ def test_to_excel_styleconverter(self): def test_write_cells_merge_styled(self): - _skip_if_no_openpyxl() if not openpyxl_compat.is_compat(major_ver=2): raise nose.SkipTest('incompatiable openpyxl version') diff --git a/tox.ini b/tox.ini index 8c5e65763159d..9fbb15087c4d5 100644 --- a/tox.ini +++ b/tox.ini @@ -82,7 +82,7 @@ usedevelop = True deps = {[testenv]deps} openpyxl<2.2.0 -commands = {envbindir}/nosetests {toxinidir}/pandas/io/tests/test_excel.py +commands = {envbindir}/nosetests {posargs} {toxinidir}/pandas/io/tests/test_excel.py [testenv:openpyxl22] usedevelop = True From 5900483d2a9339676e6acdb62287d9086ab0ad25 Mon Sep 17 00:00:00 2001 From: Charlie Clark Date: Sat, 19 Sep 2015 15:40:59 +0200 Subject: [PATCH 05/11] Invert order for reading number format. --- pandas/io/tests/test_excel.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pandas/io/tests/test_excel.py b/pandas/io/tests/test_excel.py index 4452c6626ed8a..4c964b5c0a4e6 100644 --- a/pandas/io/tests/test_excel.py +++ b/pandas/io/tests/test_excel.py @@ -1796,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) From 2597a45cfa06bf19f976560a02ad76de3301d449 Mon Sep 17 00:00:00 2001 From: Charlie Clark Date: Mon, 21 Sep 2015 19:42:39 +0200 Subject: [PATCH 06/11] Update docs. --- doc/source/io.rst | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/doc/source/io.rst b/doc/source/io.rst index 5ad9af310225d..7f186005560c7 100644 --- a/doc/source/io.rst +++ b/doc/source/io.rst @@ -2230,6 +2230,8 @@ 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`. +Added support for Openpyxl >= 2.2 + .. code-block:: python # Safe import for either Python 2.x or 3.x @@ -2279,14 +2281,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`` From 692fccdcb69fd4fae8f92c1fe7213e433be99e70 Mon Sep 17 00:00:00 2001 From: Charlie Clark Date: Mon, 21 Sep 2015 19:44:10 +0200 Subject: [PATCH 07/11] Allow openpyxl to handle the formatting for dates and times. --- pandas/io/excel.py | 7 ------- 1 file changed, 7 deletions(-) diff --git a/pandas/io/excel.py b/pandas/io/excel.py index 0eab947a0d2f5..dd7babe837d04 100644 --- a/pandas/io/excel.py +++ b/pandas/io/excel.py @@ -1200,13 +1200,6 @@ def write_cells(self, cells, sheet_name=None, startrow=0, startcol=0): xcell = wks.cell(row=startrow + cell.row + 1, column=startcol + cell.col + 1) xcell.value = _conv_value(cell.val) - # Apply format codes before cell.style to allow override - if isinstance(cell.val, datetime.datetime): - xcell.number_format = self.datetime_format - - elif isinstance(cell.val, datetime.date): - xcell.number_format = self.date_format - style_kwargs = {} if cell.style: style_kwargs = self._convert_to_style_kwargs(cell.style) From 79d1cf1c0861ca07a91fdc479904ce934cc5727d Mon Sep 17 00:00:00 2001 From: Charlie Clark Date: Mon, 21 Sep 2015 19:50:49 +0200 Subject: [PATCH 08/11] Make function call clearer. --- pandas/io/excel.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/pandas/io/excel.py b/pandas/io/excel.py index dd7babe837d04..8a70dc5c1186c 100644 --- a/pandas/io/excel.py +++ b/pandas/io/excel.py @@ -1197,7 +1197,10 @@ def write_cells(self, cells, sheet_name=None, startrow=0, startcol=0): self.sheets[sheet_name] = wks for cell in cells: - xcell = wks.cell(row=startrow + cell.row + 1, column=startcol + cell.col + 1) + xcell = wks.cell( + row=startrow + cell.row + 1, + column=startcol + cell.col + 1 + ) xcell.value = _conv_value(cell.val) style_kwargs = {} From 9255e99304c398db302eb59d8f7bea3f80bdbbbd Mon Sep 17 00:00:00 2001 From: Charlie Clark Date: Mon, 21 Sep 2015 21:22:33 +0200 Subject: [PATCH 09/11] Add version flag. --- doc/source/io.rst | 2 ++ 1 file changed, 2 insertions(+) diff --git a/doc/source/io.rst b/doc/source/io.rst index 7f186005560c7..aea9a80f86735 100644 --- a/doc/source/io.rst +++ b/doc/source/io.rst @@ -2230,6 +2230,8 @@ 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 From fcab59c4d349e0e72c560f81319c6524d667b915 Mon Sep 17 00:00:00 2001 From: Charlie Clark Date: Mon, 21 Sep 2015 21:22:48 +0200 Subject: [PATCH 10/11] Remove comments. --- pandas/io/tests/test_excel.py | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/pandas/io/tests/test_excel.py b/pandas/io/tests/test_excel.py index 4c964b5c0a4e6..657789fe8ce9b 100644 --- a/pandas/io/tests/test_excel.py +++ b/pandas/io/tests/test_excel.py @@ -1525,7 +1525,7 @@ def test_to_excel_styleconverter(self): "hidden": False, }, } -# + font_color = styles.Color('00FF0000') font = styles.Font(bold=True, color=font_color) side = styles.Side(style=styles.borders.BORDER_THIN) @@ -1533,16 +1533,16 @@ def test_to_excel_styleconverter(self): alignment = styles.Alignment(horizontal='center', vertical='top') fill_color = styles.Color(rgb='006666FF', tint=0.3) fill = styles.PatternFill(patternType='solid', fgColor=fill_color) -# + # ahh openpyxl API changes ver = openpyxl.__version__ if ver >= LooseVersion('2.0.0') and ver < LooseVersion('2.1.0'): number_format = styles.NumberFormat(format_code='0.00') else: number_format = '0.00' # XXX: Only works with openpyxl-2.1.0 -# + protection = styles.Protection(locked=True, hidden=False) -# + kw = _Openpyxl20Writer._convert_to_style_kwargs(hstyle) self.assertEqual(kw['font'], font) self.assertEqual(kw['border'], border) @@ -1550,22 +1550,22 @@ def test_to_excel_styleconverter(self): self.assertEqual(kw['fill'], fill) self.assertEqual(kw['number_format'], number_format) self.assertEqual(kw['protection'], protection) -# -# + + 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) @@ -1573,12 +1573,12 @@ def test_write_cells_merge_styled(self): 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') From 921da27affca36c0ccf3efb5db6920314f2bb2c5 Mon Sep 17 00:00:00 2001 From: Charlie Clark Date: Tue, 22 Sep 2015 12:23:02 +0200 Subject: [PATCH 11/11] Add a naive cache for styles. --- pandas/io/excel.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/pandas/io/excel.py b/pandas/io/excel.py index 8a70dc5c1186c..5767af1ad3862 100644 --- a/pandas/io/excel.py +++ b/pandas/io/excel.py @@ -1189,6 +1189,8 @@ def write_cells(self, cells, sheet_name=None, startrow=0, startcol=0): sheet_name = self._get_sheet_name(sheet_name) + _style_cache = {} + if sheet_name in self.sheets: wks = self.sheets[sheet_name] else: @@ -1205,7 +1207,11 @@ def write_cells(self, cells, sheet_name=None, startrow=0, startcol=0): style_kwargs = {} if cell.style: - style_kwargs = self._convert_to_style_kwargs(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():