diff --git a/news/vox-fs.rst b/news/vox-fs.rst new file mode 100644 index 000000000..4542a9c14 --- /dev/null +++ b/news/vox-fs.rst @@ -0,0 +1,13 @@ +**Added:** None + +**Changed:** + +* ``voxapi.Vox`` now supports ``pathlib.Path`` and ``PathLike`` objects as virtual environment identifiers + +**Deprecated:** None + +**Removed:** None + +**Fixed:** None + +**Security:** None diff --git a/tests/test_vox.py b/tests/test_vox.py index 0631601ef..afff5b9a5 100644 --- a/tests/test_vox.py +++ b/tests/test_vox.py @@ -125,6 +125,30 @@ def test_crud_subdir(xonsh_builtins, tmpdir): assert not tmpdir.join('spam', 'eggs').check() +try: + import pathlib +except ImportError: + pass +else: + @skip_if_on_conda + def test_crud_path(xonsh_builtins, tmpdir): + """ + Creates a virtual environment, gets it, enumerates it, and then deletes it. + """ + tmp = pathlib.Path(str(tmpdir)) + + vox = Vox() + vox.create(tmp) + assert stat.S_ISDIR(tmpdir.join('lib').stat().mode) + + ve = vox[tmp] + assert ve.env == str(tmp) + assert os.path.isdir(ve.bin) + + del vox[tmp] + + assert not tmpdir.check() + @skip_if_on_conda def test_crud_subdir(xonsh_builtins, tmpdir): diff --git a/xonsh/__init__.py b/xonsh/__init__.py index b6b948aba..73d472940 100644 --- a/xonsh/__init__.py +++ b/xonsh/__init__.py @@ -1,7 +1,7 @@ __version__ = '0.4.7' # amalgamate exclude jupyter_kernel parser_table parser_test_table pyghooks -# amalgamate exclude winutils wizard pytest_plugin +# amalgamate exclude winutils wizard pytest_plugin fs import os as _os if _os.getenv('XONSH_DEBUG', ''): pass diff --git a/xonsh/fs.py b/xonsh/fs.py new file mode 100644 index 000000000..178140cbd --- /dev/null +++ b/xonsh/fs.py @@ -0,0 +1,94 @@ +""" +Backported functions to implement the PEP 519 (Adding a file system path protocol) API. +""" + +import abc +import sys +import io +import pathlib + +try: + from os import PathLike, fspath, fsencode, fsdecode +except ImportError: + class PathLike(abc.ABC): + """Abstract base class for implementing the file system path protocol.""" + + @abc.abstractmethod + def __fspath__(self): + """Return the file system path representation of the object.""" + raise NotImplementedError + + PathLike.register(pathlib.Path) + + def fspath(path): + """Return the string representation of the path. + + If str or bytes is passed in, it is returned unchanged. If __fspath__() + returns something other than str or bytes then TypeError is raised. If + this function is given something that is not str, bytes, or os.PathLike + then TypeError is raised. + """ + if isinstance(path, (str, bytes)): + return path + + if isinstance(path, pathlib.Path): + return str(path) + + # Work from the object's type to match method resolution of other magic + # methods. + path_type = type(path) + try: + path = path_type.__fspath__(path) + except AttributeError: + if hasattr(path_type, '__fspath__'): + raise + else: + if isinstance(path, (str, bytes)): + return path + else: + raise TypeError("expected __fspath__() to return str or bytes, " + "not " + type(path).__name__) + + raise TypeError("expected str, bytes or os.PathLike object, not " + + path_type.__name__) + + def _fscodec(): + encoding = sys.getfilesystemencoding() + if encoding == 'mbcs': + errors = 'strict' + else: + errors = 'surrogateescape' + + def fsencode(filename): + """Encode filename (an os.PathLike, bytes, or str) 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). + """ + filename = fspath(filename) # Does type-checking of `filename`. + if isinstance(filename, str): + return filename.encode(encoding, errors) + else: + return filename + + def fsdecode(filename): + """Decode filename (an os.PathLike, bytes, or str) 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). + """ + filename = fspath(filename) # Does type-checking of `filename`. + if isinstance(filename, bytes): + return filename.decode(encoding, errors) + else: + return filename + + return fsencode, fsdecode + + fsencode, fsdecode = _fscodec() + del _fscodec + + def open(file, *pargs, **kwargs): + if isinstance(file, PathLike): + file = fspath(file) + return io.open(file, *pargs, **kwargs) diff --git a/xontrib/voxapi.py b/xontrib/voxapi.py index 49eb5d719..ba45ed970 100644 --- a/xontrib/voxapi.py +++ b/xontrib/voxapi.py @@ -16,6 +16,7 @@ import builtins import collections.abc from xonsh.platform import ON_POSIX, ON_WINDOWS +from xonsh.fs import PathLike, fspath # This is because builtins aren't globally created during testing. # FIXME: Is there a better way? @@ -132,7 +133,10 @@ class Vox(collections.abc.Mapping): """ # NOTE: clear=True is the same as delete then create. # NOTE: upgrade=True is its own method - env_path = os.path.join(self.venvdir, name) + if isinstance(name, PathLike): + env_path = fspath(name) + else: + env_path = os.path.join(self.venvdir, name) if not self._check_reserved(env_path): raise ValueError("venv can't contain reserved names ({})".format(', '.join(_subdir_names()))) venv.create( @@ -194,6 +198,8 @@ class Vox(collections.abc.Mapping): """ if name is ...: env_paths = [builtins.__xonsh_env__['VIRTUAL_ENV']] + elif isinstance(name, PathLike): + env_paths = [fspath(name)] else: if not self._check_reserved(name): # Don't allow a venv that could be a venv special dir