Skip to content
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

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

use a versionadded directive here

.. 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