diff --git a/doc/source/reference/io.rst b/doc/source/reference/io.rst index 44ee09f2a5e6b..70fd381bffd2c 100644 --- a/doc/source/reference/io.rst +++ b/doc/source/reference/io.rst @@ -53,7 +53,6 @@ Excel .. autosummary:: :toctree: api/ - :template: autosummary/class_without_autosummary.rst ExcelWriter diff --git a/doc/source/whatsnew/v1.5.0.rst b/doc/source/whatsnew/v1.5.0.rst index cd4e07b76727b..73eee5885e364 100644 --- a/doc/source/whatsnew/v1.5.0.rst +++ b/doc/source/whatsnew/v1.5.0.rst @@ -184,6 +184,43 @@ use ``series.loc[i:j]``. Slicing on a :class:`DataFrame` will not be affected. +.. _whatsnew_150.deprecations.excel_writer_attributes: + +:class:`ExcelWriter` attributes +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +All attributes of :class:`ExcelWriter` were previously documented as not +public. However some third party Excel engines documented accessing +``ExcelWriter.book`` or ``ExcelWriter.sheets``, and users were utilizing these +and possibly other attributes. Previously these attributes were not safe to use; +e.g. modifications to ``ExcelWriter.book`` would not update ``ExcelWriter.sheets`` +and conversely. In order to support this, pandas has made some attributes public +and improved their implementations so that they may now be safely used. (:issue:`45572`) + +The following attributes are now public and considered safe to access. + + - ``book`` + - ``check_extension`` + - ``close`` + - ``date_format`` + - ``datetime_format`` + - ``engine`` + - ``if_sheet_exists`` + - ``sheets`` + - ``supported_extensions`` + +The following attributes have been deprecated. They now raise a ``FutureWarning`` +when accessed and will removed in a future version. Users should be aware +that their usage is considered unsafe, and can lead to unexpected results. + + - ``cur_sheet`` + - ``handles`` + - ``path`` + - ``save`` + - ``write_cells`` + +See the documentation of :class:`ExcelWriter` for further details. + .. _whatsnew_150.deprecations.other: Other Deprecations diff --git a/pandas/io/excel/_base.py b/pandas/io/excel/_base.py index b33955737a111..c32f84d41ebde 100644 --- a/pandas/io/excel/_base.py +++ b/pandas/io/excel/_base.py @@ -836,18 +836,8 @@ class ExcelWriter(metaclass=abc.ABCMeta): Use engine_kwargs instead. - Attributes - ---------- - None - - Methods - ------- - None - Notes ----- - None of the methods and properties are considered public. - For compatibility with CSV writers, ExcelWriter serializes lists and dicts to strings before writing. @@ -1034,7 +1024,7 @@ def __new__( return object.__new__(cls) # declare external properties you can count on - path = None + _path = None @property @abc.abstractmethod @@ -1054,7 +1044,16 @@ def sheets(self) -> dict[str, Any]: """Mapping of sheet names to sheet objects.""" pass + @property @abc.abstractmethod + def book(self): + """ + Book instance. Class type will depend on the engine used. + + This attribute can be used to access engine-specific features. + """ + pass + def write_cells( self, cells, @@ -1066,6 +1065,8 @@ def write_cells( """ Write given formatted cells into Excel an excel sheet + .. deprecated:: 1.5.0 + Parameters ---------- cells : generator @@ -1077,12 +1078,47 @@ def write_cells( freeze_panes: int tuple of length 2 contains the bottom-most row and right-most column to freeze """ - pass + self._deprecate("write_cells") + return self._write_cells(cells, sheet_name, startrow, startcol, freeze_panes) @abc.abstractmethod + def _write_cells( + self, + cells, + sheet_name: str | None = None, + startrow: int = 0, + startcol: int = 0, + freeze_panes: tuple[int, int] | None = None, + ) -> None: + """ + Write given formatted cells into Excel an excel sheet + + Parameters + ---------- + cells : generator + cell of formatted data to save to Excel sheet + sheet_name : str, default None + Name of Excel sheet, if None, then use self.cur_sheet + startrow : upper left cell row to dump data frame + startcol : upper left cell column to dump data frame + freeze_panes: int tuple of length 2 + contains the bottom-most row and right-most column to freeze + """ + pass + def save(self) -> None: """ Save workbook to disk. + + .. deprecated:: 1.5.0 + """ + self._deprecate("save") + return self._save() + + @abc.abstractmethod + def _save(self) -> None: + """ + Save workbook to disk. """ pass @@ -1111,25 +1147,25 @@ def __init__( mode = mode.replace("a", "r+") # cast ExcelWriter to avoid adding 'if self.handles is not None' - self.handles = IOHandles( + self._handles = IOHandles( cast(IO[bytes], path), compression={"compression": None} ) if not isinstance(path, ExcelWriter): - self.handles = get_handle( + self._handles = get_handle( path, mode, storage_options=storage_options, is_text=False ) - self.cur_sheet = None + self._cur_sheet = None if date_format is None: - self.date_format = "YYYY-MM-DD" + self._date_format = "YYYY-MM-DD" else: - self.date_format = date_format + self._date_format = date_format if datetime_format is None: - self.datetime_format = "YYYY-MM-DD HH:MM:SS" + self._datetime_format = "YYYY-MM-DD HH:MM:SS" else: - self.datetime_format = datetime_format + self._datetime_format = datetime_format - self.mode = mode + self._mode = mode if if_sheet_exists not in (None, "error", "new", "replace", "overlay"): raise ValueError( @@ -1140,16 +1176,78 @@ def __init__( raise ValueError("if_sheet_exists is only valid in append mode (mode='a')") if if_sheet_exists is None: if_sheet_exists = "error" - self.if_sheet_exists = if_sheet_exists + self._if_sheet_exists = if_sheet_exists + + def _deprecate(self, attr: str): + """ + Deprecate attribute or method for ExcelWriter. + """ + warnings.warn( + f"{attr} is not part of the public API, usage can give in unexpected " + "results and will be removed in a future version", + FutureWarning, + stacklevel=find_stack_level(), + ) + + @property + def date_format(self) -> str: + """ + Format string for dates written into Excel files (e.g. ‘YYYY-MM-DD’). + """ + return self._date_format + + @property + def datetime_format(self) -> str: + """ + Format string for dates written into Excel files (e.g. ‘YYYY-MM-DD’). + """ + return self._datetime_format + + @property + def if_sheet_exists(self) -> str: + """ + How to behave when writing to a sheet that already exists in append mode. + """ + return self._if_sheet_exists + + @property + def cur_sheet(self): + """ + Current sheet for writing. + + .. deprecated:: 1.5.0 + """ + self._deprecate("cur_sheet") + return self._cur_sheet + + @property + def handles(self): + """ + Handles to Excel sheets. + + .. deprecated:: 1.5.0 + """ + self._deprecate("handles") + return self._handles + + @property + def path(self): + """ + Path to Excel file. + + .. deprecated:: 1.5.0 + """ + self._deprecate("path") + return self._path def __fspath__(self): - return getattr(self.handles.handle, "name", "") + return getattr(self._handles.handle, "name", "") def _get_sheet_name(self, sheet_name: str | None) -> str: if sheet_name is None: - sheet_name = self.cur_sheet + sheet_name = self._cur_sheet if sheet_name is None: # pragma: no cover - raise ValueError("Must pass explicit sheet_name or set cur_sheet property") + raise ValueError("Must pass explicit sheet_name or set _cur_sheet property") return sheet_name def _value_with_fmt(self, val) -> tuple[object, str | None]: @@ -1175,9 +1273,9 @@ def _value_with_fmt(self, val) -> tuple[object, str | None]: elif is_bool(val): val = bool(val) elif isinstance(val, datetime.datetime): - fmt = self.datetime_format + fmt = self._datetime_format elif isinstance(val, datetime.date): - fmt = self.date_format + fmt = self._date_format elif isinstance(val, datetime.timedelta): val = val.total_seconds() / 86400 fmt = "0" @@ -1213,8 +1311,8 @@ def __exit__(self, exc_type, exc_value, traceback): def close(self) -> None: """synonym for save, to make it more file-like""" - self.save() - self.handles.close() + self._save() + self._handles.close() XLS_SIGNATURES = ( diff --git a/pandas/io/excel/_odswriter.py b/pandas/io/excel/_odswriter.py index 77e75b13f968c..94f173c1469e0 100644 --- a/pandas/io/excel/_odswriter.py +++ b/pandas/io/excel/_odswriter.py @@ -55,9 +55,18 @@ def __init__( engine_kwargs = combine_kwargs(engine_kwargs, kwargs) - self.book = OpenDocumentSpreadsheet(**engine_kwargs) + self._book = OpenDocumentSpreadsheet(**engine_kwargs) self._style_dict: dict[str, str] = {} + @property + def book(self): + """ + Book instance of class odf.opendocument.OpenDocumentSpreadsheet. + + This attribute can be used to access engine-specific features. + """ + return self._book + @property def sheets(self) -> dict[str, Any]: """Mapping of sheet names to sheet objects.""" @@ -69,15 +78,15 @@ def sheets(self) -> dict[str, Any]: } return result - def save(self) -> None: + def _save(self) -> None: """ Save workbook to disk. """ for sheet in self.sheets.values(): self.book.spreadsheet.addElement(sheet) - self.book.save(self.handles.handle) + self.book.save(self._handles.handle) - def write_cells( + def _write_cells( self, cells: list[ExcelCell], sheet_name: str | None = None, diff --git a/pandas/io/excel/_openpyxl.py b/pandas/io/excel/_openpyxl.py index 4b03c2536b31b..1ef4c348773bc 100644 --- a/pandas/io/excel/_openpyxl.py +++ b/pandas/io/excel/_openpyxl.py @@ -63,32 +63,41 @@ def __init__( # ExcelWriter replaced "a" by "r+" to allow us to first read the excel file from # the file and later write to it - if "r+" in self.mode: # Load from existing workbook + if "r+" in self._mode: # Load from existing workbook from openpyxl import load_workbook - self.book = load_workbook(self.handles.handle, **engine_kwargs) - self.handles.handle.seek(0) + self._book = load_workbook(self._handles.handle, **engine_kwargs) + self._handles.handle.seek(0) else: # Create workbook object with default optimized_write=True. - self.book = Workbook(**engine_kwargs) + self._book = Workbook(**engine_kwargs) if self.book.worksheets: self.book.remove(self.book.worksheets[0]) + @property + def book(self): + """ + Book instance of class openpyxl.workbook.Workbook. + + This attribute can be used to access engine-specific features. + """ + return self._book + @property def sheets(self) -> dict[str, Any]: """Mapping of sheet names to sheet objects.""" result = {name: self.book[name] for name in self.book.sheetnames} return result - def save(self) -> None: + def _save(self) -> None: """ Save workbook to disk. """ - self.book.save(self.handles.handle) - if "r+" in self.mode and not isinstance(self.handles.handle, mmap.mmap): + self.book.save(self._handles.handle) + if "r+" in self._mode and not isinstance(self._handles.handle, mmap.mmap): # truncate file to the written content - self.handles.handle.truncate() + self._handles.handle.truncate() @classmethod def _convert_to_style_kwargs(cls, style_dict: dict) -> dict[str, Serialisable]: @@ -424,7 +433,7 @@ def _convert_to_protection(cls, protection_dict): return Protection(**protection_dict) - def write_cells( + def _write_cells( self, cells, sheet_name: str | None = None, @@ -437,23 +446,23 @@ def write_cells( _style_cache: dict[str, dict[str, Serialisable]] = {} - if sheet_name in self.sheets and self.if_sheet_exists != "new": - if "r+" in self.mode: - if self.if_sheet_exists == "replace": + if sheet_name in self.sheets and self._if_sheet_exists != "new": + if "r+" in self._mode: + if self._if_sheet_exists == "replace": old_wks = self.sheets[sheet_name] target_index = self.book.index(old_wks) del self.book[sheet_name] wks = self.book.create_sheet(sheet_name, target_index) - elif self.if_sheet_exists == "error": + elif self._if_sheet_exists == "error": raise ValueError( f"Sheet '{sheet_name}' already exists and " f"if_sheet_exists is set to 'error'." ) - elif self.if_sheet_exists == "overlay": + elif self._if_sheet_exists == "overlay": wks = self.sheets[sheet_name] else: raise ValueError( - f"'{self.if_sheet_exists}' is not valid for if_sheet_exists. " + f"'{self._if_sheet_exists}' is not valid for if_sheet_exists. " "Valid options are 'error', 'new', 'replace' and 'overlay'." ) else: diff --git a/pandas/io/excel/_xlsxwriter.py b/pandas/io/excel/_xlsxwriter.py index dbd6264827591..45fe4713ce194 100644 --- a/pandas/io/excel/_xlsxwriter.py +++ b/pandas/io/excel/_xlsxwriter.py @@ -203,20 +203,29 @@ def __init__( engine_kwargs=engine_kwargs, ) - self.book = Workbook(self.handles.handle, **engine_kwargs) + self._book = Workbook(self._handles.handle, **engine_kwargs) + + @property + def book(self): + """ + Book instance of class xlsxwriter.Workbook. + + This attribute can be used to access engine-specific features. + """ + return self._book @property def sheets(self) -> dict[str, Any]: result = self.book.sheetnames return result - def save(self) -> None: + def _save(self) -> None: """ Save workbook to disk. """ self.book.close() - def write_cells( + def _write_cells( self, cells, sheet_name: str | None = None, diff --git a/pandas/io/excel/_xlwt.py b/pandas/io/excel/_xlwt.py index fe2addc890c22..871fcbd3a8475 100644 --- a/pandas/io/excel/_xlwt.py +++ b/pandas/io/excel/_xlwt.py @@ -59,9 +59,18 @@ def __init__( if encoding is None: encoding = "ascii" - self.book = xlwt.Workbook(encoding=encoding, **engine_kwargs) - self.fm_datetime = xlwt.easyxf(num_format_str=self.datetime_format) - self.fm_date = xlwt.easyxf(num_format_str=self.date_format) + self._book = xlwt.Workbook(encoding=encoding, **engine_kwargs) + self._fm_datetime = xlwt.easyxf(num_format_str=self._datetime_format) + self._fm_date = xlwt.easyxf(num_format_str=self._date_format) + + @property + def book(self): + """ + Book instance of class xlwt.Workbook. + + This attribute can be used to access engine-specific features. + """ + return self._book @property def sheets(self) -> dict[str, Any]: @@ -69,15 +78,31 @@ def sheets(self) -> dict[str, Any]: result = {sheet.name: sheet for sheet in self.book._Workbook__worksheets} return result - def save(self) -> None: + @property + def fm_date(self): + """ + XFStyle formatter for dates. + """ + self._deprecate("fm_date") + return self._fm_date + + @property + def fm_datetime(self): + """ + XFStyle formatter for dates. + """ + self._deprecate("fm_datetime") + return self._fm_datetime + + def _save(self) -> None: """ Save workbook to disk. """ if self.sheets: # fails when the ExcelWriter is just opened and then closed - self.book.save(self.handles.handle) + self.book.save(self._handles.handle) - def write_cells( + def _write_cells( self, cells, sheet_name: str | None = None, diff --git a/pandas/io/formats/excel.py b/pandas/io/formats/excel.py index 768c462a746dc..2aa67746c16d7 100644 --- a/pandas/io/formats/excel.py +++ b/pandas/io/formats/excel.py @@ -888,7 +888,7 @@ def write( need_save = True try: - writer.write_cells( + writer._write_cells( formatted_cells, sheet_name, startrow=startrow, diff --git a/pandas/tests/io/excel/test_openpyxl.py b/pandas/tests/io/excel/test_openpyxl.py index 0387591a248c1..bab1a1eed97c2 100644 --- a/pandas/tests/io/excel/test_openpyxl.py +++ b/pandas/tests/io/excel/test_openpyxl.py @@ -76,8 +76,8 @@ def test_write_cells_merge_styled(ext): with tm.ensure_clean(ext) as path: with _OpenpyxlWriter(path) as writer: - writer.write_cells(initial_cells, sheet_name=sheet_name) - writer.write_cells(merge_cells, sheet_name=sheet_name) + 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["B1"] diff --git a/pandas/tests/io/excel/test_writers.py b/pandas/tests/io/excel/test_writers.py index eb860bb49250f..16a656d71e496 100644 --- a/pandas/tests/io/excel/test_writers.py +++ b/pandas/tests/io/excel/test_writers.py @@ -1261,6 +1261,30 @@ def test_to_excel_empty_frame(self, engine, ext): expected = DataFrame() tm.assert_frame_equal(result, expected) + @pytest.mark.parametrize("attr", ["cur_sheet", "handles", "path"]) + def test_deprecated_attr(self, engine, ext, attr): + # GH#45572 + with tm.ensure_clean(ext) as path: + with ExcelWriter(path) as writer: + msg = f"{attr} is not part of the public API" + with tm.assert_produces_warning(FutureWarning, match=msg): + getattr(writer, attr) + # Some engines raise if nothing is written + DataFrame().to_excel(writer) + + @pytest.mark.parametrize( + "attr, args", [("save", ()), ("write_cells", ([], "test"))] + ) + def test_deprecated_method(self, engine, ext, attr, args): + # GH#45572 + with tm.ensure_clean(ext) as path: + with ExcelWriter(path) as writer: + msg = f"{attr} is not part of the public API" + # Some engines raise if nothing is written + DataFrame().to_excel(writer) + with tm.assert_produces_warning(FutureWarning, match=msg): + getattr(writer, attr)(*args) + class TestExcelWriterEngineTests: @pytest.mark.parametrize( @@ -1297,10 +1321,13 @@ class DummyClass(ExcelWriter): supported_extensions = ["xlsx", "xls"] engine = "dummy" - def save(self): + def book(self): + pass + + def _save(self): called_save.append(True) - def write_cells(self, *args, **kwargs): + def _write_cells(self, *args, **kwargs): called_write_cells.append(True) @property diff --git a/pandas/tests/io/excel/test_xlwt.py b/pandas/tests/io/excel/test_xlwt.py index 2d5386d6c616d..3aa405eb1e275 100644 --- a/pandas/tests/io/excel/test_xlwt.py +++ b/pandas/tests/io/excel/test_xlwt.py @@ -134,3 +134,13 @@ def test_book_and_sheets_consistent(ext): assert writer.sheets == {} sheet = writer.book.add_sheet("test_name") assert writer.sheets == {"test_name": sheet} + + +@pytest.mark.parametrize("attr", ["fm_date", "fm_datetime"]) +def test_deprecated_attr(ext, attr): + # GH#45572 + with tm.ensure_clean(ext) as path: + with ExcelWriter(path, engine="xlwt") as writer: + msg = f"{attr} is not part of the public API" + with tm.assert_produces_warning(FutureWarning, match=msg): + getattr(writer, attr)