Repository: PyFilesystem/pyfilesystem2
Pull request #49 (2017-06-11 14:45:55)

Pathlike
Adds support for fsencode, fsdecode, and path-like paths in OSFS

coveralls: 
[![Coverage Status](https://coveralls.io/builds/11921608/badge)](https://coveralls.io/builds/11921608)

Coverage remained the same at 100.0% when pulling **a3240b2e2421eb169c9a35cef2b2c31275fef0c6 on pathlike** into **6b787091b45be2275de80225e0bdf09374fc8d95 on master**.

coveralls: 
[![Coverage Status](https://coveralls.io/builds/11921632/badge)](https://coveralls.io/builds/11921632)

Coverage remained the same at 100.0% when pulling **a3240b2e2421eb169c9a35cef2b2c31275fef0c6 on pathlike** into **6b787091b45be2275de80225e0bdf09374fc8d95 on master**.


Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index bcdd47b..d5147ce 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,11 +4,12 @@ All notable changes to this project will be documented in this file.
 The format is based on [Keep a Changelog](http://keepachangelog.com/)
 and this project adheres to [Semantic Versioning](http://semver.org/).
 
-## [Unreleased]
+## [2.0.4] - 2017-06-11
 
 ### Added
 
 - Opener extension mechanism contributed by Martin Larralde.
+- Support for pathlike objects.
 
 ### Fixed
 
@@ -19,6 +20,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
 - More specific error when `validatepath` throws an error about the path
     argument being the wrong type, and changed from a ValueError to a
     TypeError.
+- Deprecated `encoding` parameter in OSFS.
 
 ## [2.0.3] - 2017-04-22
 
diff --git a/fs/_fscompat.py b/fs/_fscompat.py
new file mode 100644
index 0000000..b450fff
--- /dev/null
+++ b/fs/_fscompat.py
@@ -0,0 +1,73 @@
+import sys
+
+import six
+
+try:
+    from os import fsencode, fsdecode
+except ImportError:
+    def _fscodec():
+        encoding = sys.getfilesystemencoding()
+        errors = 'strict' if encoding == 'mbcs' else 'surrogateescape'
+
+        def fsencode(filename):
+            """
+            Encode filename to the filesystem encoding with 'surrogateescape' error
+            handler, return bytes unchanged. On Windows, use 'strict' error handler if
+            the file system encoding is 'mbcs' (which is the default encoding).
+            """
+            if isinstance(filename, bytes):
+                return filename
+            elif isinstance(filename, six.text_type):
+                return filename.encode(encoding, errors)
+            else:
+                raise TypeError("expect string type, not %s" % type(filename).__name__)
+
+        def fsdecode(filename):
+            """
+            Decode filename from the filesystem encoding with 'surrogateescape' error
+            handler, return str unchanged. On Windows, use 'strict' error handler if
+            the file system encoding is 'mbcs' (which is the default encoding).
+            """
+            if isinstance(filename, six.text_type):
+                return filename
+            elif isinstance(filename, bytes):
+                return filename.decode(encoding, errors)
+            else:
+                raise TypeError("expect string type, not %s" % type(filename).__name__)
+
+        return fsencode, fsdecode
+
+    fsencode, fsdecode = _fscodec()
+    del _fscodec
+
+try:
+    from os import fspath
+except ImportError:
+    def fspath(path):
+        """Return the path representation of a path-like object.
+
+        If str or bytes is passed in, it is returned unchanged. Otherwise the
+        os.PathLike interface is used to get the path representation. If the
+        path representation is not str or bytes, TypeError is raised. If the
+        provided path is not str, bytes, or os.PathLike, TypeError is raised.
+        """
+        if isinstance(path, (six.text_type, bytes)):
+            return path
+
+        # Work from the object's type to match method resolution of other magic
+        # methods.
+        path_type = type(path)
+        try:
+            path_repr = path_type.__fspath__(path)
+        except AttributeError:
+            if hasattr(path_type, '__fspath__'):
+                raise
+            else:
+                raise TypeError("expected string type or os.PathLike object, "
+                                "not " + path_type.__name__)
+        if isinstance(path_repr, (six.text_type, bytes)):
+            return path_repr
+        else:
+            raise TypeError("expected {}.__fspath__() to return string type "
+                            "not {}".format(path_type.__name__,
+                                            type(path_repr).__name__))
diff --git a/fs/_version.py b/fs/_version.py
index c976758..f6bb6f4 100644
--- a/fs/_version.py
+++ b/fs/_version.py
@@ -1,1 +1,1 @@
-__version__ = "2.0.4a2"
+__version__ = "2.0.4"
diff --git a/fs/osfs.py b/fs/osfs.py
index d00a40e..db821c2 100644
--- a/fs/osfs.py
+++ b/fs/osfs.py
@@ -25,8 +25,9 @@ from . import errors
 from .errors import FileExists
 from .base import FS
 from .enums import ResourceType
+from ._fscompat import fsencode, fsdecode, fspath
 from .info import Info
-from .path import abspath, basename, normpath
+from .path import basename
 from .permissions import Permissions
 from .error_tools import convert_os_errors
 from .mode import Mode, validate_open_mode
@@ -43,9 +44,9 @@ class OSFS(FS):
     """
     Create an OSFS.
 
-    :param root_path: An OS path to the location on your HD you
-        wish to manage.
-    :type root_path: str
+    :param root_path: An OS path or path-like object to the location on
+        your HD you wish to manage.
+    :type root_path: str or path-like
     :param create: Set to ``True`` to create the root directory if it
         does not already exist, otherwise the directory should exist
         prior to creating the ``OSFS`` instance.
@@ -53,9 +54,6 @@ class OSFS(FS):
     :param int create_mode: The permissions that will be used to create
         the directory if ``create`` is True and the path doesn't exist,
         defaults to ``0o777``.
-    :param encoding: The encoding to use for paths, or ``None``
-        (default) to auto-detect.
-    :type encoding: str
     :raises `fs.errors.CreateFailed`: If ``root_path`` does not
         exists, or could not be created.
 
@@ -71,13 +69,11 @@ class OSFS(FS):
     def __init__(self,
                  root_path,
                  create=False,
-                 create_mode=0o777,
-                 encoding=None):
+                 create_mode=0o777):
         """Create an OSFS instance."""
 
         super(OSFS, self).__init__()
-        self.encoding = encoding or sys.getfilesystemencoding()
-
+        root_path = fsdecode(fspath(root_path))
         _root_path = os.path.expanduser(os.path.expandvars(root_path))
         _root_path = os.path.normpath(os.path.abspath(_root_path))
         self.root_path = _root_path
@@ -113,21 +109,17 @@ class OSFS(FS):
             _meta["invalid_path_chars"] = '\0'
 
             if 'PC_PATH_MAX' in os.pathconf_names:
-                root_path_safe = _root_path.encode(self.encoding) \
-                    if six.PY2 and isinstance(_root_path, six.text_type) \
-                    else _root_path
                 _meta['max_sys_path_length'] = (
                     os.pathconf(
-                        root_path_safe,
+                        fsencode(_root_path),
                         os.pathconf_names['PC_PATH_MAX']
                     )
                 )
 
     def __repr__(self):
-        _fmt = "{}({!r}, encoding={!r})"
+        _fmt = "{}({!r})"
         return _fmt.format(self.__class__.__name__,
-                           self.root_path,
-                           self.encoding)
+                           self.root_path)
 
     def __str__(self):
         fmt = "<{} '{}'>"


Test patch:
diff --git a/tests/test_fscompat.py b/tests/test_fscompat.py
new file mode 100644
index 0000000..a24b157
--- /dev/null
+++ b/tests/test_fscompat.py
@@ -0,0 +1,61 @@
+from __future__ import unicode_literals
+
+import unittest
+
+import six
+
+from fs._fscompat import fsencode, fsdecode, fspath
+
+
+class PathMock(object):
+    def __init__(self, path):
+        self._path = path
+    def __fspath__(self):
+        return self._path
+
+
+class BrokenPathMock(object):
+    def __init__(self, path):
+        self._path = path
+    def __fspath__(self):
+        return self.broken
+
+
+class TestFSCompact(unittest.TestCase):
+
+    def test_fspath(self):
+        path = PathMock('foo')
+        self.assertEqual(fspath(path), 'foo')
+        path = PathMock(b'foo')
+        self.assertEqual(fspath(path), b'foo')
+        path = 'foo'
+        assert path is fspath(path)
+
+        with self.assertRaises(TypeError):
+            fspath(100)
+
+        with self.assertRaises(TypeError):
+            fspath(PathMock(5))
+
+        with self.assertRaises(AttributeError):
+            fspath(BrokenPathMock('foo'))
+
+    def test_fsencode(self):
+        encode_bytes = fsencode(b'foo')
+        assert isinstance(encode_bytes, bytes)
+        self.assertEqual(encode_bytes, b'foo')
+
+        encode_bytes = fsencode('foo')
+        assert isinstance(encode_bytes, bytes)
+        self.assertEqual(encode_bytes, b'foo')
+
+        with self.assertRaises(TypeError):
+            fsencode(5)
+
+    def test_fsdecode(self):
+        decode_text = fsdecode(b'foo')
+        assert isinstance(decode_text, six.text_type)
+        decode_text = fsdecode('foo')
+        assert isinstance(decode_text, six.text_type)
+        with self.assertRaises(TypeError):
+            fsdecode(5)