diff --git a/CHANGELOG.md b/CHANGELOG.md index 1fb1728..73f9206 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ v0.4.0 (in development) binary mode. - `InPlaceBytes` and `InPlaceText` are now deprecated and will be removed in a future version; please use `InPlace` with `mode='b'` or `mode='t'` instead. +- Support fsencoded-bytes as file paths under Python 3 v0.3.0 (2018-06-28) ------------------- diff --git a/README.rst b/README.rst index 5deaacc..f8b17cd 100644 --- a/README.rst +++ b/README.rst @@ -82,7 +82,7 @@ Compared to the in-place filtering implemented by the Python standard library's Installation ============ Just use `pip `_ (You have pip, right?) to install -``in_place`` and its dependencies:: +``in_place``:: pip install in_place @@ -133,6 +133,10 @@ following arguments: ``newline``) will be forwarded to ``io.open()`` (or the builtin ``open`` if ``mode`` is ``None``) when opening both the input and output file strems. +``name``, ``backup``, and ``backup_ext`` can be either ``str`` or +filesystem-encoded ``bytes`` in Python 3, and in Python 3.6 or later, path-like +objects are also accepted. + Note: Earlier versions of this library provided separate ``InPlaceText`` and diff --git a/TODO.md b/TODO.md index a44ed07..1bc151a 100644 --- a/TODO.md +++ b/TODO.md @@ -39,6 +39,4 @@ - Give the classes decent `__repr__`s - Make the context manager reusable - Add a `seekable()` method that returns `False`? -- Support `os.PathLike` objects in Python 3.6+ -- Support `bytes` paths in Python 3 using `os.fsdecode()` - Should `io.open()` be used when `mode` is unset but `kwargs` is nonempty? diff --git a/in_place.py b/in_place.py index edd4b0e..37d4c48 100644 --- a/in_place.py +++ b/in_place.py @@ -24,6 +24,12 @@ import tempfile from warnings import warn +try: + from os import fsdecode +except ImportError: + def fsdecode(p): + return p + __all__ = ['InPlace', 'InPlaceBytes', 'InPlaceText'] class InPlace(object): @@ -32,9 +38,10 @@ class InPlace(object): write ending up at the same filepath that you read from) that takes care of all the necessary mucking about with temporary files. - :param string name: The path to the file to open & edit in-place (resolved + :param name: The path to the file to open & edit in-place (resolved relative to the current directory at the time of the instance's creation) + :type name: path-like :param string mode: Whether to operate on the file in binary or text mode. If ``mode`` is ``'b'``, the file will be opened in binary mode, and @@ -45,14 +52,16 @@ class InPlace(object): `open` using the default mode, and data will be read & written as `str` objects, whatever those happen to be in your version of Python. - :param string backup: The path at which to save the file's original - contents once editing has finished (resolved relative to the current - directory at the time of the instance's creation); if `None` (the - default), no backup is saved + :param backup: The path at which to save the file's original contents once + editing has finished (resolved relative to the current directory at the + time of the instance's creation); if `None` (the default), no backup is + saved + :type backup: path-like - :param string backup_ext: A string to append to ``name`` to get the path at - which to save the file's original contents. Cannot be empty. - ``backup`` and ``backup_ext`` are mutually exclusive. + :param backup_ext: A string to append to ``name`` to get the path at which + to save the file's original contents. Cannot be empty. ``backup`` and + ``backup_ext`` are mutually exclusive. + :type backup_ext: path-like :param bool delay_open: If `True`, the newly-constructed instance will not be open, and the user must either explicitly call the :meth:`open()` @@ -78,11 +87,11 @@ def __init__(self, name, mode=None, backup=None, backup_ext=None, delay_open=False, move_first=False, **kwargs): cwd = os.getcwd() #: The path to the file to edit in-place - self.name = name + self.name = fsdecode(name) #: Whether to operate on the file in binary or text mode self.mode = mode #: The absolute path of the file to edit in-place - self.filepath = os.path.join(cwd, name) + self.filepath = os.path.join(cwd, self.name) #: ``filepath`` with symbolic links resolved. This is set just before #: opening the file. self.realpath = None @@ -91,11 +100,11 @@ def __init__(self, name, mode=None, backup=None, backup_ext=None, raise ValueError('backup and backup_ext are mutually exclusive') #: The absolute path of the backup file (if any) that the original #: contents of ``realpath`` will be moved to after editing - self.backuppath = os.path.join(cwd, backup) + self.backuppath = os.path.join(cwd, fsdecode(backup)) elif backup_ext is not None: - if backup_ext == '': + if not backup_ext: raise ValueError('backup_ext cannot be empty') - self.backuppath = self.filepath + backup_ext + self.backuppath = self.filepath + fsdecode(backup_ext) else: self.backuppath = None #: Whether to move the input file before opening and create the output diff --git a/test/test_nonstr_path.py b/test/test_nonstr_path.py new file mode 100644 index 0000000..12b2933 --- /dev/null +++ b/test/test_nonstr_path.py @@ -0,0 +1,150 @@ +import sys +import pytest +from in_place import InPlace +from test_in_place_util import TEXT, pylistdir + +class PathLike(object): + def __init__(self, path): + self.path = path + + def __fspath__(self): + return self.path + + +@pytest.mark.skipif(sys.version_info[:2] < (3,6), reason='Python 3.6+ only') +def test_pathlike(tmpdir): + assert pylistdir(tmpdir) == [] + p = tmpdir.join("file.txt") + p.write(TEXT) + with InPlace(PathLike(str(p))) as fp: + assert not fp.closed + for line in fp: + assert isinstance(line, str) + fp.write(line.swapcase()) + assert not fp.closed + assert fp.closed + assert pylistdir(tmpdir) == ['file.txt'] + assert p.read() == TEXT.swapcase() + +@pytest.mark.skipif(sys.version_info[:2] < (3,6), reason='Python 3.6+ only') +def test_pathlike_backup_ext(tmpdir): + assert pylistdir(tmpdir) == [] + p = tmpdir.join("file.txt") + p.write(TEXT) + with InPlace(PathLike(str(p)), backup_ext='~') as fp: + for line in fp: + fp.write(line.swapcase()) + assert pylistdir(tmpdir) == ['file.txt', 'file.txt~'] + assert p.new(ext='txt~').read() == TEXT + assert p.read() == TEXT.swapcase() + +@pytest.mark.skipif(sys.version_info[:2] < (3,6), reason='Python 3.6+ only') +def test_pathlike_backup(tmpdir): + assert pylistdir(tmpdir) == [] + p = tmpdir.join("file.txt") + p.write(TEXT) + bkp = tmpdir.join('backup.txt') + with InPlace(PathLike(str(p)), backup=PathLike(str(bkp))) as fp: + assert not fp.closed + for line in fp: + fp.write(line.swapcase()) + assert not fp.closed + assert fp.closed + assert pylistdir(tmpdir) == ['backup.txt', 'file.txt'] + assert bkp.read() == TEXT + assert p.read() == TEXT.swapcase() + +@pytest.mark.skipif(sys.version_info[:2] < (3,6), reason='Python 3.6+ only') +def test_pathlike_bytes(tmpdir): + from os import fsencode + assert pylistdir(tmpdir) == [] + p = tmpdir.join("file.txt") + p.write(TEXT) + with InPlace(PathLike(fsencode(str(p)))) as fp: + assert not fp.closed + for line in fp: + assert isinstance(line, str) + fp.write(line.swapcase()) + assert not fp.closed + assert fp.closed + assert pylistdir(tmpdir) == ['file.txt'] + assert p.read() == TEXT.swapcase() + +@pytest.mark.skipif(sys.version_info[:2] < (3,6), reason='Python 3.6+ only') +def test_pathlike_bytes_backup_ext(tmpdir): + from os import fsencode + assert pylistdir(tmpdir) == [] + p = tmpdir.join("file.txt") + p.write(TEXT) + with InPlace(PathLike(fsencode(str(p))), backup_ext='~') as fp: + for line in fp: + fp.write(line.swapcase()) + assert pylistdir(tmpdir) == ['file.txt', 'file.txt~'] + assert p.new(ext='txt~').read() == TEXT + assert p.read() == TEXT.swapcase() + +@pytest.mark.skipif(sys.version_info[:2] < (3,6), reason='Python 3.6+ only') +def test_pathlike_bytes_backup(tmpdir): + from os import fsencode + assert pylistdir(tmpdir) == [] + p = tmpdir.join("file.txt") + p.write(TEXT) + bkp = tmpdir.join('backup.txt') + with InPlace( + PathLike(fsencode(str(p))), + backup=PathLike(fsencode(str(bkp))), + ) as fp: + assert not fp.closed + for line in fp: + fp.write(line.swapcase()) + assert not fp.closed + assert fp.closed + assert pylistdir(tmpdir) == ['backup.txt', 'file.txt'] + assert bkp.read() == TEXT + assert p.read() == TEXT.swapcase() + +@pytest.mark.skipif(sys.version_info[0] < 3, reason='Python 3 only') +def test_bytes(tmpdir): + from os import fsencode + assert pylistdir(tmpdir) == [] + p = tmpdir.join("file.txt") + p.write(TEXT) + with InPlace(fsencode(str(p))) as fp: + assert not fp.closed + for line in fp: + assert isinstance(line, str) + fp.write(line.swapcase()) + assert not fp.closed + assert fp.closed + assert pylistdir(tmpdir) == ['file.txt'] + assert p.read() == TEXT.swapcase() + +@pytest.mark.skipif(sys.version_info[0] < 3, reason='Python 3 only') +def test_bytes_backup_ext(tmpdir): + from os import fsencode + assert pylistdir(tmpdir) == [] + p = tmpdir.join("file.txt") + p.write(TEXT) + with InPlace(fsencode(str(p)), backup_ext=fsencode('~')) as fp: + for line in fp: + fp.write(line.swapcase()) + assert pylistdir(tmpdir) == ['file.txt', 'file.txt~'] + assert p.new(ext='txt~').read() == TEXT + assert p.read() == TEXT.swapcase() + +@pytest.mark.skipif(sys.version_info[0] < 3, reason='Python 3 only') +def test_bytes_backup(tmpdir): + from os import fsencode + assert pylistdir(tmpdir) == [] + p = tmpdir.join("file.txt") + p.write(TEXT) + bkp = tmpdir.join('backup.txt') + with InPlace(fsencode(str(p)), backup=fsencode(str(bkp))) as fp: + assert not fp.closed + for line in fp: + fp.write(line.swapcase()) + assert not fp.closed + assert fp.closed + assert pylistdir(tmpdir) == ['backup.txt', 'file.txt'] + assert bkp.read() == TEXT + assert p.read() == TEXT.swapcase() diff --git a/tox.ini b/tox.ini index 6fff521..1be28b3 100644 --- a/tox.ini +++ b/tox.ini @@ -9,7 +9,7 @@ deps = pytest~=3.0 pytest-cov~=2.0 pytest-flakes~=4.0 - six ~= 1.4 + six~=1.4 commands = pytest in_place.py test [pytest]