Repository: alteryx/woodwork (python)

Issue:
Cannot serialize multiple tables to same directory and then deserialize
With the current serialization implementation, users cannot serialize multiple Woodwork dataframes to the same directory and then deserialize them. This is because typing information is always stored in a file named `woodwork_typing_info.json`. If you serialize multiple files to the same path, this file will be overwritten making it impossible to deserialize all but the last dataframe that was serialized.

Additionally, the `woodwork_typing_info.json` is overwritten without warning, so users could potentially corrupt the data needed for deserialization and not know this happened until they attempt to deserialize.

At a minimum, we should raise an error if the `woodwork_typing_info.json` already exists, giving users the option to specify a different path without corrupting already existing data. It might be better yet to give users the ability to optionally specify a custom name for the typing information file on serialization and deserialization, allowing multiple tables to be serialized/deserialized at the same path.

#### Code Sample, a copy-pastable example to reproduce your bug.

```python
import pandas as pd
import woodwork as ww
from woodwork.deserialize import read_woodwork_table

df = pd.DataFrame({
    'id': [0, 1, 2, 3],
    'ints': [1, 10, 20, 100]
})
df2 = pd.DataFrame({
    'id': [0, 1, 2, 3],
    'bools': [True, False, False, True]
})

df.ww.init(name="first_dataframe")
df2.ww.init(name="second_dataframe")

df.ww.to_disk('.')
df2.ww.to_disk('.')  # This overwrites the json file from the line above without error or warning

new_df = read_woodwork_table('.')  # This deserializes the second table only
new_df.ww.name
```

```
'second_dataframe'
```


Pull request description:
Raise error if files already exist
Closes #963 

Patch:
diff --git a/docs/source/release_notes.rst b/docs/source/release_notes.rst
index 0f0ddc2..10b1986 100644
--- a/docs/source/release_notes.rst
+++ b/docs/source/release_notes.rst
@@ -12,13 +12,14 @@ Future Release
     * Changes
         * Added ``from_disk`` as a convenience function to deserialize a WW table (:pr:`1363`)
         * Allow attr version in setup.cfg (:pr:`1361`)
+        * Raise error if files already exist during serialization (:pr:`1356`)
     * Documentation Changes
         * Upgrade nbconvert and remove jinja2 dependency (:pr:`1362`)
         * Add M1 installation instructions to docs and contributing guide (:pr:`1367`)
     * Testing Changes
 
     Thanks to the following people for contributing to this release:
-    :user:`gsheni`, :user:`rwedge`, :user:`ParthivNaresh`, :user:`thehomebrewnerd`, :user:`bchen1116`
+    :user:`gsheni`, :user:`jeff-hernandez`, :user:`rwedge`, :user:`ParthivNaresh`, :user:`thehomebrewnerd`, :user:`bchen1116`
 
 v0.15.0 Mar 24, 2022
 ====================
diff --git a/woodwork/exceptions.py b/woodwork/exceptions.py
index cc9271d..811b337 100644
--- a/woodwork/exceptions.py
+++ b/woodwork/exceptions.py
@@ -85,3 +85,7 @@ class WoodworkNotInitWarning(UserWarning):
 
 class TypeValidationError(Exception):
     pass
+
+
+class WoodworkFileExistsError(FileExistsError):
+    pass
diff --git a/woodwork/serializers/csv_serializer.py b/woodwork/serializers/csv_serializer.py
index 5df2372..bb08b82 100644
--- a/woodwork/serializers/csv_serializer.py
+++ b/woodwork/serializers/csv_serializer.py
@@ -1,6 +1,8 @@
+import glob
 import os
 
 from woodwork.accessor_utils import _is_dask_dataframe, _is_spark_dataframe
+from woodwork.exceptions import WoodworkFileExistsError
 from woodwork.serializers.serializer_base import Serializer
 
 
@@ -42,7 +44,12 @@ class CSVSerializer(Serializer):
         self.location = basename
         if self.data_subdirectory:
             self.location = os.path.join(self.data_subdirectory, basename)
-        return os.path.join(self.write_path, self.location)
+        location = os.path.join(self.write_path, self.location)
+        if os.path.exists(location) or glob.glob(location):
+            message = f"Data file already exists at '{location}'."
+            message += "Please remove or use a different filename."
+            raise WoodworkFileExistsError(message)
+        return location
 
     def write_dataframe(self):
         csv_kwargs = self.kwargs.copy()
diff --git a/woodwork/serializers/serializer_base.py b/woodwork/serializers/serializer_base.py
index a5ea404..22ff509 100644
--- a/woodwork/serializers/serializer_base.py
+++ b/woodwork/serializers/serializer_base.py
@@ -5,6 +5,7 @@ import tarfile
 import tempfile
 
 from woodwork.accessor_utils import _is_dask_dataframe, _is_spark_dataframe
+from woodwork.exceptions import WoodworkFileExistsError
 from woodwork.logical_types import LatLong
 from woodwork.s3_utils import get_transport_params, use_smartopen
 from woodwork.type_sys.utils import _get_ltype_class, _get_specified_ltype_params
@@ -49,9 +50,8 @@ class Serializer:
     def save_to_local_path(self):
         """Serialize data and typing information to a local directory."""
         if self.data_subdirectory:
-            os.makedirs(
-                os.path.join(self.write_path, self.data_subdirectory), exist_ok=True
-            )
+            location = os.path.join(self.write_path, self.data_subdirectory)
+            os.makedirs(location, exist_ok=True)
         else:
             os.makedirs(self.write_path, exist_ok=True)
         self.write_dataframe()
@@ -85,8 +85,14 @@ class Serializer:
             "params": self.kwargs,
         }
         self.typing_info["loading_info"].update(loading_info)
+        file = os.path.join(self.write_path, self.typing_info_filename)
+
+        if os.path.exists(file):
+            message = f"Typing info already exists at '{file}'. "
+            message += "Please remove or use a different filename."
+            raise WoodworkFileExistsError(message)
+
         try:
-            file = os.path.join(self.write_path, self.typing_info_filename)
             with open(file, "w") as file:
                 json.dump(self.typing_info, file)
         except TypeError:
@@ -104,7 +110,12 @@ class Serializer:
         self.location = basename
         if self.data_subdirectory:
             self.location = os.path.join(self.data_subdirectory, basename)
-        return os.path.join(self.write_path, self.location)
+        location = os.path.join(self.write_path, self.location)
+        if os.path.exists(location):
+            message = f"Data file already exists at '{location}'. "
+            message += "Please remove or use a different filename."
+            raise WoodworkFileExistsError(message)
+        return location
 
     def _create_archive(self):
         """Create a tar archive of data and typing information."""


Test patch:
diff --git a/woodwork/tests/accessor/test_serialization.py b/woodwork/tests/accessor/test_serialization.py
index 4adaaf0..1135efa 100644
--- a/woodwork/tests/accessor/test_serialization.py
+++ b/woodwork/tests/accessor/test_serialization.py
@@ -1,5 +1,6 @@
 import json
 import os
+import shutil
 from unittest.mock import patch
 
 import boto3
@@ -12,6 +13,7 @@ from woodwork.deserializers.deserializer_base import _check_schema_version
 from woodwork.exceptions import (
     OutdatedSchemaWarning,
     UpgradeSchemaWarning,
+    WoodworkFileExistsError,
     WoodworkNotInitError,
 )
 from woodwork.logical_types import Categorical, Ordinal
@@ -379,6 +381,7 @@ def test_to_csv_use_standard_tags(sample_df, tmpdir):
         str(tmpdir), format="csv", encoding="utf-8", engine="python"
     )
     deserialized_no_tags_df = read_woodwork_table(str(tmpdir))
+    shutil.rmtree(str(tmpdir))
 
     standard_tags_df = sample_df.copy()
     standard_tags_df.ww.init(use_standard_tags=True)
@@ -387,6 +390,7 @@ def test_to_csv_use_standard_tags(sample_df, tmpdir):
         str(tmpdir), format="csv", encoding="utf-8", engine="python"
     )
     deserialized_tags_df = read_woodwork_table(str(tmpdir))
+    shutil.rmtree(str(tmpdir))
 
     assert no_standard_tags_df.ww.schema != standard_tags_df.ww.schema
 
@@ -547,6 +551,8 @@ def test_to_disk_custom_data_subdirectory(
             sample_df.ww.to_disk(
                 str(tmpdir), format=file_format, data_subdirectory=data_subdirectory
             )
+        shutil.rmtree(str(tmpdir))
+
     else:
         sample_df.ww.to_disk(
             str(tmpdir), format=file_format, data_subdirectory=data_subdirectory
@@ -561,6 +567,7 @@ def test_to_disk_custom_data_subdirectory(
             to_pandas(deserialized_df, index=deserialized_df.ww.index, sort_index=True),
         )
         assert sample_df.ww.schema == deserialized_df.ww.schema
+        shutil.rmtree(str(tmpdir))
 
 
 @pytest.mark.parametrize(
@@ -618,6 +625,7 @@ def test_categorical_dtype_serialization(serialize_df, tmpdir):
             to_pandas(df, index=df.ww.index, sort_index=True),
         )
         assert deserialized_df.ww.schema == df.ww.schema
+        shutil.rmtree(str(tmpdir))
 
 
 @pytest.fixture
@@ -775,19 +783,25 @@ def test_serialize_subdirs_not_removed(sample_df, tmpdir):
     sample_df.ww.init()
     write_path = tmpdir.mkdir("test")
     test_dir = write_path.mkdir("test_dir")
-    with open(str(write_path.join("woodwork_typing_info.json")), "w") as f:
+    sample_text = str(test_dir.join("sample_text.json"))
+
+    with open(sample_text, "w") as f:
         json.dump("__SAMPLE_TEXT__", f)
-    compression = None
+
     sample_df.ww.to_disk(
         path=str(write_path),
         index="1",
         sep="\t",
+        compression=None,
         encoding="utf-8",
-        compression=compression,
+        typing_info_filename="woodwork_typing_info_2.json",
     )
-    assert os.path.exists(str(test_dir))
-    with open(str(write_path.join("woodwork_typing_info.json")), "r") as f:
-        assert "__SAMPLE_TEXT__" not in json.load(f)
+
+    assert os.path.exists(sample_text)
+    with open(sample_text, "r") as f:
+        assert "__SAMPLE_TEXT__" in json.load(f)
+
+    shutil.rmtree(str(tmpdir))
 
 
 @pytest.mark.parametrize("profile_name", [None, False])
@@ -870,6 +884,33 @@ def test_earlier_schema_version():
     test_version(major, minor, patch - 1, raises=False)
 
 
+@pytest.mark.parametrize("format", ["csv", "parquet", "pickle"])
+def test_overwrite_error(sample_df, tmpdir, format):
+    if format == "pickle" and (
+        _is_dask_dataframe(sample_df) or _is_spark_dataframe(sample_df)
+    ):
+        pytest.skip("Cannot pickle dask and spark dataframes")
+
+    folder_1 = str(tmpdir.join("folder_1"))
+    folder_2 = str(tmpdir.join("folder_2"))
+    sample_df.ww.init()
+
+    sample_df.ww.to_disk(folder_1, data_subdirectory=None, format=format)
+    with pytest.raises(WoodworkFileExistsError, match="Typing info already exists"):
+        sample_df.ww.to_disk(folder_1, format=format)
+
+    sample_df.ww.to_disk(folder_2, data_subdirectory=None, format=format)
+    with pytest.raises(WoodworkFileExistsError, match="Data file already exists"):
+        sample_df.ww.to_disk(
+            folder_2,
+            format=format,
+            typing_info_filename="new_typing_info",
+            data_subdirectory=None,
+        )
+
+    shutil.rmtree(str(tmpdir))
+
+
 @patch("woodwork.deserialize.read_woodwork_table")
 def test_from_disk(mock_read_woodwork_table, tmpdir, sample_df):
     sample_df.ww.init(