Repository: python-trio/trio

Issue:
Add tests for fspath



Discussion:

Resolving patch:
diff --git a/trio/_util.py b/trio/_util.py
--- a/trio/_util.py
+++ b/trio/_util.py
@@ -258,48 +258,60 @@ def fix_one(obj):
         fix_one(obj)
 
 
-# This is based on the PEP 519 fspath implementation.
-# The function has been adapted to work with pathlib objects on python 3.5
+# os.fspath is defined on Python 3.6+ but we need to support Python 3.5 too
+# This is why we provide our own implementation. On Python 3.6+ we use the
+# StdLib's version and on Python 3.5 our own version.
+# Our own implementation implementation is based on PEP 519 while it has also
+# been adapted to work with pathlib objects on python 3.5
 # The input typehint is removed as there is no os.PathLike on 3.5.
 # See: https://www.python.org/dev/peps/pep-0519/#os
 
 
 def fspath(path) -> t.Union[str, bytes]:
-    """Return the string representation of the path.
-
-    If str or bytes is passed in, it is returned unchanged. If a pre-python 3.6
-    pathlib object is passed, its string representation is returned. If
-    __fspath__() returns something other than str or bytes then TypeError is
-    raised. If this function is given something that is not str, bytes,
-    pathlib.PurePath or os.PathLike then TypeError is raised.
+    """Return the path representation of a path-like object.
+
+    Returns
+    -------
+    - If str or bytes is passed in, it is returned unchanged.
+    - If the os.PathLike interface is implemented it is used to get the path
+      representation.
+    - If the python version is 3.5 or earlier and a pathlib object is passed,
+      the object's string representation is returned.
+
+    Raises
+    ------
+    - Regardless of the input, if the path representation (e.g. the value
+      returned from __fspath__) is not str or bytes, TypeError is raised.
+    - If the provided path is not str, bytes, pathlib.PurePath or os.PathLike,
+      TypeError is raised.
     """
     if isinstance(path, (str, bytes)):
         return path
-
     # Work from the object's type to match method resolution of other magic
     # methods.
     path_type = type(path)
+    # On python 3.5, pathlib objects don't have the __fspath__ method,
+    # but we still want to get their string representation.
+    if issubclass(path_type, pathlib.PurePath):
+        return str(path)
     try:
-        path = path_type.__fspath__(path)
+        path_repr = path_type.__fspath__(path)
     except AttributeError:
         if hasattr(path_type, '__fspath__'):
             raise
-        # On python 3.5 pathlib objects don't have an __fspath__ method.
-        # but we still want to get their string representation.
-        if isinstance(path, pathlib.PurePath):
-            return str(path)
-    else:
-        if isinstance(path, (str, bytes)):
-            return path
         else:
             raise TypeError(
-                "expected __fspath__() to return str or bytes, "
-                "not " + type(path).__name__
+                "expected str, bytes or os.PathLike object, "
+                "not " + path_type.__name__
             )
-
-    raise TypeError(
-        "expected str, bytes or os.PathLike object, not " + path_type.__name__
-    )
+    if isinstance(path_repr, (str, bytes)):
+        return path_repr
+    else:
+        raise TypeError(
+            "expected {}.__fspath__() to return str or bytes, "
+            "not {}".format(path_type.__name__,
+                            type(path_repr).__name__)
+        )
 
 
 if hasattr(os, "fspath"):


Test patch:
diff --git a/trio/tests/test_util.py b/trio/tests/test_util.py
--- a/trio/tests/test_util.py
+++ b/trio/tests/test_util.py
@@ -1,16 +1,24 @@
 import pytest
 
-import signal
+import os
 import sys
+import signal
+import pathlib
 import textwrap
 
 from async_generator import async_generator, yield_
 
-from .._util import *
+from .._util import acontextmanager, signal_raise, ConflictDetector, fspath
 from .. import _core
 from ..testing import wait_all_tasks_blocked, assert_checkpoints
 
 
+def raise_(exc):
+    """ Raise provided exception.
+    Just a helper for raising exceptions from lambdas. """
+    raise exc
+
+
 def test_signal_raise():
     record = []
 
@@ -221,3 +229,84 @@ def test_module_metadata_is_fixed_up():
     # Also check methods
     assert trio.ssl.SSLStream.__init__.__module__ == "trio.ssl"
     assert trio.abc.Stream.send_all.__module__ == "trio.abc"
+
+
+# define a concrete class implementing the PathLike protocol
+# Since we want to have compatibility with Python 3.5 we need
+# to define the base class on runtime.
+BaseKlass = os.PathLike if hasattr(os, "PathLike") else object
+
+
+class ConcretePathLike(BaseKlass):
+    """ Class implementing the file system path protocol."""
+
+    def __init__(self, path=""):
+        self.path = path
+
+    def __fspath__(self):
+        return self.path
+
+
+class TestFspath(object):
+
+    # based on:
+    # https://github.com/python/cpython/blob/da6c3da6c33c6bf794f741e348b9c6d86cc43ec5/Lib/test/test_os.py#L3527-L3571
+
+    @pytest.mark.parametrize(
+        "path", (b'hello', b'goodbye', b'some/path/and/file')
+    )
+    def test_return_bytes(self, path):
+        assert path == fspath(path)
+
+    @pytest.mark.parametrize(
+        "path", ('hello', 'goodbye', 'some/path/and/file')
+    )
+    def test_return_string(self, path):
+        assert path == fspath(path)
+
+    @pytest.mark.parametrize(
+        "path", (pathlib.Path("/home"), pathlib.Path("C:\\windows"))
+    )
+    def test_handle_pathlib(self, path):
+        assert str(path) == fspath(path)
+
+    @pytest.mark.parametrize("path", ("path/like/object", b"path/like/object"))
+    def test_handle_pathlike_protocol(self, path):
+        pathlike = ConcretePathLike(path)
+        assert path == fspath(pathlike)
+        if sys.version_info > (3, 6):
+            assert issubclass(ConcretePathLike, os.PathLike)
+            assert isinstance(pathlike, os.PathLike)
+
+    def test_argument_required(self):
+        with pytest.raises(TypeError):
+            fspath()
+
+    def test_throw_error_at_multiple_arguments(self):
+        with pytest.raises(TypeError):
+            fspath(1, 2)
+
+    @pytest.mark.parametrize(
+        "klass", (23, object(), int, type, os, type("blah", (), {})())
+    )
+    def test_throw_error_at_non_pathlike(self, klass):
+        with pytest.raises(TypeError):
+            fspath(klass)
+
+    @pytest.mark.parametrize(
+        "exception, method",
+        [
+            (TypeError, 1),  # __fspath__ is not callable
+            (TypeError, lambda x: 23
+             ),  # __fspath__ returns a value other than str or bytes
+            (Exception, lambda x: raise_(Exception)
+             ),  # __fspath__raises a random exception
+            (AttributeError, lambda x: raise_(AttributeError)
+             ),  # __fspath__ raises AttributeError
+        ]
+    )
+    def test_bad_pathlike_implementation(self, exception, method):
+        klass = type('foo', (), {})
+        klass.__fspath__ = method
+        with pytest.raises(exception):
+            fspath(klass())