Repository: statsmodels/statsmodels (Python)

Issue:
ENH: Allow both pathlib and string paths for file io operations such as load and save
Using an object which inherits pathlib.Path and overrides the open method breaks, as it tries to open it with the builtin open method and the given path is from the s3 (this behaviour is implemented in the custom object).

```python
import statsmodels.api as sm
LOGIT_PATH = URIPath(
    "some_path"
)
sm.load(LOGIT_PATH)


class URIPath(pathlib.Path):
    def open(...):
        ...
       handle_s3_case
```

Suggestion: use the .open() method of pathlib.Path



Hints:

Patch:
diff --git a/statsmodels/base/model.py b/statsmodels/base/model.py
--- a/statsmodels/base/model.py
+++ b/statsmodels/base/model.py
@@ -1075,7 +1075,6 @@ def predict(self, exog=None, transform=True, *args, **kwargs):
             else:
                 exog_index = [exog.index.name]
 
-
         if transform and hasattr(self.model, 'formula') and (exog is not None):
             # allow both location of design_info, see #7043
             design_info = (getattr(self.model, "design_info", None) or
@@ -2144,7 +2143,7 @@ def load(cls, fname):
 
         Parameters
         ----------
-        fname : {str, handle}
+        fname : {str, handle, pathlib.Path}
             A string filename or a file handle.
 
         Returns
diff --git a/statsmodels/iolib/openfile.py b/statsmodels/iolib/openfile.py
--- a/statsmodels/iolib/openfile.py
+++ b/statsmodels/iolib/openfile.py
@@ -1,6 +1,8 @@
 """
 Handle file opening for read/write
 """
+from pathlib import Path
+
 from numpy.lib._iotools import _is_string_like
 
 
@@ -9,15 +11,16 @@ class EmptyContextManager(object):
     This class is needed to allow file-like object to be used as
     context manager, but without getting closed.
     """
+
     def __init__(self, obj):
         self._obj = obj
 
     def __enter__(self):
-        '''When entering, return the embedded object'''
+        """When entering, return the embedded object"""
         return self._obj
 
     def __exit__(self, *args):
-        '''Do not hide anything'''
+        """Do not hide anything"""
         return False
 
     def __getattr__(self, name):
@@ -25,14 +28,15 @@ def __getattr__(self, name):
 
 
 def _open(fname, mode, encoding):
-    if fname.endswith('.gz'):
+    if fname.endswith(".gz"):
         import gzip
+
         return gzip.open(fname, mode, encoding=encoding)
     else:
         return open(fname, mode, encoding=encoding)
 
 
-def get_file_obj(fname, mode='r', encoding=None):
+def get_file_obj(fname, mode="r", encoding=None):
     """
     Light wrapper to handle strings, path objects and let files (anything else)
     pass through.
@@ -54,10 +58,15 @@ def get_file_obj(fname, mode='r', encoding=None):
     already a file-like object, the returned context manager *will not
     close the file*.
     """
+
     if _is_string_like(fname):
-        return _open(fname, mode, encoding)
+        fname = Path(fname)
+    if isinstance(fname, Path):
+        return fname.open(mode=mode, encoding=encoding)
+    elif hasattr(fname, "open"):
+        return fname.open(mode=mode, encoding=encoding)
     try:
-        return open(fname, mode, encoding=encoding)  # handle pathlib-like objs
+        return open(fname, mode, encoding=encoding)
     except TypeError:
         try:
             # Make sure the object has the write methods
diff --git a/statsmodels/iolib/smpickle.py b/statsmodels/iolib/smpickle.py
--- a/statsmodels/iolib/smpickle.py
+++ b/statsmodels/iolib/smpickle.py
@@ -8,12 +8,12 @@ def save_pickle(obj, fname):
 
     Parameters
     ----------
-    fname : str
+    fname : {str, pathlib.Path}
         Filename to pickle to
     """
     import pickle
 
-    with get_file_obj(fname, 'wb') as fout:
+    with get_file_obj(fname, "wb") as fout:
         pickle.dump(obj, fout, protocol=-1)
 
 
@@ -29,7 +29,7 @@ def load_pickle(fname):
 
     Parameters
     ----------
-    fname : str
+    fname : {str, pathlib.Path}
         Filename to unpickle
 
     Notes
@@ -38,5 +38,5 @@ def load_pickle(fname):
     """
     import pickle
 
-    with get_file_obj(fname, 'rb') as fin:
+    with get_file_obj(fname, "rb") as fin:
         return pickle.load(fin)


Test patch:
diff --git a/statsmodels/iolib/tests/test_pickle.py b/statsmodels/iolib/tests/test_pickle.py
--- a/statsmodels/iolib/tests/test_pickle.py
+++ b/statsmodels/iolib/tests/test_pickle.py
@@ -1,11 +1,13 @@
+from statsmodels.compat.python import lrange
+
 from io import BytesIO
+import os
 import pathlib
 import tempfile
 
 from numpy.testing import assert_equal
 
-from statsmodels.compat.python import lrange
-from statsmodels.iolib.smpickle import save_pickle, load_pickle
+from statsmodels.iolib.smpickle import load_pickle, save_pickle
 
 
 def test_pickle():
@@ -26,8 +28,6 @@ def test_pickle():
 
     # cleanup, tested on Windows
     try:
-        import os
-
         os.remove(path_str)
         os.remove(path_pathlib)
         os.rmdir(tmpdir)
@@ -42,3 +42,34 @@ def test_pickle():
     d = load_pickle(fh)
     fh.close()
     assert_equal(a, d)
+
+
+def test_pickle_supports_open():
+    tmpdir = tempfile.mkdtemp(prefix="pickle")
+    a = lrange(10)
+
+    class SubPath:
+        def __init__(self, path):
+            self._path = pathlib.Path(path)
+
+        def open(
+            self,
+            mode="r",
+            buffering=-1,
+            encoding=None,
+            errors=None,
+            newline=None,
+        ):
+            return self._path.open(
+                mode=mode,
+                buffering=buffering,
+                encoding=encoding,
+                errors=errors,
+                newline=newline,
+            )
+
+    # test with pathlib
+    path_pathlib = SubPath(tmpdir + os.pathsep + "res2.pkl")
+    save_pickle(a, path_pathlib)
+    c = load_pickle(path_pathlib)
+    assert_equal(a, c)