Repository: modin-project/modin

Issue:
`read_csv_glob` fails with empty parse_date list
### System information
Ray 1.9.2
Modin 0.12.0
Python 3.9.7

### Describe the problem

```
pd_experimental.read_csv_glob("somevalidpath", parse_dates=[])
----
...
File "##/python3.9/site-packages/modin/experimental/pandas/io.py", line 208, in _read
pd_obj = FactoryDispatcher.read_csv_glob(**kwargs)
File "##/python3.9/site-packages/modin/core/execution/dispatching/factories/dispatcher.py", line 185, in read_csv_glob
return cls.__factory._read_csv_glob(**kwargs)
File "##/python3.9/site-packages/modin/core/execution/dispatching/factories/factories.py", line 513, in _read_csv_glob
return cls.io_cls.read_csv_glob(**kwargs)
File "##/python3.9/site-packages/modin/core/io/text/csv_glob_dispatcher.py", line 238, in _read
if isinstance(parse_dates, list) and isinstance(parse_dates[0], list):
IndexError: list index out of range (worker#1713)
File "##/python3.9/site-packages/ray/worker.py", line 1713, in get
raise value.as_instanceof_cause()
```

it looks like code only checks that the type of `parse_dates` is a list, but doesn’t check for empty content, tries to access first element and throws an error



Discussion:
@c3-cjazra Thank you! I have verified the bug on my computer. I'm working on a fix.

Resolving patch:
diff --git a/modin/core/io/text/csv_glob_dispatcher.py b/modin/core/io/text/csv_glob_dispatcher.py
--- a/modin/core/io/text/csv_glob_dispatcher.py
+++ b/modin/core/io/text/csv_glob_dispatcher.py
@@ -239,12 +239,24 @@ def _read(cls, filepath_or_buffer, **kwargs):
             # we are not guaranteed to have the correct information regarding how many
             # columns are on each partition.
             column_widths = None
-            # Check if is list of lists
-            if isinstance(parse_dates, list) and isinstance(parse_dates[0], list):
-                for group in parse_dates:
-                    new_col_name = "_".join(group)
-                    column_names = column_names.drop(group).insert(0, new_col_name)
-            # Check if it is a dictionary
+            if isinstance(parse_dates, list):
+                for date in parse_dates:
+                    # Lists within the parse_dates list are sequences of
+                    # CSV columns that are parsed together as a single date
+                    # column. They can be a list of either string column names
+                    # or integer column indices. e.g. if parse_dates is
+                    # [[1, 2]] and columns at indices 1 and 2 are "b" and "c",
+                    # the output dataframe has the single date column "b_c". If
+                    # parse_dates is [["a", 1]] and the column at index 1 is
+                    # named "b", the output dataframe has the single date
+                    # column "a_b".
+                    if isinstance(date, list):
+                        # TODO(https://github.com/modin-project/modin/issues/4310):
+                        # Handle parse_dates with lists that include lists that
+                        # include ints. This code assumes that all lists with
+                        # parse_dates are lists of column names.
+                        new_col_name = "_".join(date)
+                        column_names = column_names.drop(date).insert(0, new_col_name)
             elif isinstance(parse_dates, dict):
                 for new_col_name, group in parse_dates.items():
                     column_names = column_names.drop(group).insert(0, new_col_name)
diff --git a/modin/experimental/core/execution/native/implementations/omnisci_on_native/io/io.py b/modin/experimental/core/execution/native/implementations/omnisci_on_native/io/io.py
--- a/modin/experimental/core/execution/native/implementations/omnisci_on_native/io/io.py
+++ b/modin/experimental/core/execution/native/implementations/omnisci_on_native/io/io.py
@@ -466,13 +466,17 @@ def _read_csv_check_support(
             )
 
         parse_dates_unsupported = isinstance(parse_dates, dict) or (
-            isinstance(parse_dates, list) and isinstance(parse_dates[0], list)
+            isinstance(parse_dates, list)
+            and any(not isinstance(date, str) for date in parse_dates)
         )
         if parse_dates_unsupported:
             return (
                 False,
-                "read_csv with 'arrow' engine supports only bool and "
-                + "flattened lists 'parse_dates' parameter",
+                (
+                    "read_csv with 'arrow' engine supports only bool and "
+                    + "flattened list of string column names for the "
+                    + "'parse_dates' parameter"
+                ),
             )
         if names and names != lib.no_default:
             if header not in [None, 0, "infer"]:


Test patch:
diff --git a/modin/experimental/core/execution/native/implementations/omnisci_on_native/test/test_dataframe.py b/modin/experimental/core/execution/native/implementations/omnisci_on_native/test/test_dataframe.py
--- a/modin/experimental/core/execution/native/implementations/omnisci_on_native/test/test_dataframe.py
+++ b/modin/experimental/core/execution/native/implementations/omnisci_on_native/test/test_dataframe.py
@@ -35,6 +35,7 @@
     generate_multiindex,
     eval_general,
     df_equals_with_non_stable_indices,
+    time_parsing_csv_path,
 )
 from modin.utils import try_cast_to_pandas
 from modin.pandas.utils import from_arrow
@@ -135,14 +136,15 @@ def test_housing_csv(self):
             )
 
     def test_time_parsing(self):
-        csv_file = os.path.join(
-            self.root, "modin/pandas/test/data", "test_time_parsing.csv"
-        )
+        csv_file = os.path.join(self.root, time_parsing_csv_path)
         for kwargs in (
             {
                 "skiprows": 1,
                 "names": [
                     "timestamp",
+                    "year",
+                    "month",
+                    "date",
                     "symbol",
                     "high",
                     "low",
@@ -274,6 +276,7 @@ def test_float32(self):
             ["c2"],
             [["col2", "col3"]],
             {"col23": ["col2", "col3"]},
+            [],
         ],
     )
     @pytest.mark.parametrize("names", [None, [f"c{x}" for x in range(1, 7)]])
@@ -285,7 +288,8 @@ def test_read_csv_datetime(
     ):
 
         parse_dates_unsupported = isinstance(parse_dates, dict) or (
-            isinstance(parse_dates, list) and isinstance(parse_dates[0], list)
+            isinstance(parse_dates, list)
+            and any(not isinstance(date, str) for date in parse_dates)
         )
         if parse_dates_unsupported and engine == "arrow" and not names:
             pytest.skip(
diff --git a/modin/experimental/pandas/test/test_io_exp.py b/modin/experimental/pandas/test/test_io_exp.py
--- a/modin/experimental/pandas/test/test_io_exp.py
+++ b/modin/experimental/pandas/test/test_io_exp.py
@@ -20,6 +20,7 @@
 from modin.utils import get_current_execution
 from modin.pandas.test.utils import df_equals, teardown_test_files, test_data
 from modin.test.test_utils import warns_that_defaulting_to_pandas
+from modin.pandas.test.utils import parse_dates_values_by_id, time_parsing_csv_path
 
 
 @pytest.mark.skipif(
@@ -119,6 +120,29 @@ def test_read_csv_without_glob(self):
             with pytest.raises(FileNotFoundError):
                 pd.read_csv_glob("s3://nyc-tlc/trip data/yellow_tripdata_2020-")
 
+    @pytest.mark.parametrize(
+        "parse_dates",
+        [pytest.param(value, id=id) for id, value in parse_dates_values_by_id.items()],
+    )
+    def test_read_single_csv_with_parse_dates(self, parse_dates):
+        try:
+            pandas_df = pandas.read_csv(time_parsing_csv_path, parse_dates=parse_dates)
+        except Exception as pandas_exception:
+            with pytest.raises(Exception) as modin_exception:
+                modin_df = pd.read_csv_glob(
+                    time_parsing_csv_path, parse_dates=parse_dates
+                )
+                # Call __repr__ on the modin df to force it to materialize.
+                repr(modin_df)
+            assert isinstance(
+                modin_exception.value, type(pandas_exception)
+            ), "Got Modin Exception type {}, but pandas Exception type {} was expected".format(
+                type(modin_exception.value), type(pandas_exception)
+            )
+        else:
+            modin_df = pd.read_csv_glob(time_parsing_csv_path, parse_dates=parse_dates)
+            df_equals(modin_df, pandas_df)
+
 
 @pytest.mark.skipif(
     Engine.get() != "Ray", reason="Currently only support Ray engine for glob paths."
diff --git a/modin/pandas/test/data/test_time_parsing.csv b/modin/pandas/test/data/test_time_parsing.csv
--- a/modin/pandas/test/data/test_time_parsing.csv
+++ b/modin/pandas/test/data/test_time_parsing.csv
@@ -1,4 +1,4 @@
-timestamp,symbol,high,low,open,close,spread,volume
-2010-04-01 00:00:00,USD/JPY,93.52600,93.36100,93.51800,93.38200,0.00500,3049
-2010-04-01 00:30:00,USD/JPY,93.47500,93.35200,93.38500,93.39100,0.00600,2251
-2010-04-01 01:00:00,USD/JPY,93.42100,93.32600,93.39100,93.38400,0.00600,1577
\ No newline at end of file
+timestamp,year,month,date,symbol,high,low,open,close,spread,volume
+2010-04-01 00:00:00,2010,04,01,USD/JPY,93.52600,93.36100,93.51800,93.38200,0.00500,3049
+2010-04-01 00:30:00,2010,04,01,USD/JPY,93.47500,93.35200,93.38500,93.39100,0.00600,2251
+2010-04-01 01:00:00,2010,04,01,USD/JPY,93.42100,93.32600,93.39100,93.38400,0.00600,1577
\ No newline at end of file
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
@@ -59,6 +59,8 @@
     teardown_test_files,
     generate_dataframe,
     default_to_pandas_ignore_string,
+    parse_dates_values_by_id,
+    time_parsing_csv_path,
 )
 
 if StorageFormat.get() == "Omnisci":
@@ -878,14 +880,29 @@ def test_read_csv_categories(self):
 
     @pytest.mark.parametrize("encoding", [None, "utf-8"])
     @pytest.mark.parametrize("encoding_errors", ["strict", "ignore"])
-    @pytest.mark.parametrize("parse_dates", [False, ["timestamp"]])
-    @pytest.mark.parametrize("index_col", [None, 0, 2])
+    @pytest.mark.parametrize(
+        "parse_dates",
+        [pytest.param(value, id=id) for id, value in parse_dates_values_by_id.items()],
+    )
+    @pytest.mark.parametrize("index_col", [None, 0, 5])
     @pytest.mark.parametrize("header", ["infer", 0])
     @pytest.mark.parametrize(
         "names",
         [
             None,
-            ["timestamp", "symbol", "high", "low", "open", "close", "spread", "volume"],
+            [
+                "timestamp",
+                "year",
+                "month",
+                "date",
+                "symbol",
+                "high",
+                "low",
+                "open",
+                "close",
+                "spread",
+                "volume",
+            ],
         ],
     )
     def test_read_csv_parse_dates(
@@ -899,7 +916,7 @@ def test_read_csv_parse_dates(
         eval_io(
             fn_name="read_csv",
             # read_csv kwargs
-            filepath_or_buffer="modin/pandas/test/data/test_time_parsing.csv",
+            filepath_or_buffer=time_parsing_csv_path,
             names=names,
             header=header,
             index_col=index_col,
diff --git a/modin/pandas/test/utils.py b/modin/pandas/test/utils.py
--- a/modin/pandas/test/utils.py
+++ b/modin/pandas/test/utils.py
@@ -120,6 +120,19 @@
     #     "col2": True,
     # },
 }
+# The parse_dates param can take several different types and combinations of
+# types. Use the following values to test date parsing on a CSV created for
+# that purpose at `time_parsing_csv_path`
+parse_dates_values_by_id = {
+    "bool": False,
+    "list_of_single_int": [0],
+    "list_of_single_string": ["timestamp"],
+    "list_of_list_of_strings": [["year", "month", "date"]],
+    "list_of_string_and_list_of_strings": ["timestamp", ["year", "month", "date"]],
+    "empty_list": [],
+    "nonexistent_string_column": ["z"],
+    "nonexistent_int_column": [99],
+}
 
 # See details in #1403
 test_data["int_data"]["index"] = test_data["int_data"].pop(
@@ -464,6 +477,9 @@
 COMP_TO_EXT = {"gzip": "gz", "bz2": "bz2", "xz": "xz", "zip": "zip"}
 
 
+time_parsing_csv_path = "modin/pandas/test/data/test_time_parsing.csv"
+
+
 def categories_equals(left, right):
     assert (left.ordered and right.ordered) or (not left.ordered and not right.ordered)
     assert_extension_array_equal(left, right)