Repository: modin-project/modin

Issue:
`read_excel` raises exception when passing in-memory file bytes object
### System information
- **OS Platform and Distribution (e.g., Linux Ubuntu 16.04)**:
- **Modin version** (`modin.__version__`): 0.13.3
- **Python version**: 3.8.2
- **openpyxl version**: 3.0.9

Current implementation doesn't allow passing `bytes` or `BytesIO` object, unlike standard pandas implementation:
```
import modin.pandas as pd

xlsx_file = pd.ExcelFile(file_content) # passing bytes or BytesIO object
pd.read_excel(xlsx_file)
```
Output:
```
File "/Users/dchernov/Projects/test/.venv/lib/python3.8/site-packages/modin/pandas/io.py", line 353, in read_excel
    intermediate = FactoryDispatcher.read_excel(**kwargs)
  File "/Users/dchernov/Projects/test/.venv/lib/python3.8/site-packages/modin/core/execution/dispatching/factories/dispatcher.py", line 217, in read_excel
    return cls.__factory._read_excel(**kwargs)
  File "/Users/dchernov/Projects/test/.venv/lib/python3.8/site-packages/modin/core/execution/dispatching/factories/factories.py", line 257, in _read_excel
    return cls.io_cls.read_excel(**kwargs)
  File "/Users/dchernov/Projects/test/.venv/lib/python3.8/site-packages/modin/core/io/file_dispatcher.py", line 151, in read
    query_compiler = cls._read(*args, **kwargs)
  File "/Users/dchernov/Projects/test/.venv/lib/python3.8/site-packages/modin/core/io/text/excel_dispatcher.py", line 84, in _read
    ex = ExcelReader(io_file, read_only=True)
  File "/Users/dchernov/Projects/test/.venv/lib/python3.8/site-packages/openpyxl/reader/excel.py", line 124, in __init__
    self.archive = _validate_archive(fn)
  File "/Users/dchernov/Projects/test/.venv/lib/python3.8/site-packages/openpyxl/reader/excel.py", line 78, in _validate_archive
    file_format = os.path.splitext(filename)[-1].lower()
  File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.8/lib/python3.8/posixpath.py", line 118, in splitext
    p = os.fspath(p)
TypeError: expected ExcelFile.__fspath__() to return str or bytes, not _io.BytesIO
```


Discussion:
@chedv thanks for reporting the issue. I can reproduce it with the following code at Modin commit 95d71b2bde0ceb40cb1b64fa762b3009a94168dd, using [these instructions](https://stackoverflow.com/a/28065603/17554722) from StackOverflow to create an excel buffer.

```python

import pandas as pd
from io import BytesIO

buffer = BytesIO()
# Use a temp filename to keep pandas happy.
writer = pd.ExcelWriter('temp.xlsx', engine='xlsxwriter')

# Set the filename/file handle in the xlsxwriter.workbook object.
writer.book.filename = buffer

# Write the data frame to the StringIO object.
pd.DataFrame().to_excel(writer, sheet_name='Sheet1')
writer.save()

xlsx_file = pd.ExcelFile(buffer)
print(pd.read_excel(xlsx_file))
```

That code prints an empty dataframe in pandas:

```
Empty DataFrame
Columns: []
Index: []
```

but in Modin, I get a `TypeError` similar to the one you reported.
Are there any updates on this issue, or maybe can anyone share a workarround on it please?
@abdeltif-b there hasn't been any progress on this issue, but for a workaround you can read the data with pandas, then convert the result to modin:

```python
import modin.pandas as pd
import pandas

xlsx_file = pandas.ExcelFile(file_content) # passing bytes or BytesIO object
df = pd.DataFrame(pandas.read_excel(xlsx_file))
```

Resolving patch:
diff --git a/modin/core/io/io.py b/modin/core/io/io.py
--- a/modin/core/io/io.py
+++ b/modin/core/io/io.py
@@ -28,6 +28,7 @@
 from modin.error_message import ErrorMessage
 from modin.core.storage_formats.base.query_compiler import BaseQueryCompiler
 from modin.utils import _inherit_docstrings
+from modin.pandas.io import ExcelFile
 
 _doc_default_io_method = """
 {summary} using pandas.
@@ -277,6 +278,12 @@ def read_clipboard(cls, sep=r"\s+", **kwargs):  # pragma: no cover # noqa: PR01
     )
     def read_excel(cls, **kwargs):  # noqa: PR01
         ErrorMessage.default_to_pandas("`read_excel`")
+        if isinstance(kwargs["io"], ExcelFile):
+            # otherwise, Modin objects may be passed to the pandas context, resulting
+            # in undefined behavior
+            # for example in the case: pd.read_excel(pd.ExcelFile), since reading from
+            # pd.ExcelFile in `read_excel` isn't supported
+            kwargs["io"]._set_pandas_mode()
         intermediate = pandas.read_excel(**kwargs)
         if isinstance(intermediate, (OrderedDict, dict)):
             parsed = type(intermediate)()
diff --git a/modin/core/io/text/excel_dispatcher.py b/modin/core/io/text/excel_dispatcher.py
--- a/modin/core/io/text/excel_dispatcher.py
+++ b/modin/core/io/text/excel_dispatcher.py
@@ -13,12 +13,16 @@
 
 """Module houses `ExcelDispatcher` class, that is used for reading excel files."""
 
+import os
+from io import BytesIO
+
 import pandas
 import re
 import warnings
 
 from modin.core.io.text.text_file_dispatcher import TextFileDispatcher
 from modin.config import NPartitions
+from modin.pandas.io import ExcelFile
 
 EXCEL_READ_BLOCK_SIZE = 4096
 
@@ -55,6 +59,22 @@ def _read(cls, io, **kwargs):
                 **kwargs
             )
 
+        if isinstance(io, bytes):
+            io = BytesIO(io)
+
+        # isinstance(ExcelFile, os.PathLike) == True
+        if not isinstance(io, (str, os.PathLike, BytesIO)) or isinstance(
+            io, (ExcelFile, pandas.ExcelFile)
+        ):
+            if isinstance(io, ExcelFile):
+                io._set_pandas_mode()
+            return cls.single_worker_read(
+                io,
+                reason="Modin only implements parallel `read_excel` the following types of `io`: "
+                + "str, os.PathLike, io.BytesIO.",
+                **kwargs
+            )
+
         from zipfile import ZipFile
         from openpyxl.worksheet.worksheet import Worksheet
         from openpyxl.worksheet._reader import WorksheetReader
@@ -79,7 +99,7 @@ def _read(cls, io, **kwargs):
 
         # NOTE: ExcelReader() in read-only mode does not close file handle by itself
         # work around that by passing file object if we received some path
-        io_file = open(io, "rb") if isinstance(io, str) else io
+        io_file = open(io, "rb") if isinstance(io, (str, os.PathLike)) else io
         try:
             ex = ExcelReader(io_file, read_only=True)
             ex.read()
@@ -90,14 +110,12 @@ def _read(cls, io, **kwargs):
             ex.read_strings()
             ws = Worksheet(wb)
         finally:
-            if isinstance(io, str):
+            if isinstance(io, (str, os.PathLike)):
                 # close only if it were us who opened the object
                 io_file.close()
 
         pandas_kw = dict(kwargs)  # preserve original kwargs
         with ZipFile(io) as z:
-            from io import BytesIO
-
             # Convert index to sheet name in file
             if isinstance(sheet_name, int):
                 sheet_name = "sheet{}".format(sheet_name + 1)
diff --git a/modin/pandas/io.py b/modin/pandas/io.py
--- a/modin/pandas/io.py
+++ b/modin/pandas/io.py
@@ -835,10 +835,21 @@ class ExcelFile(ClassLogger, pandas.ExcelFile):  # noqa: PR01, D200
     Class for parsing tabular excel sheets into DataFrame objects.
     """
 
+    _behave_like_pandas = False
+
+    def _set_pandas_mode(self):  # noqa
+        # disable Modin behavior to be able to pass object to `pandas.read_excel`
+        # otherwise, Modin objects may be passed to the pandas context, resulting
+        # in undefined behavior
+        self._behave_like_pandas = True
+
     def __getattribute__(self, item):
+        if item in ["_set_pandas_mode", "_behave_like_pandas"]:
+            return object.__getattribute__(self, item)
+
         default_behaviors = ["__init__", "__class__"]
         method = super(ExcelFile, self).__getattribute__(item)
-        if item not in default_behaviors:
+        if not self._behave_like_pandas and item not in default_behaviors:
             if callable(method):
 
                 def return_handler(*args, **kwargs):


Test patch:
diff --git a/modin/pandas/test/test_io.py b/modin/pandas/test/test_io.py
--- a/modin/pandas/test/test_io.py
+++ b/modin/pandas/test/test_io.py
@@ -44,6 +44,7 @@
 import pyarrow as pa
 import fastparquet
 import os
+from io import BytesIO, StringIO
 from scipy import sparse
 import sys
 import sqlalchemy as sa
@@ -1006,8 +1007,6 @@ def test_read_csv_default_to_pandas(self):
             warning_suffix = ""
         with warns_that_defaulting_to_pandas(suffix=warning_suffix):
             # This tests that we default to pandas on a buffer
-            from io import StringIO
-
             with open(pytest.csvs_names["test_read_csv_regular"], "r") as _f:
                 pd.read_csv(StringIO(_f.read()))
 
@@ -1944,11 +1943,13 @@ def test_read_json_metadata(self, make_json_file):
 @pytest.mark.filterwarnings(default_to_pandas_ignore_string)
 class TestExcel:
     @check_file_leaks
-    def test_read_excel(self, make_excel_file):
+    @pytest.mark.parametrize("pathlike", [False, True])
+    def test_read_excel(self, pathlike, make_excel_file):
+        unique_filename = make_excel_file()
         eval_io(
             fn_name="read_excel",
             # read_excel kwargs
-            io=make_excel_file(),
+            io=Path(unique_filename) if pathlike else unique_filename,
         )
 
     @check_file_leaks
@@ -2085,11 +2086,54 @@ def test_ExcelFile(self, make_excel_file):
         try:
             df_equals(modin_excel_file.parse(), pandas_excel_file.parse())
             assert modin_excel_file.io == unique_filename
-            assert isinstance(modin_excel_file, pd.ExcelFile)
         finally:
             modin_excel_file.close()
             pandas_excel_file.close()
 
+    def test_ExcelFile_bytes(self, make_excel_file):
+        unique_filename = make_excel_file()
+        with open(unique_filename, mode="rb") as f:
+            content = f.read()
+
+        modin_excel_file = pd.ExcelFile(content)
+        pandas_excel_file = pandas.ExcelFile(content)
+
+        df_equals(modin_excel_file.parse(), pandas_excel_file.parse())
+
+    def test_read_excel_ExcelFile(self, make_excel_file):
+        unique_filename = make_excel_file()
+        with open(unique_filename, mode="rb") as f:
+            content = f.read()
+
+        modin_excel_file = pd.ExcelFile(content)
+        pandas_excel_file = pandas.ExcelFile(content)
+
+        df_equals(pd.read_excel(modin_excel_file), pandas.read_excel(pandas_excel_file))
+
+    @pytest.mark.parametrize("use_bytes_io", [False, True])
+    def test_read_excel_bytes(self, use_bytes_io, make_excel_file):
+        unique_filename = make_excel_file()
+        with open(unique_filename, mode="rb") as f:
+            io_bytes = f.read()
+
+        if use_bytes_io:
+            io_bytes = BytesIO(io_bytes)
+
+        eval_io(
+            fn_name="read_excel",
+            # read_excel kwargs
+            io=io_bytes,
+        )
+
+    def test_read_excel_file_handle(self, make_excel_file):
+        unique_filename = make_excel_file()
+        with open(unique_filename, mode="rb") as f:
+            eval_io(
+                fn_name="read_excel",
+                # read_excel kwargs
+                io=f,
+            )
+
     @pytest.mark.xfail(strict=False, reason="Flaky test, defaults to pandas")
     def test_to_excel(self, tmp_path):
         modin_df, pandas_df = create_test_dfs(TEST_DATA)