diff --git a/CHANGELOG.md b/CHANGELOG.md index bcdd47bd..d5147ce9 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 00000000..b450fff1 --- /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 c9767581..f6bb6f4d 100644 --- a/fs/_version.py +++ b/fs/_version.py @@ -1 +1 @@ -__version__ = "2.0.4a2" +__version__ = "2.0.4" diff --git a/fs/osfs.py b/fs/osfs.py index d00a40ef..db821c27 100644 --- a/fs/osfs.py +++ b/fs/osfs.py @@ -25,8 +25,9 @@ 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 @@ def __init__(self, _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 = "<{} '{}'>" diff --git a/tests/test_fscompat.py b/tests/test_fscompat.py new file mode 100644 index 00000000..a24b1574 --- /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)