Skip to content

Openpyxl22 #11144

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
76 changes: 72 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,74 @@ 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)

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)

# 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):
Copy link
Contributor

Choose a reason for hiding this comment

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

this would almost never be true, as we only have internally Timestamp (which is a sub-class of datetime.datetime). prob what you want is something like core.format._is_dates_only (which you should only call on an entire array/columns (a-priori to iterating over the cells)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

The code has been kept around from the previous implementation. openpyxl itself will automatically assign date and time formatting to relevant objects. The preferred method for adding a Pandas Dataframe to an openpyxl worksheet is described here: https://bitbucket.org/snippets/openpyxl/jgbak .Hope to add support for NumPy types soon so that only the conversion from a Dataframe to lists of lists will be required.

I haven't used that here because it doesn't look like a Dataframe is being passed in but rather some kind of cell collection abstraction: offsets and styling.

xcell.number_format = self.date_format

style_kwargs = {}
if cell.style:
style_kwargs = self._convert_to_style_kwargs(cell.style)

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
156 changes: 138 additions & 18 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 @@ -1514,25 +1525,135 @@ def test_to_excel_styleconverter(self):
"hidden": False,
},
}

#
Copy link
Contributor

Choose a reason for hiding this comment

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

take out these commented lines

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):
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 = _Openpyxl2Writer._convert_to_style_kwargs(hstyle)
kw = _Openpyxl22Writer._convert_to_style_kwargs(hstyle)
self.assertEqual(kw['font'], font)
self.assertEqual(kw['border'], border)
self.assertEqual(kw['alignment'], alignment)
Expand All @@ -1542,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')

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