pandas-dev/pandas · #69457
ENH: infer read_excel engine from a workbook object (GH#46352)
doc/source/whatsnew/v3.1.0.rst1 + / 0 −
@@ -160,6 +160,7 @@ Other enhancements - Added :class:`NamedFunc`, an alias to :class:`NamedAgg` for a more semantically accurate name when used with non-aggregation functions; either can accept arbitrary functions (:issue:`65164`) - :meth:`ExtensionArray.map` now calls :meth:`ExtensionArray._cast_pointwise_result` to retain the dtype backend, e.g. Arrow-backed arrays now preserve their Arrow dtype through ``map`` (:issue:`57189`, :issue:`62164`) - :func:`read_csv` now supports ``dtype="complex64"`` and ``dtype="complex128"`` with the C engine, enabling round-tripping of complex-number columns written by :meth:`DataFrame.to_csv` (:issue:`9379`)+- :func:`read_excel` and :class:`ExcelFile` now infer ``engine`` from a workbook object created by a supported engine's library, such as an ``openpyxl.Workbook``, instead of raising (:issue:`46352`) - :func:`to_datetime` and ``strptime`` parsing now support the ``%N`` directive for matching exactly 9 digits representing nanoseconds, providing symmetry with ``strftime`` formatting (:issue:`65863`) - :meth:`DataFrame.select_dtypes` can now select ``datetime64`` and ``timedelta64`` columns by a specific resolution (e.g. ``"datetime64[us]"``, ``np.dtype("timedelta64[ms]")``), which previously raised; a unit-specific spec matches only columns with exactly that resolution, while a unitless ``"datetime64"``/``"timedelta64"`` still matches every resolution (:issue:`40234`) - :meth:`Series.round` now works on third-party numeric :class:`ExtensionArray` types via a default :meth:`ExtensionArray.round` (:issue:`49387`)pandas/io/excel/_base.py65 + / 49 −
@@ -206,7 +206,7 @@ def read_excel( Parameters ----------- io : str, ExcelFile, xlrd.Book, path object, or file-like object+ io : str, ExcelFile, workbook object, path object, or file-like object Any valid string path is acceptable. The string could be a URL. Valid URL schemes include http, ftp, s3, and file. For file URLs, a host is expected. A local file could be: ``file://localhost/path/to/table.xlsx``.@@ -221,6 +221,9 @@ def read_excel( such as a file handle (e.g. via builtin ``open`` function) or ``StringIO``. + A workbook object from a supported engine's library, such as an+ ``openpyxl.Workbook``, is read with that engine.+ sheet_name : str, int, list, or None, default 0 Strings are used for sheet names. Integers are used in zero-indexed sheet positions (chart sheets do not count as a sheet position).@@ -291,7 +294,6 @@ def read_excel( of dtype conversion. If you use ``None``, it will infer the dtype of each column based on the data. engine : {'openpyxl', 'calamine', 'odf', 'pyxlsb', 'xlrd'}, default None- If io is not a buffer or path, this must be set to identify io. Engine compatibility : - ``openpyxl`` supports newer Excel file formats.@@ -303,7 +305,8 @@ def read_excel( When ``engine=None``, the following logic will be used to determine the engine: - - If ``path_or_buffer`` is an OpenDocument format (.odf, .ods, .odt),+ - If ``io`` is a workbook object, the engine that created it will be used.+ - Otherwise if ``path_or_buffer`` is an OpenDocument format (.odf, .ods, .odt), then `odf <https://pypi.org/project/odfpy/>`_ will be used. - Otherwise if ``path_or_buffer`` is an xls format, ``xlrd`` will be used. - Otherwise if ``path_or_buffer`` is in xlsb format, ``pyxlsb`` will be used.@@ -1538,6 +1541,29 @@ def inspect_excel_format( return "zip" +# GH#46352 - engine name -> (module, class) of the workbook objects it reads+_WORKBOOK_CLASSES = {+ "xlrd": ("xlrd", "Book"),+ "openpyxl": ("openpyxl", "Workbook"),+ "odf": ("odf.opendocument", "OpenDocument"),+ "pyxlsb": ("pyxlsb", "Workbook"),+ "calamine": ("python_calamine", "CalamineWorkbook"),+}+++def _infer_engine_from_workbook(obj) -> str | None:+ """+ Return the engine whose library created the workbook ``obj``, if any.+ """+ for engine, (module_name, class_name) in _WORKBOOK_CLASSES.items():+ # GH#56692 - avoid importing optional dependencies; such a workbook+ # can only exist if the caller already imported its module.+ module = sys.modules.get(module_name)+ if module is not None and isinstance(obj, getattr(module, class_name)):+ return engine+ return None++ @set_module("pandas") class ExcelFile: """@@ -1548,11 +1574,10 @@ class ExcelFile: Parameters ---------- path_or_buffer : str, bytes, pathlib.Path,- A file-like object, xlrd workbook or openpyxl workbook.+ A file-like object or a workbook object from a supported engine. If a string or path object, expected to be a path to a .xls, .xlsx, .xlsb, .xlsm, .odf, .ods, or .odt file. engine : str, default None- If io is not a buffer or path, this must be set to identify io. Supported engines: ``xlrd``, ``openpyxl``, ``odf``, ``pyxlsb``, ``calamine`` Engine compatibility : @@ -1568,7 +1593,9 @@ class ExcelFile: When ``engine=None``, the following logic will be used to determine the engine: - - If ``path_or_buffer`` is an OpenDocument format (.odf, .ods, .odt),+ - If ``path_or_buffer`` is a workbook object, the engine that created it+ will be used.+ - Otherwise if ``path_or_buffer`` is an OpenDocument format (.odf, .ods, .odt), then `odf <https://pypi.org/project/odfpy/>`_ will be used. - Otherwise if ``path_or_buffer`` is an xls format, ``xlrd`` will be used.@@ -1645,53 +1672,42 @@ def __init__( self._io = stringify_path(path_or_buffer) if engine is None:- # Only determine ext if it is needed- ext: str | None = None-- # GH#56692 - avoid importing xlrd; a Book only exists if the caller- # already imported it. GH#68086 - must precede inspect_excel_format,- # which seeks.- xlrd = sys.modules.get("xlrd")- if xlrd is not None and isinstance(path_or_buffer, xlrd.Book):- ext = "xls"- engine = "xlrd"+ # GH#68086 - must precede inspect_excel_format, which seeks.+ engine = _infer_engine_from_workbook(path_or_buffer) + if engine is None:+ ext = inspect_excel_format(+ content_or_path=path_or_buffer, storage_options=storage_options+ ) if ext is None:- ext = inspect_excel_format(- content_or_path=path_or_buffer, storage_options=storage_options+ raise ValueError(+ "Excel file format cannot be determined, you must specify "+ "an engine manually." )- if ext is None:- raise ValueError(- "Excel file format cannot be determined, you must specify "- "an engine manually."- ) - if engine is None:- engine = config["io"]["excel"][ext]["reader"]- if engine == "auto":- engine = get_default_engine(ext, mode="reader")- # GH#56542 - the calamine engine will become the default- # for xlsx/xlsm. Warn while calamine is available so the- # switch is actionable; an explicit engine or the- # io.excel.xlsx.reader option silences this. xlsm files- # are format-sniffed as xlsx, so ext is never "xlsm" here.- if (- ext == "xlsx"- and engine == "openpyxl"- and import_optional_dependency(- "python_calamine", errors="ignore"- )- is not None- ):- warnings.warn(- "The default engine for reading 'xlsx' files "- "will change from 'openpyxl' to 'calamine' in a "- "future version. Pass engine='openpyxl' (or set "- "the 'io.excel.xlsx.reader' option) to keep the "- "current engine and silence this warning.",- Pandas4Warning,- stacklevel=find_stack_level(),- )+ engine = config["io"]["excel"][ext]["reader"]+ if engine == "auto":+ engine = get_default_engine(ext, mode="reader")+ # GH#56542 - the calamine engine will become the default+ # for xlsx/xlsm. Warn while calamine is available so the+ # switch is actionable; an explicit engine or the+ # io.excel.xlsx.reader option silences this. xlsm files+ # are format-sniffed as xlsx, so ext is never "xlsm" here.+ if (+ ext == "xlsx"+ and engine == "openpyxl"+ and import_optional_dependency("python_calamine", errors="ignore")+ is not None+ ):+ warnings.warn(+ "The default engine for reading 'xlsx' files "+ "will change from 'openpyxl' to 'calamine' in a "+ "future version. Pass engine='openpyxl' (or set "+ "the 'io.excel.xlsx.reader' option) to keep the "+ "current engine and silence this warning.",+ Pandas4Warning,+ stacklevel=find_stack_level(),+ ) assert engine is not None self.engine = enginepandas/tests/io/excel/test_readers.py30 + / 0 −
@@ -1890,3 +1890,33 @@ def test_pyxlsb_engine_deprecated(datapath): Pandas4Warning, match="pyxlsb engine is deprecated" ): pd.read_excel(path, engine="pyxlsb")+++@pytest.mark.filterwarnings(+ "ignore:The (xlrd|pyxlsb) engine is deprecated:pandas.errors.Pandas4Warning"+)+@pytest.mark.parametrize(+ "engine, module_name, read_ext, load",+ [+ ("xlrd", "xlrd", ".xls", lambda mod, path: mod.open_workbook(path)),+ ("openpyxl", "openpyxl", ".xlsx", lambda mod, path: mod.load_workbook(path)),+ ("odf", "odf.opendocument", ".ods", lambda mod, path: mod.load(path)),+ ("pyxlsb", "pyxlsb", ".xlsb", lambda mod, path: mod.open_workbook(path)),+ (+ "calamine",+ "python_calamine",+ ".xlsx",+ lambda mod, path: mod.CalamineWorkbook.from_path(path),+ ),+ ],+)+def test_read_workbook_infers_engine(datapath, engine, module_name, read_ext, load):+ # GH#46352+ module = pytest.importorskip(module_name)+ path = datapath("io", "data", "excel", f"test1{read_ext}")+ expected = pd.read_excel(path, engine=engine, index_col=0)++ with pd.ExcelFile(load(module, path)) as xl:+ assert xl.engine == engine+ result = pd.read_excel(load(module, path), index_col=0)+ tm.assert_frame_equal(result, expected)