Skip to content

Commit fa1e1c1

Browse files
Themanwithoutaplanjreback
authored andcommitted
COMPAT: openpyxl >= 2.2 support, pandas-dev#10125
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.
1 parent f920bf2 commit fa1e1c1

File tree

4 files changed

+238
-23
lines changed

4 files changed

+238
-23
lines changed

doc/source/io.rst

+8-3
Original file line numberDiff line numberDiff line change
@@ -2230,6 +2230,10 @@ Writing Excel Files to Memory
22302230
Pandas supports writing Excel files to buffer-like objects such as ``StringIO`` or
22312231
``BytesIO`` using :class:`~pandas.io.excel.ExcelWriter`.
22322232

2233+
.. versionadded:: 0.17
2234+
2235+
Added support for Openpyxl >= 2.2
2236+
22332237
.. code-block:: python
22342238
22352239
# Safe import for either Python 2.x or 3.x
@@ -2279,14 +2283,15 @@ config options <options>` ``io.excel.xlsx.writer`` and
22792283
files if `Xlsxwriter`_ is not available.
22802284

22812285
.. _XlsxWriter: http://xlsxwriter.readthedocs.org
2282-
.. _openpyxl: http://packages.python.org/openpyxl/
2286+
.. _openpyxl: http://openpyxl.readthedocs.org/
22832287
.. _xlwt: http://www.python-excel.org
22842288

22852289
To specify which writer you want to use, you can pass an engine keyword
22862290
argument to ``to_excel`` and to ``ExcelWriter``. The built-in engines are:
22872291

2288-
- ``openpyxl``: This includes stable support for OpenPyxl 1.6.1 up to but
2289-
not including 2.0.0, and experimental support for OpenPyxl 2.0.0 and later.
2292+
- ``openpyxl``: This includes stable support for Openpyxl from 1.6.1. However,
2293+
it is advised to use version 2.2 and higher, especially when working with
2294+
styles.
22902295
- ``xlsxwriter``
22912296
- ``xlwt``
22922297

pandas/io/excel.py

+74-4
Original file line numberDiff line numberDiff line change
@@ -57,8 +57,10 @@ def get_writer(engine_name):
5757
# make sure we make the intelligent choice for the user
5858
if LooseVersion(openpyxl.__version__) < '2.0.0':
5959
return _writers['openpyxl1']
60+
elif LooseVersion(openpyxl.__version__) < '2.2.0':
61+
return _writers['openpyxl20']
6062
else:
61-
return _writers['openpyxl2']
63+
return _writers['openpyxl22']
6264
except ImportError:
6365
# fall through to normal exception handling below
6466
pass
@@ -760,11 +762,11 @@ class _OpenpyxlWriter(_Openpyxl1Writer):
760762
register_writer(_OpenpyxlWriter)
761763

762764

763-
class _Openpyxl2Writer(_Openpyxl1Writer):
765+
class _Openpyxl20Writer(_Openpyxl1Writer):
764766
"""
765767
Note: Support for OpenPyxl v2 is currently EXPERIMENTAL (GH7565).
766768
"""
767-
engine = 'openpyxl2'
769+
engine = 'openpyxl20'
768770
openpyxl_majorver = 2
769771

770772
def write_cells(self, cells, sheet_name=None, startrow=0, startcol=0):
@@ -1172,8 +1174,76 @@ def _convert_to_protection(cls, protection_dict):
11721174
return Protection(**protection_dict)
11731175

11741176

1175-
register_writer(_Openpyxl2Writer)
1177+
register_writer(_Openpyxl20Writer)
11761178

1179+
class _Openpyxl22Writer(_Openpyxl20Writer):
1180+
"""
1181+
Note: Support for OpenPyxl v2.2 is currently EXPERIMENTAL (GH7565).
1182+
"""
1183+
engine = 'openpyxl22'
1184+
openpyxl_majorver = 2
1185+
1186+
def write_cells(self, cells, sheet_name=None, startrow=0, startcol=0):
1187+
# Write the frame cells using openpyxl.
1188+
from openpyxl import styles
1189+
1190+
sheet_name = self._get_sheet_name(sheet_name)
1191+
1192+
_style_cache = {}
1193+
1194+
if sheet_name in self.sheets:
1195+
wks = self.sheets[sheet_name]
1196+
else:
1197+
wks = self.book.create_sheet()
1198+
wks.title = sheet_name
1199+
self.sheets[sheet_name] = wks
1200+
1201+
for cell in cells:
1202+
xcell = wks.cell(
1203+
row=startrow + cell.row + 1,
1204+
column=startcol + cell.col + 1
1205+
)
1206+
xcell.value = _conv_value(cell.val)
1207+
1208+
style_kwargs = {}
1209+
if cell.style:
1210+
key = str(cell.style)
1211+
style_kwargs = _style_cache.get(key)
1212+
if style_kwargs is None:
1213+
style_kwargs = self._convert_to_style_kwargs(cell.style)
1214+
_style_cache[key] = style_kwargs
1215+
1216+
if style_kwargs:
1217+
for k, v in style_kwargs.items():
1218+
setattr(xcell, k, v)
1219+
1220+
if cell.mergestart is not None and cell.mergeend is not None:
1221+
1222+
wks.merge_cells(
1223+
start_row=startrow + cell.row + 1,
1224+
start_column=startcol + cell.col + 1,
1225+
end_column=startcol + cell.mergeend + 1,
1226+
end_row=startrow + cell.mergeend + 1
1227+
)
1228+
1229+
# When cells are merged only the top-left cell is preserved
1230+
# The behaviour of the other cells in a merged range is undefined
1231+
if style_kwargs:
1232+
first_row = startrow + cell.row + 1
1233+
last_row = startrow + cell.mergestart + 1
1234+
first_col = startcol + cell.col + 1
1235+
last_col = startcol + cell.mergeend + 1
1236+
1237+
for row in range(first_row, last_row + 1):
1238+
for col in range(first_col, last_col + 1):
1239+
if row == first_row and col == first_col:
1240+
# Ignore first cell. It is already handled.
1241+
continue
1242+
xcell = wks.cell(column=col, row=row)
1243+
for k, v in style_kwargs.items():
1244+
setattr(xcell, k, v)
1245+
1246+
register_writer(_Openpyxl22Writer)
11771247

11781248
class _XlwtWriter(ExcelWriter):
11791249
engine = 'xlwt'

pandas/io/tests/test_excel.py

+135-15
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
from pandas.io.parsers import read_csv
2020
from pandas.io.excel import (
2121
ExcelFile, ExcelWriter, read_excel, _XlwtWriter, _Openpyxl1Writer,
22-
_Openpyxl2Writer, register_writer, _XlsxWriter
22+
_Openpyxl20Writer, _Openpyxl22Writer, register_writer, _XlsxWriter
2323
)
2424
from pandas.io.common import URLError
2525
from pandas.util.testing import ensure_clean, makeCustomDataframe as mkdf
@@ -1470,17 +1470,28 @@ def test_to_excel_styleconverter(self):
14701470
xlsx_style.alignment.vertical)
14711471

14721472

1473+
def skip_openpyxl_gt21(cls):
1474+
"""Skip a TestCase instance if openpyxl >= 2.2"""
1475+
1476+
@classmethod
1477+
def setUpClass(cls):
1478+
_skip_if_no_openpyxl()
1479+
import openpyxl
1480+
ver = openpyxl.__version__
1481+
if not (ver >= LooseVersion('2.0.0') and ver < LooseVersion('2.2.0')):
1482+
raise nose.SkipTest("openpyxl >= 2.2")
1483+
1484+
cls.setUpClass = setUpClass
1485+
return cls
1486+
14731487
@raise_on_incompat_version(2)
1474-
class Openpyxl2Tests(ExcelWriterBase, tm.TestCase):
1488+
@skip_openpyxl_gt21
1489+
class Openpyxl20Tests(ExcelWriterBase, tm.TestCase):
14751490
ext = '.xlsx'
1476-
engine_name = 'openpyxl2'
1491+
engine_name = 'openpyxl20'
14771492
check_skip = staticmethod(lambda *args, **kwargs: None)
14781493

14791494
def test_to_excel_styleconverter(self):
1480-
_skip_if_no_openpyxl()
1481-
if not openpyxl_compat.is_compat(major_ver=2):
1482-
raise nose.SkipTest('incompatiable openpyxl version')
1483-
14841495
import openpyxl
14851496
from openpyxl import styles
14861497

@@ -1532,7 +1543,7 @@ def test_to_excel_styleconverter(self):
15321543

15331544
protection = styles.Protection(locked=True, hidden=False)
15341545

1535-
kw = _Openpyxl2Writer._convert_to_style_kwargs(hstyle)
1546+
kw = _Openpyxl20Writer._convert_to_style_kwargs(hstyle)
15361547
self.assertEqual(kw['font'], font)
15371548
self.assertEqual(kw['border'], border)
15381549
self.assertEqual(kw['alignment'], alignment)
@@ -1542,7 +1553,116 @@ def test_to_excel_styleconverter(self):
15421553

15431554

15441555
def test_write_cells_merge_styled(self):
1556+
from pandas.core.format import ExcelCell
1557+
from openpyxl import styles
1558+
1559+
sheet_name='merge_styled'
1560+
1561+
sty_b1 = {'font': {'color': '00FF0000'}}
1562+
sty_a2 = {'font': {'color': '0000FF00'}}
1563+
1564+
initial_cells = [
1565+
ExcelCell(col=1, row=0, val=42, style=sty_b1),
1566+
ExcelCell(col=0, row=1, val=99, style=sty_a2),
1567+
]
1568+
1569+
sty_merged = {'font': { 'color': '000000FF', 'bold': True }}
1570+
sty_kwargs = _Openpyxl20Writer._convert_to_style_kwargs(sty_merged)
1571+
openpyxl_sty_merged = styles.Style(**sty_kwargs)
1572+
merge_cells = [
1573+
ExcelCell(col=0, row=0, val='pandas',
1574+
mergestart=1, mergeend=1, style=sty_merged),
1575+
]
1576+
1577+
with ensure_clean('.xlsx') as path:
1578+
writer = _Openpyxl20Writer(path)
1579+
writer.write_cells(initial_cells, sheet_name=sheet_name)
1580+
writer.write_cells(merge_cells, sheet_name=sheet_name)
1581+
1582+
wks = writer.sheets[sheet_name]
1583+
xcell_b1 = wks.cell('B1')
1584+
xcell_a2 = wks.cell('A2')
1585+
self.assertEqual(xcell_b1.style, openpyxl_sty_merged)
1586+
self.assertEqual(xcell_a2.style, openpyxl_sty_merged)
1587+
1588+
def skip_openpyxl_lt22(cls):
1589+
"""Skip a TestCase instance if openpyxl < 2.2"""
1590+
1591+
@classmethod
1592+
def setUpClass(cls):
15451593
_skip_if_no_openpyxl()
1594+
import openpyxl
1595+
ver = openpyxl.__version__
1596+
if ver < LooseVersion('2.2.0'):
1597+
raise nose.SkipTest("openpyxl < 2.2")
1598+
1599+
cls.setUpClass = setUpClass
1600+
return cls
1601+
1602+
@raise_on_incompat_version(2)
1603+
@skip_openpyxl_lt22
1604+
class Openpyxl22Tests(ExcelWriterBase, tm.TestCase):
1605+
ext = '.xlsx'
1606+
engine_name = 'openpyxl22'
1607+
check_skip = staticmethod(lambda *args, **kwargs: None)
1608+
1609+
def test_to_excel_styleconverter(self):
1610+
import openpyxl
1611+
from openpyxl import styles
1612+
1613+
hstyle = {
1614+
"font": {
1615+
"color": '00FF0000',
1616+
"bold": True,
1617+
},
1618+
"borders": {
1619+
"top": "thin",
1620+
"right": "thin",
1621+
"bottom": "thin",
1622+
"left": "thin",
1623+
},
1624+
"alignment": {
1625+
"horizontal": "center",
1626+
"vertical": "top",
1627+
},
1628+
"fill": {
1629+
"patternType": 'solid',
1630+
'fgColor': {
1631+
'rgb': '006666FF',
1632+
'tint': 0.3,
1633+
},
1634+
},
1635+
"number_format": {
1636+
"format_code": "0.00"
1637+
},
1638+
"protection": {
1639+
"locked": True,
1640+
"hidden": False,
1641+
},
1642+
}
1643+
1644+
font_color = styles.Color('00FF0000')
1645+
font = styles.Font(bold=True, color=font_color)
1646+
side = styles.Side(style=styles.borders.BORDER_THIN)
1647+
border = styles.Border(top=side, right=side, bottom=side, left=side)
1648+
alignment = styles.Alignment(horizontal='center', vertical='top')
1649+
fill_color = styles.Color(rgb='006666FF', tint=0.3)
1650+
fill = styles.PatternFill(patternType='solid', fgColor=fill_color)
1651+
1652+
number_format = '0.00'
1653+
1654+
protection = styles.Protection(locked=True, hidden=False)
1655+
1656+
kw = _Openpyxl22Writer._convert_to_style_kwargs(hstyle)
1657+
self.assertEqual(kw['font'], font)
1658+
self.assertEqual(kw['border'], border)
1659+
self.assertEqual(kw['alignment'], alignment)
1660+
self.assertEqual(kw['fill'], fill)
1661+
self.assertEqual(kw['number_format'], number_format)
1662+
self.assertEqual(kw['protection'], protection)
1663+
1664+
1665+
def test_write_cells_merge_styled(self):
15461666
if not openpyxl_compat.is_compat(major_ver=2):
15471667
raise nose.SkipTest('incompatiable openpyxl version')
15481668

@@ -1560,23 +1680,23 @@ def test_write_cells_merge_styled(self):
15601680
]
15611681

15621682
sty_merged = {'font': { 'color': '000000FF', 'bold': True }}
1563-
sty_kwargs = _Openpyxl2Writer._convert_to_style_kwargs(sty_merged)
1564-
openpyxl_sty_merged = styles.Style(**sty_kwargs)
1683+
sty_kwargs = _Openpyxl22Writer._convert_to_style_kwargs(sty_merged)
1684+
openpyxl_sty_merged = sty_kwargs['font']
15651685
merge_cells = [
15661686
ExcelCell(col=0, row=0, val='pandas',
15671687
mergestart=1, mergeend=1, style=sty_merged),
15681688
]
15691689

15701690
with ensure_clean('.xlsx') as path:
1571-
writer = _Openpyxl2Writer(path)
1691+
writer = _Openpyxl22Writer(path)
15721692
writer.write_cells(initial_cells, sheet_name=sheet_name)
15731693
writer.write_cells(merge_cells, sheet_name=sheet_name)
15741694

15751695
wks = writer.sheets[sheet_name]
15761696
xcell_b1 = wks.cell('B1')
15771697
xcell_a2 = wks.cell('A2')
1578-
self.assertEqual(xcell_b1.style, openpyxl_sty_merged)
1579-
self.assertEqual(xcell_a2.style, openpyxl_sty_merged)
1698+
self.assertEqual(xcell_b1.font, openpyxl_sty_merged)
1699+
self.assertEqual(xcell_a2.font, openpyxl_sty_merged)
15801700

15811701

15821702
class XlwtTests(ExcelWriterBase, tm.TestCase):
@@ -1676,9 +1796,9 @@ def test_column_format(self):
16761796
cell = read_worksheet.cell('B2')
16771797

16781798
try:
1679-
read_num_format = cell.style.number_format._format_code
1799+
read_num_format = cell.number_format
16801800
except:
1681-
read_num_format = cell.style.number_format
1801+
read_num_format = cell.style.number_format._format_code
16821802

16831803
self.assertEqual(read_num_format, num_format)
16841804

tox.ini

+21-1
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@ deps =
1414
python-dateutil
1515
beautifulsoup4
1616
lxml
17-
openpyxl<2.0.0
1817
xlsxwriter
1918
xlrd
2019
six
@@ -70,3 +69,24 @@ deps =
7069
deps =
7170
numpy==1.8.0
7271
{[testenv]deps}
72+
73+
[testenv:openpyxl1]
74+
usedevelop = True
75+
deps =
76+
{[testenv]deps}
77+
openpyxl<2.0.0
78+
commands = {envbindir}/nosetests {toxinidir}/pandas/io/tests/test_excel.py
79+
80+
[testenv:openpyxl20]
81+
usedevelop = True
82+
deps =
83+
{[testenv]deps}
84+
openpyxl<2.2.0
85+
commands = {envbindir}/nosetests {posargs} {toxinidir}/pandas/io/tests/test_excel.py
86+
87+
[testenv:openpyxl22]
88+
usedevelop = True
89+
deps =
90+
{[testenv]deps}
91+
openpyxl>=2.2.0
92+
commands = {envbindir}/nosetests {posargs} {toxinidir}/pandas/io/tests/test_excel.py

0 commit comments

Comments
 (0)