Repository: NVIDIA-Merlin/NVTabular

Issue:
[BUG] nvt.Dataset(df).to_parquet(tmp, partition_on=col) saves parquet files without .parquet extension
**Describe the bug**

I run this script `nvt.Dataset(df).to_parquet(tmp, partition_on=col)` to save preprocessed dask-cudf or cudf file by partitioning on a given column but this does not save the files with `.parquet` extension. It saves them as `part.0` instead of `part.0.parquet`.


**Expected behavior**
A clear and concise description of what you expected to happen.

**Environment details (please complete the following information):**
 - Environment location: [Bare-metal, Docker, Cloud(specify cloud provider)] Docker 
 
 - Method of NVTabular install: [conda, Docker, or from source] Docker- 0.5.3 image with latest nvtabular main branch and also tested with `nvtabular-merlin-pytorch` nightly container. 

   - If method of install is [Docker], provide `docker pull` & `docker run` commands used




Discussion:

Resolving patch:
diff --git a/nvtabular/io/dataset.py b/nvtabular/io/dataset.py
--- a/nvtabular/io/dataset.py
+++ b/nvtabular/io/dataset.py
@@ -642,9 +642,9 @@ def to_parquet(
             persist stage. The `FULL` option is not yet implemented.
         partition_on : str or list(str)
             Columns to use for hive-partitioning.  If this option is used,
-            `preserve_files`, `output_files`, and `out_files_per_proc` will
-            all be ignored.  Also, the `PER_WORKER` shuffle will not be
-            supported.
+            `preserve_files`, `output_files`, and `out_files_per_proc`
+            cannot be specified, and `method` will be ignored.  Also, the
+            `PER_WORKER` shuffle will not be supported.
         preserve_files : bool
             Whether to preserve the original file-to-partition mapping of
             the base dataset. This option requires `method="subgraph"`, and is
@@ -695,25 +695,41 @@ def to_parquet(
             specifying `method="worker"`.
         """
 
-        # Check that method (algorithm) is valid
-        if method not in ("subgraph", "worker"):
-            raise ValueError(f"{method} not a recognized method for `Dataset.to_parquet`")
-
-        # Deal with method-specific defaults
-        if method == "worker":
-            if output_files or preserve_files:
-                raise ValueError("output_files and preserve_files require `method='subgraph'`")
-            output_files = False
-        elif preserve_files and output_files:
-            raise ValueError("Cannot specify both preserve_files and output_files.")
-        elif not (output_files or preserve_files):
-            # Default "subgraph" behavior - Set output_files to the
-            # total umber of workers, multiplied by out_files_per_proc
-            try:
-                nworkers = len(self.client.cluster.workers)
-            except AttributeError:
-                nworkers = 1
-            output_files = nworkers * (out_files_per_proc or 1)
+        if partition_on:
+
+            # Check that the user is not expecting a specific output-file
+            # count/structure that is not supported
+            if output_files:
+                raise ValueError("`output_files` not supported when `partition_on` is used.")
+            if out_files_per_proc:
+                raise ValueError("`out_files_per_proc` not supported when `partition_on` is used.")
+            if preserve_files:
+                raise ValueError("`preserve_files` not supported when `partition_on` is used.")
+
+        else:
+
+            # Check that method (algorithm) is valid
+            if method not in ("subgraph", "worker"):
+                raise ValueError(f"{method} not a recognized method for `Dataset.to_parquet`")
+
+            # Deal with method-specific defaults
+            if method == "worker":
+                if output_files or preserve_files:
+                    raise ValueError("output_files and preserve_files require `method='subgraph'`")
+                output_files = False
+            elif preserve_files and output_files:
+                raise ValueError("Cannot specify both preserve_files and output_files.")
+            elif not (output_files or preserve_files):
+                # Default "subgraph" behavior - Set output_files to the
+                # total umber of workers, multiplied by out_files_per_proc
+                try:
+                    nworkers = len(self.client.cluster.workers)
+                except AttributeError:
+                    nworkers = 1
+                output_files = nworkers * (out_files_per_proc or 1)
+
+        # Replace None/False suffix argument with ""
+        suffix = suffix or ""
 
         # Check shuffle argument
         shuffle = _check_shuffle_arg(shuffle)
@@ -725,9 +741,6 @@ def to_parquet(
         else:
             ddf = self.to_ddf(shuffle=shuffle)
 
-        # Replace None/False suffix argument with ""
-        suffix = suffix or ""
-
         # Deal with `method=="subgraph"`.
         # Convert `output_files` argument to a dict mapping
         if output_files:
diff --git a/nvtabular/io/writer.py b/nvtabular/io/writer.py
--- a/nvtabular/io/writer.py
+++ b/nvtabular/io/writer.py
@@ -129,6 +129,11 @@ def _write_thread(self):
 
     @annotate("add_data", color="orange", domain="nvt_python")
     def add_data(self, df):
+
+        # Early return
+        if isinstance(df, list) and not df:
+            return
+
         # Populate columns idxs
         if not self.col_idx:
             _df = df[0] if isinstance(df, list) else df


Test patch:
diff --git a/tests/unit/test_io.py b/tests/unit/test_io.py
--- a/tests/unit/test_io.py
+++ b/tests/unit/test_io.py
@@ -736,6 +736,11 @@ def test_hive_partitioned_data(tmpdir, cpu):
         seed=42,
     ).reset_index()
     ddf["timestamp"] = ddf["timestamp"].dt.round("D").dt.day
+
+    # Make sure the first partition is empty
+    ddf = ddf[ddf.timestamp > 1]
+
+    # Convert to nvt.Dataset
     ds = nvt.Dataset(ddf, engine="parquet")
 
     # Write the dataset to disk
@@ -748,12 +753,14 @@ def test_hive_partitioned_data(tmpdir, cpu):
     df_expect = df_expect.sort_values(["id", "x", "y"]).reset_index(drop=True)
     timestamp_check = df_expect["timestamp"].iloc[0]
     name_check = df_expect["name"].iloc[0]
-    assert glob.glob(
+    result_paths = glob.glob(
         os.path.join(
             path,
             f"timestamp={timestamp_check}/name={name_check}/*",
         )
     )
+    assert result_paths
+    assert all(p.endswith(".parquet") for p in result_paths)
 
     # Read back with dask.dataframe and check the data
     df_check = dd.read_parquet(path).compute()