Repository: wright-group/WrightTools

Issue:
Adding support for Pathlib objects in attrs
Currently if pathlib objects are passed as filepath, the object opens fine, but we get a warning:

```
/home/kyle/wright/WrightTools/WrightTools/_group.py:74: UserWarning: 'filepath' not included in attrs because its Type (<class 'pathlib.PosixPath'>) cannot be represented
```

This is fairly trivial to support in _group.py, and does not represent a change to the WT5 version.
pathlib.Path objects incompatible with save
https://github.com/wright-group/WrightTools/blob/6943ce95436f054836a8c6c871957ed8b7698b51/WrightTools/_group.py#L344

`Path` instances have no method `endswith`


Discussion:

Resolving patch:
diff --git a/WrightTools/_group.py b/WrightTools/_group.py
--- a/WrightTools/_group.py
+++ b/WrightTools/_group.py
@@ -6,6 +6,8 @@
 
 import shutil
 import os
+import sys
+import pathlib
 import weakref
 import tempfile
 import posixpath
@@ -16,6 +18,7 @@
 import h5py
 
 from . import kit as wt_kit
+from . import exceptions as wt_exceptions
 from . import __wt5_version__
 
 
@@ -64,8 +67,15 @@ def __init__(self, file=None, parent=None, name=None, **kwargs):
             self.attrs["created"] = wt_kit.TimeStamp().RFC3339
         for key, value in kwargs.items():
             try:
-                if isinstance(value, list) and len(value) > 0 and isinstance(value[0], str):
+                if isinstance(value, pathlib.Path):
+                    value = str(value)
+                elif isinstance(value, list) and len(value) > 0 and isinstance(value[0], str):
                     value = np.array(value, dtype="S")
+                elif sys.version_info > (3, 6):
+                    try:
+                        value = os.fspath(value)
+                    except TypeError:
+                        pass  # Not all things that can be stored have fspath
                 self.attrs[key] = value
             except TypeError:
                 # some values have no native HDF5 equivalent
@@ -140,9 +150,10 @@ def __new__(cls, *args, **kwargs):
             tmpfile = tempfile.mkstemp(prefix="", suffix=".wt5")
             p = tmpfile[1]
             if filepath:
-                shutil.copyfile(src=filepath, dst=p)
+                shutil.copyfile(src=str(filepath), dst=p)
         elif edit_local and filepath:
             p = filepath
+        p = str(p)
         for i in cls.instances.keys():
             if i.startswith(os.path.abspath(p) + "::"):
                 file = cls.instances[i].file
@@ -326,7 +337,7 @@ def save(self, filepath=None, overwrite=False, verbose=True):
 
         Parameters
         ----------
-        filepath : string (optional)
+        filepath : Path-like object (optional)
             Filepath to write. If None, file is created using natural_name.
         overwrite : boolean (optional)
             Toggle overwrite behavior. Default is False.
@@ -338,18 +349,18 @@ def save(self, filepath=None, overwrite=False, verbose=True):
         str
             Written filepath.
         """
-        # parse filepath
         if filepath is None:
-            filepath = os.path.join(os.getcwd(), self.natural_name + ".wt5")
-        elif not filepath.endswith((".wt5", ".h5", ".hdf5")):
-            filepath += ".wt5"
-        filepath = os.path.expanduser(filepath)
-        # handle overwrite
-        if os.path.isfile(filepath):
+            filepath = pathlib.Path("." / self.natural_name)
+        else:
+            filepath = pathlib.Path(filepath)
+        filepath = filepath.with_suffix(".wt5")
+        filepath = filepath.absolute().expanduser()
+        if filepath.exists():
             if overwrite:
-                os.remove(filepath)
+                filepath.unlink()
             else:
-                raise FileExistsError(filepath)
+                raise wt_exceptions.FileExistsError(filepath)
+
         # copy to new file
         h5py.File(filepath)
         new = Group(filepath=filepath, edit_local=True)
@@ -365,4 +376,4 @@ def save(self, filepath=None, overwrite=False, verbose=True):
         del new
         if verbose:
             print("file saved at", filepath)
-        return filepath
+        return str(filepath)
diff --git a/WrightTools/exceptions.py b/WrightTools/exceptions.py
--- a/WrightTools/exceptions.py
+++ b/WrightTools/exceptions.py
@@ -76,6 +76,15 @@ class ValueError(ValueError, WrightToolsException):
     pass
 
 
+class FileExistsError(FileExistsError, WrightToolsException):
+    """Raised when trying to create a file or directory which already exists.
+
+    Corresponds to errno ``EEXIST``.
+    """
+
+    pass
+
+
 class TypeError(TypeError, WrightToolsException):
     """Raised when an operation or function is applied to an object of inappropriate type.
 


Test patch:
diff --git a/tests/group/save.py b/tests/group/save.py
old mode 100644
new mode 100755
--- a/tests/group/save.py
+++ b/tests/group/save.py
@@ -1,3 +1,4 @@
+#! /usr/bin/env python3
 """Test group copy and save."""
 
 
@@ -5,6 +6,8 @@
 
 
 import os
+import pathlib
+import warnings
 
 import numpy as np
 
@@ -148,6 +151,31 @@ def test_propagate_expressions():
     os.remove(p)
 
 
+def test_save_pathlib():
+    p = datasets.PyCMDS.wm_w2_w1_001
+    data = wt.data.from_PyCMDS(p)
+
+    p = pathlib.Path(__file__).parent / "pathlib"
+    p = data.save(p)
+    p = pathlib.Path(p)
+    assert p.exists()
+    assert p.suffix == ".wt5"
+    with warnings.catch_warnings(record=True) as w:
+        new = wt.open(p)
+        assert len(w) == 0
+    for k, v in data.attrs.items():
+        assert_equal(new.attrs[k], v)
+    for k, v in data.items():
+        assert_equal(new[k], v)
+    for axis in new.axes:
+        assert getattr(new, axis.natural_name) is axis
+    for channel in new.channels:
+        assert getattr(new, channel.natural_name) is channel
+    data.close()
+    new.close()
+    p.unlink()
+
+
 # --- run -----------------------------------------------------------------------------------------
 
 
@@ -159,3 +187,4 @@ def test_propagate_expressions():
     test_simple_save()
     test_propagate_units()
     test_propagate_expressions()
+    test_save_pathlib()