diff --git a/doc/source/whatsnew/v0.24.0.txt b/doc/source/whatsnew/v0.24.0.txt index e931450cb5c01..6997ea84e5b83 100644 --- a/doc/source/whatsnew/v0.24.0.txt +++ b/doc/source/whatsnew/v0.24.0.txt @@ -8,6 +8,8 @@ v0.24.0 New features ~~~~~~~~~~~~ +- ``ExcelWriter`` now accepts ``mode`` as a keyword argument, enabling append to existing workbooks when using the ``openpyxl`` engine (:issue:`3441`) + .. _whatsnew_0240.enhancements.other: Other Enhancements diff --git a/pandas/io/excel.py b/pandas/io/excel.py index 5608c29637447..e86d33742b266 100644 --- a/pandas/io/excel.py +++ b/pandas/io/excel.py @@ -804,6 +804,10 @@ class ExcelWriter(object): datetime_format : string, default None Format string for datetime objects written into Excel files (e.g. 'YYYY-MM-DD HH:MM:SS') + mode : {'w' or 'a'}, default 'w' + File mode to use (write or append). + + .. versionadded:: 0.24.0 Notes ----- @@ -897,7 +901,8 @@ def save(self): pass def __init__(self, path, engine=None, - date_format=None, datetime_format=None, **engine_kwargs): + date_format=None, datetime_format=None, mode='w', + **engine_kwargs): # validate that this engine can handle the extension if isinstance(path, string_types): ext = os.path.splitext(path)[-1] @@ -919,6 +924,8 @@ def __init__(self, path, engine=None, else: self.datetime_format = datetime_format + self.mode = mode + def __fspath__(self): return _stringify_path(self.path) @@ -993,23 +1000,27 @@ class _OpenpyxlWriter(ExcelWriter): engine = 'openpyxl' supported_extensions = ('.xlsx', '.xlsm') - def __init__(self, path, engine=None, **engine_kwargs): + def __init__(self, path, engine=None, mode='w', **engine_kwargs): # Use the openpyxl module as the Excel writer. from openpyxl.workbook import Workbook - super(_OpenpyxlWriter, self).__init__(path, **engine_kwargs) + super(_OpenpyxlWriter, self).__init__(path, mode=mode, **engine_kwargs) - # Create workbook object with default optimized_write=True. - self.book = Workbook() + if self.mode == 'a': # Load from existing workbook + from openpyxl import load_workbook + book = load_workbook(self.path) + self.book = book + else: + # Create workbook object with default optimized_write=True. + self.book = Workbook() - # Openpyxl 1.6.1 adds a dummy sheet. We remove it. - if self.book.worksheets: - try: - self.book.remove(self.book.worksheets[0]) - except AttributeError: + if self.book.worksheets: + try: + self.book.remove(self.book.worksheets[0]) + except AttributeError: - # compat - self.book.remove_sheet(self.book.worksheets[0]) + # compat - for openpyxl <= 2.4 + self.book.remove_sheet(self.book.worksheets[0]) def save(self): """ @@ -1443,11 +1454,16 @@ class _XlwtWriter(ExcelWriter): engine = 'xlwt' supported_extensions = ('.xls',) - def __init__(self, path, engine=None, encoding=None, **engine_kwargs): + def __init__(self, path, engine=None, encoding=None, mode='w', + **engine_kwargs): # Use the xlwt module as the Excel writer. import xlwt engine_kwargs['engine'] = engine - super(_XlwtWriter, self).__init__(path, **engine_kwargs) + + if mode == 'a': + raise ValueError('Append mode is not supported with xlwt!') + + super(_XlwtWriter, self).__init__(path, mode=mode, **engine_kwargs) if encoding is None: encoding = 'ascii' @@ -1713,13 +1729,18 @@ class _XlsxWriter(ExcelWriter): supported_extensions = ('.xlsx',) def __init__(self, path, engine=None, - date_format=None, datetime_format=None, **engine_kwargs): + date_format=None, datetime_format=None, mode='w', + **engine_kwargs): # Use the xlsxwriter module as the Excel writer. import xlsxwriter + if mode == 'a': + raise ValueError('Append mode is not supported with xlsxwriter!') + super(_XlsxWriter, self).__init__(path, engine=engine, date_format=date_format, datetime_format=datetime_format, + mode=mode, **engine_kwargs) self.book = xlsxwriter.Workbook(path, **engine_kwargs) diff --git a/pandas/tests/io/test_excel.py b/pandas/tests/io/test_excel.py index 05423474f330a..2a225e6fe6a45 100644 --- a/pandas/tests/io/test_excel.py +++ b/pandas/tests/io/test_excel.py @@ -2006,6 +2006,31 @@ def test_write_cells_merge_styled(self, merge_cells, ext, engine): assert xcell_b1.font == openpyxl_sty_merged assert xcell_a2.font == openpyxl_sty_merged + @pytest.mark.parametrize("mode,expected", [ + ('w', ['baz']), ('a', ['foo', 'bar', 'baz'])]) + def test_write_append_mode(self, merge_cells, ext, engine, mode, expected): + import openpyxl + df = DataFrame([1], columns=['baz']) + + with ensure_clean(ext) as f: + wb = openpyxl.Workbook() + wb.worksheets[0].title = 'foo' + wb.worksheets[0]['A1'].value = 'foo' + wb.create_sheet('bar') + wb.worksheets[1]['A1'].value = 'bar' + wb.save(f) + + writer = ExcelWriter(f, engine=engine, mode=mode) + df.to_excel(writer, sheet_name='baz', index=False) + writer.save() + + wb2 = openpyxl.load_workbook(f) + result = [sheet.title for sheet in wb2.worksheets] + assert result == expected + + for index, cell_value in enumerate(expected): + assert wb2.worksheets[index]['A1'].value == cell_value + @td.skip_if_no('xlwt') @pytest.mark.parametrize("merge_cells,ext,engine", [ @@ -2060,6 +2085,13 @@ def test_to_excel_styleconverter(self, merge_cells, ext, engine): assert xlwt.Alignment.HORZ_CENTER == xls_style.alignment.horz assert xlwt.Alignment.VERT_TOP == xls_style.alignment.vert + def test_write_append_mode_raises(self, merge_cells, ext, engine): + msg = "Append mode is not supported with xlwt!" + + with ensure_clean(ext) as f: + with tm.assert_raises_regex(ValueError, msg): + ExcelWriter(f, engine=engine, mode='a') + @td.skip_if_no('xlsxwriter') @pytest.mark.parametrize("merge_cells,ext,engine", [ @@ -2111,6 +2143,13 @@ def test_column_format(self, merge_cells, ext, engine): assert read_num_format == num_format + def test_write_append_mode_raises(self, merge_cells, ext, engine): + msg = "Append mode is not supported with xlsxwriter!" + + with ensure_clean(ext) as f: + with tm.assert_raises_regex(ValueError, msg): + ExcelWriter(f, engine=engine, mode='a') + class TestExcelWriterEngineTests(object):