Skip to content

Commit

Permalink
COMPAT: openpyxl >= 2.2 support, #10125
Browse files Browse the repository at this point in the history
Create separate environments for testing openpyxl.

Subclass Openpyxl2Writer for >= 2.2

Add openpyxl >= 2.2 specific tests.

Use class decorator for skipping TestClass

Invert order for reading number format.

Update docs.

Allow openpyxl to handle the formatting for dates and times.

Make function call clearer.

Add version flag.

Remove comments.

Add a naive cache for styles.
  • Loading branch information
Themanwithoutaplan authored and jreback committed Sep 22, 2015
1 parent f920bf2 commit fa1e1c1
Show file tree
Hide file tree
Showing 4 changed files with 238 additions and 23 deletions.
11 changes: 8 additions & 3 deletions doc/source/io.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -2279,14 +2283,15 @@ config options <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``

Expand Down
78 changes: 74 additions & 4 deletions pandas/io/excel.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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'
Expand Down
150 changes: 135 additions & 15 deletions pandas/io/tests/test_excel.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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)
Expand All @@ -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')

Expand All @@ -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):
Expand Down Expand Up @@ -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)

Expand Down
22 changes: 21 additions & 1 deletion tox.ini
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ deps =
python-dateutil
beautifulsoup4
lxml
openpyxl<2.0.0
xlsxwriter
xlrd
six
Expand Down Expand Up @@ -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

0 comments on commit fa1e1c1

Please sign in to comment.