Repository: jwodder/inplace (language: python)

Issue:
Mypy type compliance
Currently when using this package (for example the `InPlace` class), when linting with mypy a `missing library stubs or py.typed marker` error is raised. Would you consider adding stub files to make this package mypy compatible?

To reproduce, create a small file using `inplace`:
```
import in_place

with in_place.InPlace('example.txt') as example_file:
    for line in example_file:
        example_file.write(line)
```

Run `mypy file_name.py`. 


Discussion / hints:

Fix patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index bde94e3..87252e2 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,7 +1,22 @@
-v0.6.0 (in development)
+v1.0.0 (in development)
 -----------------------
 - Support Python 3.10, 3.11, and 3.12
-- Drop support for Python 3.6
+- Drop support for Python 3.6 and 3.7
+- **Breaking**: The `move_first` argument has been removed.  Only the
+  `move_first=False` semantics are retained.
+- Removed the `readall()` method.  I don't think it ever worked.
+- **Breaking**: The `delay_open` argument and `open()` method have been
+  removed.  Filehandles will now always be created at the moment an in-place
+  instance is constructed, just like when calling the standard library's
+  `open()`.
+- **Breaking**: When the input path points to a symlink and `backup_ext` is
+  given, the backup extension will now be appended to the resolved path rather
+  than to the pre-resolved path.
+- Added type annotations
+- `InPlaceText` and `InPlaceBytes` (deprecated in v0.4.0) have been removed
+- Added `read1()` and `readinto1()` methods for binary mode
+- The `InPlace` constructor now immediately raises a `ValueError` if `backup`
+  is the empty string
 
 v0.5.0 (2021-02-20)
 -------------------
diff --git a/MANIFEST.in b/MANIFEST.in
index 80174ec..7a2cb7d 100644
--- a/MANIFEST.in
+++ b/MANIFEST.in
@@ -1,4 +1,5 @@
 include CHANGELOG.* CONTRIBUTORS.* LICENSE tox.ini
+graft src
 graft docs
 prune docs/_build
 graft test
diff --git a/README.rst b/README.rst
index 0591455..f5f1870 100644
--- a/README.rst
+++ b/README.rst
@@ -38,13 +38,15 @@ For example, given the file ``somefile.txt``::
     All mimsy were the borogoves,
         And the mome raths outgrabe.
 
-and the program ``disemvowel.py``::
+and the program ``disemvowel.py``:
+
+.. code:: python
 
     import in_place
 
-    with in_place.InPlace('somefile.txt') as fp:
+    with in_place.InPlace("somefile.txt") as fp:
         for line in fp:
-            fp.write(''.join(c for c in line if c not in 'AEIOUaeiou'))
+            fp.write("".join(c for c in line if c not in "AEIOUaeiou"))
 
 after running the program, ``somefile.txt`` will have been edited in place,
 reducing it to just::
@@ -56,13 +58,17 @@ reducing it to just::
 
 and no sign of those pesky vowels remains!  If you want a sign of those pesky
 vowels to remain, you can instead save the file's original contents in, say,
-``somefile.txt~`` by constructing the filehandle with::
+``somefile.txt~`` by constructing the filehandle with:
+
+.. code:: python
+
+    in_place.InPlace("somefile.txt", backup_ext="~")
 
-    in_place.InPlace('somefile.txt', backup_ext='~')
+or save to ``someotherfile.txt`` with:
 
-or save to ``someotherfile.txt`` with::
+.. code:: python
 
-    in_place.InPlace('somefile.txt', backup='someotherfile.txt')
+    in_place.InPlace("somefile.txt", backup="someotherfile.txt")
 
 Compared to the in-place filtering implemented by the Python standard library's
 |fileinput|_ module, ``in_place`` offers the following benefits:
@@ -87,9 +93,8 @@ Compared to the in-place filtering implemented by the Python standard library's
 
 Installation
 ============
-``in_place`` requires Python 3.7 or higher.  Just use `pip
-<https://pip.pypa.io>`_ for Python 3 (You have pip, right?) to install
-``in_place`` and its dependencies::
+``in_place`` requires Python 3.8 or higher.  Just use `pip
+<https://pip.pypa.io>`_ for Python 3 (You have pip, right?) to install it::
 
     python3 -m pip install in_place
 
@@ -102,16 +107,16 @@ following arguments:
 ``name=<PATH>`` (required)
    The path to the file to open & edit in-place
 
-``mode=<'b'|'t'|None>``
+``mode=<"b"|"t"|None>``
    Whether to operate on the file in binary or text mode.  If ``mode`` is
-   ``'b'``, the file will be opened in binary mode, and data will be read &
-   written as ``bytes`` objects.  If ``mode`` is ``'t'`` or ``None`` (the
+   ``"b"``, the file will be opened in binary mode, and data will be read &
+   written as ``bytes`` objects.  If ``mode`` is ``"t"`` or ``None`` (the
    default), the file will be opened in text mode, and data will be read &
    written as ``str`` objects.
 
 ``backup=<PATH>``
    If set, the original contents of the file will be saved to the given path
-   when the instance is closed.
+   when the instance is closed.  ``backup`` cannot be set to the empty string.
 
 ``backup_ext=<EXTENSION>``
    If set, the path to the backup file will be created by appending
@@ -120,18 +125,6 @@ following arguments:
    ``backup`` and ``backup_ext`` are mutually exclusive.  ``backup_ext`` cannot
    be set to the empty string.
 
-``delay_open=<BOOL>``
-   By default, the instance is opened (including creating temporary files and
-   so forth) as soon as it's created.  Setting ``delay_open=True`` disables
-   this; the instance must then be opened either via the ``open()`` method or
-   by using it as a context manager.
-
-``move_first=<BOOL>``
-   If ``True``, move the input file to a temporary location first and create
-   the output file in its place (à la ``fileinput``) rather than the default
-   behavior of creating the output file at a temporary location and only moving
-   things around once ``close()`` is called (à la GNU ``sed(1)``).
-
 ``**kwargs``
    Any additional keyword arguments (such as ``encoding``, ``errors``, and
    ``newline``) will be forwarded to ``open()`` when opening both the input and
@@ -140,36 +133,19 @@ following arguments:
 ``name``, ``backup``, and ``backup_ext`` can be ``str``, filesystem-encoded
 ``bytes``, or path-like objects.
 
-Note:
-
-    Earlier versions of this library provided separate ``InPlaceText`` and
-    ``InPlaceBytes`` classes for operating in text and binary mode.  As of
-    version 0.4.0, these classes are deprecated and will be removed in a future
-    version.  Code written for earlier versions should be updated to use
-    ``InPlace`` with the ``mode`` argument instead::
-
-        InPlaceText(name, ...)   ->  InPlace(name, 't', ...)
-        InPlaceBytes(name, ...)  ->  InPlace(name, 'b', ...)
-
 Once open, ``InPlace`` instances act as filehandles with the usual filehandle
 attributes, specifically::
 
-    __iter__()              close()                 closed
+    __iter__()              __next__()              closed
     flush()                 name                    read()
-    readall() *             readinto() *            readline()
-    readlines()             write()                 writelines()
+    read1() *               readinto() *            readinto1() *
+    readline()              readlines()             write()
+    writelines()
 
     * binary mode only
 
 ``InPlace`` instances also feature the following new or modified attributes:
 
-``open()``
-   Open the instance, creating filehandles for reading & writing.  This method
-   must be called first before any of the other I/O methods can be used.  It is
-   normally called automatically upon instance initialization unless
-   ``delay_open`` was set to ``True``.  A ``ValueError`` is raised if this
-   method is called more than once in an instance's lifetime.
-
 ``close()``
    Close filehandles and move files to their final destinations.  If called
    after the filehandle has already been closed, ``close()`` does nothing.
@@ -183,11 +159,11 @@ attributes, specifically::
    intact) instead of replacing the original file with it
 
 ``__enter__()``, ``__exit__()``
-   When an ``InPlace`` instance is used as a context manager, it will be opened
-   (if not open already) on entering and either closed (if all went well) or
-   rolled back (if an exception occurred) on exiting.  ``InPlace`` context
-   managers are not `reusable`_ but are `reentrant`_ (as long as no further
-   operations are performed after the innermost context ends).
+   When an ``InPlace`` instance is used as a context manager, on exiting the
+   context, the instance will be either closed (if all went well) or rolled
+   back (if an exception occurred).  ``InPlace`` context managers are not
+   reusable_ but are reentrant_ (as long as no further operations are performed
+   after the innermost context ends).
 
 ``input``
    The actual filehandle that data is read from, in case you need to access it
diff --git a/TODO.md b/TODO.md
deleted file mode 100644
index 366e7ee..0000000
--- a/TODO.md
+++ /dev/null
@@ -1,39 +0,0 @@
-- Somewhere document exactly how the module works with regards to creating
-  temporary files and moving things around
-
-- Write more tests:
-    - copying file mode, timestamps, owner, & group
-    - timestamp (and other file attributes?) preservation on rollback
-    - newlines
-    - every I/O method
-        - flush
-        - readall
-    - Assert that the tempfile is created in the right directory
-    - `delay_open=True`
-    - nontrivial (i.e., containing `/` and/or `..`) relative filepaths
-    - `backup_ext` containing a directory separator?
-    - `backup_ext` when the filepath contains a directory separator
-    - relative vs. absolute paths?
-    - context manager non-reusability
-    - nonwritable directories
-    - symlinks pointing to nonexistent files
-
-- Add options for:
-    - not resolving symbolic links
-    - preserving the tempfile if an error was raised
-    - `create=False`: If true and the input file doesn't exist, act as though
-      it's simply empty
-
-- When the filename is `-`, read stdin and write to stdout?
-    - Only support this when an `allow_dash=True` argument is given?
-    - Don't do this, as standard streams can't be rolled back
-- Make `InPlace` inherit one of the ABCs in `io`?
-- How should exceptions raised by `_close` be handled?
-- Should calling `rollback` while closed be a no-op?
-- Use `shutil.move` instead of `os.rename` in order to handle cross-filesystem
-  moves?  (But then strange things will happen when moving to a directory)
-- Add a `commit` method that overwrites the input file with the output file's
-  current contents but leaves the instance open afterwards?
-- Give `InPlace` a decent `__repr__`s
-- Make the context manager reusable
-- Add a `seekable()` method that returns `False`?
diff --git a/setup.cfg b/setup.cfg
index d25913f..ad73b78 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -25,7 +25,6 @@ keywords =
 classifiers =
     Programming Language :: Python :: 3 :: Only
     Programming Language :: Python :: 3
-    Programming Language :: Python :: 3.7
     Programming Language :: Python :: 3.8
     Programming Language :: Python :: 3.9
     Programming Language :: Python :: 3.10
@@ -37,15 +36,34 @@ classifiers =
     Intended Audience :: Developers
     Topic :: System :: Filesystems
     Topic :: Text Processing :: Filters
+    Typing :: Typed
 
 project_urls =
     Source Code = https://github.com/jwodder/inplace
     Bug Tracker = https://github.com/jwodder/inplace/issues
 
 [options]
-py_modules = in_place
+packages = find_namespace:
 package_dir =
     =src
-python_requires = >=3.7
-install_requires =
-    jaraco.windows; sys_platform == "win32" and python_version < "3.8" and python_implementation != "PyPy"
+include_package_data = True
+python_requires = >=3.8
+
+[options.packages.find]
+where = src
+
+[mypy]
+allow_incomplete_defs = False
+allow_untyped_defs = False
+ignore_missing_imports = False
+# <https://github.com/python/mypy/issues/7773>:
+no_implicit_optional = True
+implicit_reexport = False
+local_partial_types = True
+pretty = True
+show_error_codes = True
+show_traceback = True
+strict_equality = True
+warn_redundant_casts = True
+warn_return_any = True
+warn_unreachable = True
diff --git a/src/in_place.py b/src/in_place.py
deleted file mode 100644
index 9f3747d..0000000
--- a/src/in_place.py
+++ /dev/null
@@ -1,403 +0,0 @@
-"""
-In-place file processing
-
-The ``in_place`` module provides an ``InPlace`` class for reading & writing a
-file "in-place": data that you write ends up at the same filepath that you read
-from, and ``in_place`` takes care of all the necessary mucking about with
-temporary files for you.
-
-Visit <https://github.com/jwodder/inplace> for more information.
-"""
-
-__version__ = "0.6.0.dev1"
-__author__ = "John Thorvald Wodder II"
-__author_email__ = "inplace@varonathe.org"
-__license__ = "MIT"
-__url__ = "https://github.com/jwodder/inplace"
-
-import os
-import os.path
-import platform
-import shutil
-import sys
-import tempfile
-from warnings import warn
-
-if (
-    platform.system() == "Windows"
-    and platform.python_implementation() != "PyPy"
-    and sys.version_info[:2] < (3, 8)
-):
-    from jaraco.windows.filesystem import get_final_path as realpath
-else:
-    from os.path import realpath
-
-__all__ = ["InPlace", "InPlaceBytes", "InPlaceText"]
-
-
-class InPlace:
-    """
-    A class for reading from & writing to a file "in-place" (with data that you
-    write ending up at the same filepath that you read from) that takes care of
-    all the necessary mucking about with temporary files.
-
-    :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
-        data will be read & written as `bytes` objects.  If ``mode`` is ``'t'``
-        or unset, the file will be opened in text mode, and data will be read &
-        written as `str` objects.
-
-    :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 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()`
-        method or use the instance as a context manager in order to open it.
-        If `False` (the default), the instance will be automatically opened as
-        soon as it is constructed.
-
-    :param bool move_first: If `True`, the original (input) file will be moved
-        to a temporary location before opening, and the output file will be
-        created in its place.  If `False` (the default), the output file will
-        be created at a temporary location, and neither file will be moved or
-        deleted until :meth:`close()` is called.
-
-    :param kwargs: Additional keyword arguments to pass to `open()`
-    """
-
-    UNOPENED = 0
-    OPEN = 1
-    CLOSED = 2
-
-    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 = os.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, self.name)
-        #: ``filepath`` with symbolic links resolved.  This is set just before
-        #: opening the file.
-        self.realpath = None
-        if backup is not None:
-            if backup_ext is not 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, os.fsdecode(backup))
-        elif backup_ext is not None:
-            if not backup_ext:
-                raise ValueError("backup_ext cannot be empty")
-            self.backuppath = self.filepath + os.fsdecode(backup_ext)
-        else:
-            self.backuppath = None
-        #: Whether to move the input file before opening and create the output
-        #: file in its place instead of moving the files after closing
-        self.move_first = move_first
-        #: Additional arguments to pass to `open`
-        self.kwargs = kwargs
-        #: The input filehandle from which data is read; only non-`None` while
-        #: the instance is open
-        self.input = None
-        #: The output filehandle to which data is written; only non-`None`
-        #: while the instance is open
-        self.output = None
-        #: The absolute path to the temporary file; only non-`None` while the
-        #: instance is open
-        self._tmppath = None
-        #: Are we not open yet, open, or closed?
-        self._state = self.UNOPENED
-        if not delay_open:
-            self.open()
-
-    def __enter__(self):
-        if self._state < self.OPEN:
-            self.open()
-        return self
-
-    def __exit__(self, exc_type, _exc_value, _traceback):
-        if self._state == self.OPEN:
-            if exc_type is not None:
-                self.rollback()
-            else:
-                self.close()
-        return False
-
-    def _mktemp(self, filepath):
-        """
-        Create an empty temporary file in the same directory as ``filepath``
-        and return the path to the new file
-        """
-        fd, tmppath = tempfile.mkstemp(
-            dir=os.path.dirname(filepath),
-            prefix="._in_place-",
-        )
-        os.close(fd)
-        return tmppath
-
-    def open(self):  # noqa: A003
-        """
-        Open the file :attr:`name` for reading and open a temporary file for
-        writing.  If :attr:`move_first` is `True`, :attr:`name` will be moved
-        to a temporary location before opening.
-
-        If ``delay_open=True`` was passed to the instance's constructor, this
-        method must be called (either explicitly or else implicitly by using
-        the instance as a context manager) before the instance can be used for
-        reading or writing.  If ``delay_open`` was `False` (the default), this
-        method is called automatically by the constructor, and the user should
-        not call it again.
-
-        :raises ValueError: if called more than once on the same instance
-        """
-        if self._state < self.OPEN:
-            self._state = self.OPEN
-            self.realpath = realpath(self.filepath)
-            try:
-                if self.move_first:
-                    if self.backuppath is not None:
-                        self._tmppath = self._mktemp(self.backuppath)
-                    else:
-                        self._tmppath = self._mktemp(self.realpath)
-                    try:
-                        os.replace(self.realpath, self._tmppath)
-                    except OSError:
-                        try_unlink(self._tmppath)
-                        self._tmppath = None
-                        raise
-                    self.output = self.open_write(self.realpath)
-                    copystats(self._tmppath, self.realpath)
-                    input_path = self._tmppath
-                else:
-                    self._tmppath = self._mktemp(self.realpath)
-                    self.output = self.open_write(self._tmppath)
-                    copystats(self.realpath, self._tmppath)
-                    input_path = self.realpath
-                self.input = self.open_read(input_path)
-            except Exception:
-                self.rollback()
-                raise
-        else:
-            raise ValueError("open() called twice on same filehandle")
-
-    def open_read(self, path):
-        """
-        Open the file at ``path`` for reading and return a file-like object.
-        Use :attr:`mode` to determine whether to open in binary or text mode.
-        """
-        if not self.mode or self.mode == "t":
-            return open(path, "r", **self.kwargs)
-        elif self.mode == "b":
-            return open(path, "rb", **self.kwargs)
-        else:
-            raise ValueError(f"{self.mode!r}: invalid mode")
-
-    def open_write(self, path):
-        """
-        Open the file at ``path`` for writing and return a file-like object.
-        Use :attr:`mode` to determine whether to open in binary or text mode.
-        """
-        if not self.mode or self.mode == "t":
-            return open(path, "w", **self.kwargs)
-        elif self.mode == "b":
-            return open(path, "wb", **self.kwargs)
-        else:
-            raise ValueError(f"{self.mode!r}: invalid mode")
-
-    def _close(self):
-        """
-        Close filehandles (if they aren't closed already) and set them to
-        `None`
-        """
-        if self.input is not None:
-            self.input.close()
-            self.input = None
-        if self.output is not None:
-            self.output.close()
-            self.output = None
-
-    def close(self):
-        """
-        Close filehandles and move affected files to their final destinations.
-        If called after the filehandle has already been closed (with either
-        this method or :meth:`rollback`), :meth:`close` does nothing.
-
-        :return: `None`
-        :raises ValueError: if called before opening the filehandle
-        """
-        if self._state == self.UNOPENED:
-            raise ValueError("Cannot close unopened file")
-        elif self._state == self.OPEN:
-            self._state = self.CLOSED
-            self._close()
-            try:
-                if self.move_first:
-                    if self.backuppath is not None:
-                        try:
-                            os.replace(self._tmppath, self.backuppath)
-                        except IOError:
-                            os.replace(self._tmppath, self.realpath)
-                            self._tmppath = None
-                            raise
-                else:
-                    if self.backuppath is not None:
-                        os.replace(self.realpath, self.backuppath)
-                    os.replace(self._tmppath, self.realpath)
-            finally:
-                if self._tmppath is not None:
-                    try_unlink(self._tmppath)
-                    self._tmppath = None
-        # elif self._state == self.CLOSED: pass
-
-    def rollback(self):
-        """
-        Close filehandles and remove/rename temporary files so that things look
-        like they did before the `InPlace` instance was opened
-
-        :return: `None`
-        :raises ValueError: if called while the `InPlace` instance is not open
-        """
-        if self._state == self.UNOPENED:
-            raise ValueError("Cannot close unopened file")
-        elif self._state == self.OPEN:
-            self._state = self.CLOSED
-            self._close()
-            if self._tmppath is not None:  # In case of error while opening
-                if self.move_first:
-                    os.replace(self._tmppath, self.realpath)
-                else:
-                    try_unlink(self._tmppath)
-                self._tmppath = None
-        else:
-            assert self._state == self.CLOSED
-            raise ValueError("Cannot rollback closed file")
-
-    @property
-    def closed(self):
-        """
-        `True` iff the filehandle is not currently open.  Note that, if the
-        filehandle was initialized with ``delay_open=True``, `closed` will be
-        `True` until :meth:`open()` is called.
-        """
-        return self._state != self.OPEN
-
-    def read(self, size=-1):
-        if self._state != self.OPEN:
-            raise ValueError("Filehandle is not currently open")
-        return self.input.read(size)
-
-    def readline(self, size=-1):
-        if self._state != self.OPEN:
-            raise ValueError("Filehandle is not currently open")
-        return self.input.readline(size)
-
-    def readlines(self, sizehint=-1):
-        if self._state != self.OPEN:
-            raise ValueError("Filehandle is not currently open")
-        return self.input.readlines(sizehint)
-
-    def readinto(self, b):
-        if self._state != self.OPEN:
-            raise ValueError("Filehandle is not currently open")
-        return self.input.readinto(b)
-
-    def readall(self):
-        if self._state != self.OPEN:
-            raise ValueError("Filehandle is not currently open")
-        return self.input.readall()
-
-    def write(self, s):
-        if self._state != self.OPEN:
-            raise ValueError("Filehandle is not currently open")
-        self.output.write(s)
-
-    def writelines(self, seq):
-        if self._state != self.OPEN:
-            raise ValueError("Filehandle is not currently open")
-        self.output.writelines(seq)
-
-    def __iter__(self):
-        if self._state != self.OPEN:
-            raise ValueError("Filehandle is not currently open")
-        return iter(self.input)
-
-    def flush(self):
-        if self._state != self.OPEN:
-            raise ValueError("Filehandle is not currently open")
-        self.output.flush()
-
-
-class InPlaceBytes(InPlace):
-    """Deprecated.  Please use `InPlace` with ``mode='b'`` instead."""
-
-    def __init__(self, name, **kwargs):
-        warn(
-            "InPlaceBytes is deprecated."
-            '  Please use `InPlace(name, mode="b")` instead.',
-            DeprecationWarning,
-        )
-        super(InPlaceBytes, self).__init__(name, mode="b", **kwargs)
-
-
-class InPlaceText(InPlace):
-    """Deprecated.  Please use `InPlace` with ``mode='t'`` instead."""
-
-    def __init__(self, name, **kwargs):
-        warn(
-            "InPlaceText is deprecated."
-            '  Please use `InPlace(name, mode="t")` instead.',
-            DeprecationWarning,
-        )
-        super(InPlaceText, self).__init__(name, mode="t", **kwargs)
-
-
-def copystats(from_file, to_file):
-    """
-    Copy stat info from ``from_file`` to ``to_file`` using `shutil.copystat`.
-    If possible, also copy the user and/or group ownership information.
-    """
-    shutil.copystat(from_file, to_file)
-    if hasattr(os, "chown"):
-        st = os.stat(from_file)
-        # Based on GNU sed's behavior:
-        try:
-            os.chown(to_file, st.st_uid, st.st_gid)
-        except IOError:
-            try:
-                os.chown(to_file, -1, st.st_gid)
-            except IOError:
-                pass
-
-
-def try_unlink(path):
-    """
-    Try to delete the file at ``path``.  If the file doesn't exist, do nothing;
-    any other errors are propagated to the caller.
-    """
-    try:
-        os.unlink(path)
-    except FileNotFoundError:
-        pass
diff --git a/src/in_place/__init__.py b/src/in_place/__init__.py
new file mode 100644
index 0000000..4e7a35d
--- /dev/null
+++ b/src/in_place/__init__.py
@@ -0,0 +1,315 @@
+"""
+In-place file processing
+
+The ``in_place`` module provides an ``InPlace`` class for reading & writing a
+file "in-place": data that you write ends up at the same filepath that you read
+from, and ``in_place`` takes care of all the necessary mucking about with
+temporary files for you.
+
+Visit <https://github.com/jwodder/inplace> for more information.
+"""
+
+from __future__ import annotations
+from collections.abc import Iterable
+import os
+import os.path
+import shutil
+import tempfile
+from types import TracebackType
+from typing import IO, TYPE_CHECKING, Any, AnyStr, Literal, Union, overload
+
+if TYPE_CHECKING:
+    from typing_extensions import Buffer
+
+__version__ = "1.0.0.dev1"
+__author__ = "John Thorvald Wodder II"
+__author_email__ = "inplace@varonathe.org"
+__license__ = "MIT"
+__url__ = "https://github.com/jwodder/inplace"
+
+__all__ = ["InPlace"]
+
+AnyPath = Union[str, bytes, "os.PathLike[str]", "os.PathLike[bytes]"]
+
+
+class InPlace(IO[AnyStr]):
+    """
+    A class for reading from & writing to a file "in-place" (with data that you
+    write ending up at the same filepath that you read from) that takes care of
+    all the necessary mucking about with temporary files.
+
+    :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
+        data will be read & written as `bytes` objects.  If ``mode`` is ``"t"``
+        or unset, the file will be opened in text mode, and data will be read &
+        written as `str` objects.
+
+    :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.  Cannot be empty.
+    :type backup: path-like
+
+    :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 kwargs: Additional keyword arguments to pass to `open()`
+    """
+
+    @overload
+    def __init__(
+        self: InPlace[str],
+        name: AnyPath,
+        mode: Literal["t", None] = None,
+        backup: AnyPath | None = None,
+        backup_ext: AnyPath | None = None,
+        **kwargs: Any,
+    ) -> None:
+        ...
+
+    @overload
+    def __init__(
+        self: InPlace[bytes],
+        name: AnyPath,
+        mode: Literal["b"],
+        backup: AnyPath | None = None,
+        backup_ext: AnyPath | None = None,
+        **kwargs: Any,
+    ) -> None:
+        ...
+
+    def __init__(
+        self,
+        name: AnyPath,
+        mode: Literal["t", "b", None] = None,
+        backup: AnyPath | None = None,
+        backup_ext: AnyPath | None = None,
+        **kwargs: Any,
+    ) -> None:
+        cwd = os.getcwd()
+        #: The path to the file to edit in-place
+        self._name = os.fsdecode(name)
+        #: The absolute path of the file to edit in-place, with symbolic links
+        #: resolved
+        self._path = os.path.realpath(os.path.join(cwd, self._name))
+        #: The absolute path of the backup file (if any) that the original
+        #: contents of ``path`` will be moved to after editing
+        self._backuppath: str | None
+        if backup is not None:
+            if backup_ext is not None:
+                raise ValueError("backup and backup_ext are mutually exclusive")
+            b = os.fsdecode(backup)
+            if not b:
+                raise ValueError("backup cannot be empty")
+            self._backuppath = os.path.join(cwd, b)
+        elif backup_ext is not None:
+            be = os.fsdecode(backup_ext)
+            if not be:
+                raise ValueError("backup_ext cannot be empty")
+            self._backuppath = self._path + be
+        else:
+            self._backuppath = None
+        if mode not in (None, "t", "b"):
+            raise ValueError(f"{mode!r}: invalid mode")
+        #: `True` iff the filehandle is closed
+        self._closed = False
+        #: The absolute path to the temporary file
+        self._tmppath = self._mktemp(self._path)
+        try:
+            #: The output filehandle to which data is written
+            self.output: IO[AnyStr]
+            if mode is None or mode == "t":
+                self.output = open(self._tmppath, "w", **kwargs)
+            else:
+                self.output = open(self._tmppath, "wb", **kwargs)
+        except Exception:
+            try_unlink(self._tmppath)
+            raise
+        try:
+            copystats(self._path, self._tmppath)
+        except Exception:
+            self.output.close()
+            try_unlink(self._tmppath)
+            raise
+        try:
+            #: The input filehandle from which data is read
+            self.input: IO[AnyStr]
+            if mode is None or mode == "t":
+                self.input = open(self._path, "r", **kwargs)
+            else:
+                self.input = open(self._path, "rb", **kwargs)
+        except Exception:
+            self.output.close()
+            try_unlink(self._tmppath)
+            raise
+
+    def __enter__(self) -> InPlace[AnyStr]:
+        return self
+
+    def __exit__(
+        self,
+        exc_type: type[BaseException] | None,
+        _exc_val: BaseException | None,
+        _exc_tb: TracebackType | None,
+    ) -> None:
+        if not self.closed:
+            if exc_type is not None:
+                self.rollback()
+            else:
+                self.close()
+
+    def _mktemp(self, filepath: str) -> str:
+        """
+        Create an empty temporary file in the same directory as ``filepath``
+        and return the path to the new file
+        """
+        fd, tmppath = tempfile.mkstemp(
+            dir=os.path.dirname(filepath),
+            prefix="._in_place-",
+        )
+        os.close(fd)
+        return tmppath
+
+    def _close(self) -> None:
+        """Close filehandles and set them to `None`"""
+        self._closed = True
+        self.input.close()
+        self.output.close()
+
+    def close(self) -> None:
+        """
+        Close filehandles and move affected files to their final destinations.
+        If called after the filehandle has already been closed (with either
+        this method or :meth:`rollback`), :meth:`close` does nothing.
+
+        :return: `None`
+        """
+        if not self.closed:
+            self._close()
+            try:
+                if self._backuppath is not None:
+                    os.replace(self._path, self._backuppath)
+                os.replace(self._tmppath, self._path)
+            finally:
+                try_unlink(self._tmppath)
+
+    def rollback(self) -> None:
+        """
+        Close filehandles and remove/rename temporary files so that things look
+        like they did before the `InPlace` instance was opened
+
+        :return: `None`
+        :raises ValueError: if called after the `InPlace` instance is closed
+        """
+        if not self.closed:
+            self._close()
+            try_unlink(self._tmppath)
+        else:
+            raise ValueError("Cannot rollback closed file")
+
+    @property
+    def name(self) -> str:
+        return self._name
+
+    @property
+    def closed(self) -> bool:
+        return self._closed
+
+    def read(self, size: int = -1) -> AnyStr:
+        return self.input.read(size)
+
+    def read1(self: InPlace[bytes], size: int = -1) -> bytes:
+        bs = self.input.read1(size)  # type: ignore[attr-defined]
+        assert isinstance(bs, bytes)
+        return bs
+
+    def readline(self, size: int = -1) -> AnyStr:
+        return self.input.readline(size)
+
+    def readlines(self, sizehint: int = -1) -> list[AnyStr]:
+        return self.input.readlines(sizehint)
+
+    def readinto(self: InPlace[bytes], b: Buffer) -> int:
+        r = self.input.readinto(b)  # type: ignore[attr-defined]
+        assert isinstance(r, int)
+        return r
+
+    def readinto1(self: InPlace[bytes], b: Buffer) -> int:
+        r = self.input.readinto1(b)  # type: ignore[attr-defined]
+        assert isinstance(r, int)
+        return r
+
+    def write(self, s: AnyStr) -> int:
+        return self.output.write(s)
+
+    def writelines(self, seq: Iterable[AnyStr]) -> None:
+        self.output.writelines(seq)
+
+    def __iter__(self) -> InPlace[AnyStr]:
+        return self
+
+    def __next__(self) -> AnyStr:
+        return next(self.input)
+
+    def flush(self) -> None:
+        self.output.flush()
+
+    def readable(self) -> bool:
+        return True
+
+    def writable(self) -> bool:
+        return True
+
+    def seekable(self) -> bool:
+        return False
+
+    def seek(self, _offset: int, _whence: int = 0) -> int:
+        raise OSError(f"{type(self).__name__} does not support seek()")
+
+    def tell(self) -> int:
+        raise OSError(f"{type(self).__name__} does not support tell()")
+
+    def truncate(self, _size: int | None = None) -> int:
+        raise OSError(f"{type(self).__name__} does not support truncate()")
+
+    def fileno(self) -> int:
+        raise OSError(f"{type(self).__name__} does not support fileno()")
+
+    def isatty(self) -> bool:
+        return False
+
+
+def copystats(from_file: str, to_file: str) -> None:
+    """
+    Copy stat info from ``from_file`` to ``to_file`` using `shutil.copystat`.
+    If possible, also copy the user and/or group ownership information.
+    """
+    shutil.copystat(from_file, to_file)
+    if hasattr(os, "chown"):
+        st = os.stat(from_file)
+        # Based on GNU sed's behavior:
+        try:
+            os.chown(to_file, st.st_uid, st.st_gid)
+        except IOError:
+            try:
+                os.chown(to_file, -1, st.st_gid)
+            except IOError:
+                pass
+
+
+def try_unlink(path: str) -> None:
+    """
+    Try to delete the file at ``path``.  If the file doesn't exist, do nothing;
+    any other errors are propagated to the caller.
+    """
+    try:
+        os.unlink(path)
+    except FileNotFoundError:
+        pass
diff --git a/src/in_place/py.typed b/src/in_place/py.typed
new file mode 100644
index 0000000..e69de29
diff --git a/tox.ini b/tox.ini
index f490e96..d75acec 100644
--- a/tox.ini
+++ b/tox.ini
@@ -1,5 +1,5 @@
 [tox]
-envlist = lint,py37,py38,py39,py310,py311,py312,pypy3
+envlist = lint,typing,py38,py39,py310,py311,py312,pypy3
 skip_missing_interpreters = True
 isolated_build = True
 minversion = 3.3.0
@@ -22,6 +22,13 @@ deps =
 commands =
     flake8 src test
 
+[testenv:typing]
+deps =
+    mypy
+    {[testenv]deps}
+commands =
+    mypy src test
+
 [pytest]
 addopts = --cov=in_place --no-cov-on-fail
 filterwarnings = error
@@ -38,6 +45,10 @@ source =
 [coverage:report]
 precision = 2
 show_missing = True
+exclude_lines =
+    pragma: no cover
+    if TYPE_CHECKING:
+    \.\.\.
 
 [flake8]
 doctests = True
@@ -49,9 +60,6 @@ unused-arguments-ignore-stub-functions = True
 select = A,B,B902,B950,C,E,E242,F,U100,W
 ignore = B005,E203,E262,E266,E501,W503
 
-per-file-ignores =
-    test/*.py:B011
-
 [isort]
 atomic = True
 force_sort_within_sections = True


Test patch:
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index a850b3d..1763e61 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -23,21 +23,22 @@ jobs:
           - ubuntu-latest
           - windows-latest
         python-version:
-          - '3.7'
           - '3.8'
           - '3.9'
           - '3.10'
           - '3.11'
           - '3.12'
-          - 'pypy-3.7'
           - 'pypy-3.8'
           - 'pypy-3.9'
           - 'pypy-3.10'
         toxenv: [py]
         include:
-          - python-version: '3.7'
+          - python-version: '3.8'
             toxenv: lint
             os: ubuntu-latest
+          - python-version: '3.8'
+            toxenv: typing
+            os: ubuntu-latest
     steps:
       - name: Check out repository
         uses: actions/checkout@v4
diff --git a/test/test_bytes.py b/test/test_bytes.py
deleted file mode 100644
index 8c455c5..0000000
--- a/test/test_bytes.py
+++ /dev/null
@@ -1,31 +0,0 @@
-from __future__ import annotations
-from pathlib import Path
-import pytest
-from in_place import InPlace
-from test_in_place_util import NLB, UNICODE, pylistdir
-
-
-def test_bytes_iconv_nobackup(tmp_path: Path) -> None:
-    assert pylistdir(tmp_path) == []
-    p = tmp_path / "file.txt"
-    p.write_text(UNICODE, encoding="utf-8")
-    with InPlace(p, "b") as fp:
-        txt = fp.read()
-        assert isinstance(txt, bytes)
-        assert txt == b"\xc3\xa5\xc3\xa9\xc3\xae\xc3\xb8\xc3\xbc" + NLB
-        fp.write(txt.decode("utf-8").encode("latin-1"))
-    assert pylistdir(tmp_path) == ["file.txt"]
-    assert p.read_bytes() == b"\xE5\xE9\xEE\xF8\xFC" + NLB
-
-
-def test_bytes_useless_after_close(tmp_path: Path) -> None:
-    assert pylistdir(tmp_path) == []
-    p = tmp_path / "file.txt"
-    p.write_text(UNICODE, encoding="utf-8")
-    with InPlace(p, "b", backup_ext="~") as fp:
-        assert not fp.closed
-    assert fp.closed
-    with pytest.raises(ValueError):
-        fp.readall()
-    with pytest.raises(ValueError):
-        fp.readinto(bytearray(42))
diff --git a/test/test_deprecated.py b/test/test_deprecated.py
deleted file mode 100644
index 9875fae..0000000
--- a/test/test_deprecated.py
+++ /dev/null
@@ -1,36 +0,0 @@
-from __future__ import annotations
-from pathlib import Path
-from unicodedata import normalize
-import pytest
-from in_place import InPlaceBytes, InPlaceText
-from test_in_place_util import NLB, UNICODE, pylistdir
-
-
-def test_deprecated_bytes_iconv_nobackup(tmp_path: Path) -> None:
-    assert pylistdir(tmp_path) == []
-    p = tmp_path / "file.txt"
-    p.write_text(UNICODE, encoding="utf-8")
-    with pytest.warns(DeprecationWarning, match="InPlaceBytes is deprecated"):
-        fp = InPlaceBytes(p)
-    with fp:
-        txt = fp.read()
-        assert isinstance(txt, bytes)
-        assert txt == b"\xc3\xa5\xc3\xa9\xc3\xae\xc3\xb8\xc3\xbc" + NLB
-        fp.write(txt.decode("utf-8").encode("latin-1"))
-    assert pylistdir(tmp_path) == ["file.txt"]
-    assert p.read_bytes() == b"\xE5\xE9\xEE\xF8\xFC" + NLB
-
-
-def test_deprecated_utf8_nobackup(tmp_path: Path) -> None:
-    assert pylistdir(tmp_path) == []
-    p = tmp_path / "file.txt"
-    p.write_text(UNICODE, encoding="utf-8")
-    with pytest.warns(DeprecationWarning, match="InPlaceText is deprecated"):
-        fp = InPlaceText(p, encoding="utf-8")
-    with fp:
-        txt = fp.read()
-        assert isinstance(txt, str)
-        assert txt == UNICODE
-        fp.write(normalize("NFD", txt))
-    assert pylistdir(tmp_path) == ["file.txt"]
-    assert p.read_text(encoding="utf-8") == "a\u030Ae\u0301i\u0302\xF8u\u0308\n"
diff --git a/test/test_encoding.py b/test/test_encoding.py
index 01819b7..36a8c6b 100644
--- a/test/test_encoding.py
+++ b/test/test_encoding.py
@@ -51,3 +51,16 @@ def test_latin1_as_utf8_replace(tmp_path: Path) -> None:
         fp.write(txt)
     assert pylistdir(tmp_path) == ["file.txt"]
     assert p.read_text(encoding="utf-8") == "\uFFFD\uFFFD\uFFFD\uFFFD\uFFFD\n"
+
+
+def test_bytes_iconv_nobackup(tmp_path: Path) -> None:
+    assert pylistdir(tmp_path) == []
+    p = tmp_path / "file.txt"
+    p.write_text(UNICODE, encoding="utf-8")
+    with InPlace(p, "b") as fp:
+        txt = fp.read()
+        assert isinstance(txt, bytes)
+        assert txt == b"\xc3\xa5\xc3\xa9\xc3\xae\xc3\xb8\xc3\xbc" + NLB
+        fp.write(txt.decode("utf-8").encode("latin-1"))
+    assert pylistdir(tmp_path) == ["file.txt"]
+    assert p.read_bytes() == b"\xE5\xE9\xEE\xF8\xFC" + NLB
diff --git a/test/test_errors.py b/test/test_errors.py
index 84bb1b0..30574ae 100644
--- a/test/test_errors.py
+++ b/test/test_errors.py
@@ -1,63 +1,266 @@
 from __future__ import annotations
 from pathlib import Path
+import platform
 import pytest
 from in_place import InPlace
-from test_in_place_util import TEXT, pylistdir
+from test_in_place_util import TEXT, UNICODE, pylistdir
+
+
+def test_backup_ext_and_backup(tmp_path: Path) -> None:
+    assert pylistdir(tmp_path) == []
+    p = tmp_path / "file.txt"
+    p.write_text(TEXT)
+    bkp = tmp_path / "backup.txt"
+    with pytest.raises(ValueError):
+        InPlace(p, backup=bkp, backup_ext="~")
+    assert pylistdir(tmp_path) == ["file.txt"]
+    assert p.read_text() == TEXT
+
+
+def test_empty_backup_ext(tmp_path: Path) -> None:
+    assert pylistdir(tmp_path) == []
+    p = tmp_path / "file.txt"
+    p.write_text(TEXT)
+    with pytest.raises(ValueError):
+        InPlace(p, backup_ext="")
+    assert pylistdir(tmp_path) == ["file.txt"]
+    assert p.read_text() == TEXT
+
+
+def test_empty_backup(tmp_path: Path) -> None:
+    assert pylistdir(tmp_path) == []
+    p = tmp_path / "file.txt"
+    p.write_text(TEXT)
+    with pytest.raises(ValueError):
+        InPlace(p, backup="")
+    assert pylistdir(tmp_path) == ["file.txt"]
+    assert p.read_text() == TEXT
 
 
-@pytest.mark.parametrize("move_first", [True, False])
 @pytest.mark.parametrize("backup", [None, "backup.txt"])
-def test_bad_mode(tmp_path: Path, move_first: bool, backup: str | None) -> None:
+def test_bad_mode(tmp_path: Path, backup: str | None) -> None:
     assert pylistdir(tmp_path) == []
     p = tmp_path / "file.txt"
     p.write_text(TEXT)
     backup_path = tmp_path / backup if backup is not None else None
     with pytest.raises(ValueError, match="invalid mode"):
-        InPlace(p, mode="q", move_first=move_first, backup=backup_path)
+        InPlace(p, mode="q", backup=backup_path)  # type: ignore[call-overload]
     assert pylistdir(tmp_path) == ["file.txt"]
     assert p.read_text() == TEXT
 
 
-@pytest.mark.parametrize("move_first", [True, False])
-@pytest.mark.parametrize("backup", [None, "backup.txt"])
-def test_bad_mode_delay_open_with(
-    tmp_path: Path, move_first: bool, backup: str | None
-) -> None:
+def test_early_close_and_write_nobackup(tmp_path: Path) -> None:
     assert pylistdir(tmp_path) == []
     p = tmp_path / "file.txt"
     p.write_text(TEXT)
-    fp = InPlace(
-        p,
-        mode="q",
-        delay_open=True,
-        move_first=move_first,
-        backup=tmp_path / backup if backup is not None else None,
-    )
-    with pytest.raises(ValueError, match="invalid mode"):
-        with fp:
-            assert False
-    assert fp.closed
+    with InPlace(p) as fp:
+        for line in fp:
+            fp.write(line.swapcase())
+        fp.close()
+        with pytest.raises(ValueError):
+            fp.write("And another thing...\n")
+    assert pylistdir(tmp_path) == ["file.txt"]
+    assert p.read_text() == TEXT.swapcase()
+
+
+def test_early_close_and_write_backup(tmp_path: Path) -> None:
+    assert pylistdir(tmp_path) == []
+    p = tmp_path / "file.txt"
+    p.write_text(TEXT)
+    bkp = tmp_path / "backup.txt"
+    with InPlace(p, backup=bkp) as fp:
+        for line in fp:
+            fp.write(line.swapcase())
+        fp.close()
+        with pytest.raises(ValueError):
+            fp.write("And another thing...\n")
+    assert pylistdir(tmp_path) == ["backup.txt", "file.txt"]
+    assert bkp.read_text() == TEXT
+    assert p.read_text() == TEXT.swapcase()
+
+
+def test_rollback_and_write_nobackup(tmp_path: Path) -> None:
+    assert pylistdir(tmp_path) == []
+    p = tmp_path / "file.txt"
+    p.write_text(TEXT)
+    with InPlace(p) as fp:
+        for line in fp:
+            fp.write(line.swapcase())
+        fp.rollback()
+        with pytest.raises(ValueError):
+            fp.write("And another thing...\n")
     assert pylistdir(tmp_path) == ["file.txt"]
     assert p.read_text() == TEXT
 
 
-@pytest.mark.parametrize("move_first", [True, False])
-@pytest.mark.parametrize("backup", [None, "backup.txt"])
-def test_bad_mode_delay_open_open(
-    tmp_path: Path, move_first: bool, backup: str | None
-) -> None:
+def test_rollback_and_write_backup(tmp_path: Path) -> None:
     assert pylistdir(tmp_path) == []
     p = tmp_path / "file.txt"
     p.write_text(TEXT)
-    fp = InPlace(
-        p,
-        mode="q",
-        delay_open=True,
-        move_first=move_first,
-        backup=tmp_path / backup if backup is not None else None,
-    )
-    with pytest.raises(ValueError, match="invalid mode"):
-        fp.open()
-    assert fp.closed
+    bkp = tmp_path / "backup.txt"
+    with InPlace(p, backup=bkp) as fp:
+        for line in fp:
+            fp.write(line.swapcase())
+        fp.rollback()
+        with pytest.raises(ValueError):
+            fp.write("And another thing...\n")
     assert pylistdir(tmp_path) == ["file.txt"]
     assert p.read_text() == TEXT
+
+
+def test_rollback_too_late(tmp_path: Path) -> None:
+    assert pylistdir(tmp_path) == []
+    p = tmp_path / "file.txt"
+    p.write_text(TEXT)
+    with InPlace(p, backup_ext="~") as fp:
+        for line in fp:
+            fp.write(line.swapcase())
+    with pytest.raises(ValueError):
+        fp.rollback()
+    assert pylistdir(tmp_path) == ["file.txt", "file.txt~"]
+    assert p.with_suffix(".txt~").read_text() == TEXT
+    assert p.read_text() == TEXT.swapcase()
+
+
+def test_backup_dirpath(tmp_path: Path) -> None:
+    """
+    Assert that using a path to a directory as the backup path raises an error
+    when closing
+    """
+    assert pylistdir(tmp_path) == []
+    p = tmp_path / "file.txt"
+    p.write_text(TEXT)
+    not_a_file = tmp_path / "not-a-file"
+    not_a_file.mkdir()
+    assert pylistdir(not_a_file) == []
+    fp = InPlace(p, backup=not_a_file)
+    fp.write("This will be discarded.\n")
+    with pytest.raises(OSError):
+        fp.close()
+    assert pylistdir(tmp_path) == ["file.txt", "not-a-file"]
+    assert p.read_text() == TEXT
+    assert pylistdir(not_a_file) == []
+
+
+def test_backup_nosuchdir(tmp_path: Path) -> None:
+    """
+    Assert that using a path to a file in a nonexistent directory as the backup
+    path raises an error when closing
+    """
+    assert pylistdir(tmp_path) == []
+    p = tmp_path / "file.txt"
+    p.write_text(TEXT)
+    fp = InPlace(p, backup=tmp_path / "nonexistent" / "backup.txt")
+    fp.write("This will be discarded.\n")
+    with pytest.raises(OSError):
+        fp.close()
+    assert pylistdir(tmp_path) == ["file.txt"]
+    assert p.read_text() == TEXT
+
+
+def test_nonexistent(tmp_path: Path) -> None:
+    assert pylistdir(tmp_path) == []
+    p = tmp_path / "file.txt"
+    with pytest.raises(FileNotFoundError):
+        InPlace(p)
+    assert pylistdir(tmp_path) == []
+
+
+def test_nonexistent_backup_ext(tmp_path: Path) -> None:
+    assert pylistdir(tmp_path) == []
+    p = tmp_path / "file.txt"
+    with pytest.raises(FileNotFoundError):
+        InPlace(p, backup_ext="~")
+    assert pylistdir(tmp_path) == []
+
+
+def test_nonexistent_backup(tmp_path: Path) -> None:
+    assert pylistdir(tmp_path) == []
+    p = tmp_path / "file.txt"
+    with pytest.raises(FileNotFoundError):
+        InPlace(p, backup=tmp_path / "backup.txt")
+    assert pylistdir(tmp_path) == []
+
+
+@pytest.mark.skipif(
+    platform.system() == "Windows", reason="Windows barely has file modes"
+)
+def test_unwritable_dir(tmp_path: Path) -> None:
+    p = tmp_path / "file.txt"
+    p.write_text(TEXT)
+    tmp_path.chmod(0o555)
+    with pytest.raises(OSError):
+        InPlace(p)
+    assert pylistdir(tmp_path) == ["file.txt"]
+    assert p.read_text() == TEXT
+
+
+@pytest.mark.skipif(
+    platform.system() == "Windows", reason="Windows barely has file modes"
+)
+def test_unreadable_file(tmp_path: Path) -> None:
+    p = tmp_path / "file.txt"
+    p.touch()
+    p.chmod(0o000)
+    with pytest.raises(OSError):
+        InPlace(p)
+    assert pylistdir(tmp_path) == ["file.txt"]
+
+
+def test_useless_after_close(tmp_path: Path) -> None:
+    assert pylistdir(tmp_path) == []
+    p = tmp_path / "file.txt"
+    p.write_text(TEXT)
+    with InPlace(p, backup_ext="~") as fp:
+        pass
+    with pytest.raises(ValueError):
+        fp.flush()
+    with pytest.raises(ValueError):
+        next(fp)
+    with pytest.raises(ValueError):
+        fp.read()
+    with pytest.raises(ValueError):
+        fp.readline()
+    with pytest.raises(ValueError):
+        fp.readlines()
+    with pytest.raises(ValueError):
+        fp.write("")
+    with pytest.raises(ValueError):
+        fp.writelines([""])
+
+
+def test_not_reusable(tmp_path: Path) -> None:
+    assert pylistdir(tmp_path) == []
+    p = tmp_path / "file.txt"
+    p.write_text(TEXT)
+    with InPlace(p) as fp:
+        pass
+    with fp:
+        with pytest.raises(ValueError):
+            fp.flush()
+        with pytest.raises(ValueError):
+            next(fp)
+        with pytest.raises(ValueError):
+            fp.read()
+        with pytest.raises(ValueError):
+            fp.readline()
+        with pytest.raises(ValueError):
+            fp.readlines()
+        with pytest.raises(ValueError):
+            fp.write("")
+        with pytest.raises(ValueError):
+            fp.writelines([""])
+
+
+def test_bytes_useless_after_close(tmp_path: Path) -> None:
+    assert pylistdir(tmp_path) == []
+    p = tmp_path / "file.txt"
+    p.write_text(UNICODE, encoding="utf-8")
+    with InPlace(p, "b", backup_ext="~") as fp:
+        pass
+    with pytest.raises(ValueError):  # type: ignore[unreachable]
+        fp.read1()
+    with pytest.raises(ValueError):
+        fp.readinto(bytearray(42))
+    with pytest.raises(ValueError):
+        fp.readinto1(bytearray(42))
diff --git a/test/test_general.py b/test/test_general.py
index 50d27e0..a2db6d1 100644
--- a/test/test_general.py
+++ b/test/test_general.py
@@ -13,12 +13,16 @@ def test_nobackup(tmp_path: Path) -> None:
     p.write_text(TEXT)
     with InPlace(p) as fp:
         assert not fp.closed
+        files = pylistdir(tmp_path)
+        assert len(files) == 2
+        assert files[0].startswith("._in_place-")
+        assert files[1] == "file.txt"
         for line in fp:
             assert isinstance(line, str)
             fp.write(line.swapcase())
         assert not fp.closed
     assert fp.closed
-    assert pylistdir(tmp_path) == ["file.txt"]
+    assert pylistdir(tmp_path) == ["file.txt"]  # type: ignore[unreachable]
     assert p.read_text() == TEXT.swapcase()
 
 
@@ -27,9 +31,16 @@ def test_backup_ext(tmp_path: Path) -> None:
     p = tmp_path / "file.txt"
     p.write_text(TEXT)
     with InPlace(p, backup_ext="~") as fp:
+        assert not fp.closed
+        files = pylistdir(tmp_path)
+        assert len(files) == 2
+        assert files[0].startswith("._in_place-")
+        assert files[1] == "file.txt"
         for line in fp:
             fp.write(line.swapcase())
-    assert pylistdir(tmp_path) == ["file.txt", "file.txt~"]
+        assert not fp.closed
+    assert fp.closed
+    assert pylistdir(tmp_path) == ["file.txt", "file.txt~"]  # type: ignore[unreachable]
     assert p.with_suffix(".txt~").read_text() == TEXT
     assert p.read_text() == TEXT.swapcase()
 
@@ -41,36 +52,19 @@ def test_backup(tmp_path: Path) -> None:
     bkp = tmp_path / "backup.txt"
     with InPlace(p, backup=bkp) as fp:
         assert not fp.closed
+        files = pylistdir(tmp_path)
+        assert len(files) == 2
+        assert files[0].startswith("._in_place-")
+        assert files[1] == "file.txt"
         for line in fp:
             fp.write(line.swapcase())
         assert not fp.closed
     assert fp.closed
-    assert pylistdir(tmp_path) == ["backup.txt", "file.txt"]
+    assert pylistdir(tmp_path) == ["backup.txt", "file.txt"]  # type: ignore[unreachable]
     assert bkp.read_text() == TEXT
     assert p.read_text() == TEXT.swapcase()
 
 
-def test_backup_ext_and_backup(tmp_path: Path) -> None:
-    assert pylistdir(tmp_path) == []
-    p = tmp_path / "file.txt"
-    p.write_text(TEXT)
-    bkp = tmp_path / "backup.txt"
-    with pytest.raises(ValueError):
-        InPlace(p, backup=bkp, backup_ext="~")
-    assert pylistdir(tmp_path) == ["file.txt"]
-    assert p.read_text() == TEXT
-
-
-def test_empty_backup_ext(tmp_path: Path) -> None:
-    assert pylistdir(tmp_path) == []
-    p = tmp_path / "file.txt"
-    p.write_text(TEXT)
-    with pytest.raises(ValueError):
-        InPlace(p, backup_ext="")
-    assert pylistdir(tmp_path) == ["file.txt"]
-    assert p.read_text() == TEXT
-
-
 def test_error_backup_ext(tmp_path: Path) -> None:
     assert pylistdir(tmp_path) == []
     p = tmp_path / "file.txt"
@@ -121,12 +115,13 @@ def test_delete_backup(tmp_path: Path) -> None:
     p = tmp_path / "file.txt"
     p.write_text(TEXT)
     bkp = tmp_path / "backup.txt"
-    with pytest.raises(OSError):
-        with InPlace(p, backup=bkp) as fp:
-            for i, line in enumerate(fp):
-                fp.write(line.swapcase())
-                if i == 5:
-                    p.unlink()
+    with InPlace(p, backup=bkp) as fp:
+        for i, line in enumerate(fp):
+            fp.write(line.swapcase())
+            if i == 5:
+                p.unlink()
+        with pytest.raises(OSError):
+            fp.close()
     assert pylistdir(tmp_path) == []
 
 
@@ -138,20 +133,8 @@ def test_early_close_nobackup(tmp_path: Path) -> None:
         for line in fp:
             fp.write(line.swapcase())
         fp.close()
-    assert pylistdir(tmp_path) == ["file.txt"]
-    assert p.read_text() == TEXT.swapcase()
-
-
-def test_early_close_and_write_nobackup(tmp_path: Path) -> None:
-    assert pylistdir(tmp_path) == []
-    p = tmp_path / "file.txt"
-    p.write_text(TEXT)
-    with pytest.raises(ValueError):
-        with InPlace(p) as fp:
-            for line in fp:
-                fp.write(line.swapcase())
-            fp.close()
-            fp.write("And another thing...\n")
+        assert pylistdir(tmp_path) == ["file.txt"]
+        assert p.read_text() == TEXT.swapcase()
     assert pylistdir(tmp_path) == ["file.txt"]
     assert p.read_text() == TEXT.swapcase()
 
@@ -165,24 +148,25 @@ def test_early_close_backup(tmp_path: Path) -> None:
         for line in fp:
             fp.write(line.swapcase())
         fp.close()
+        assert pylistdir(tmp_path) == ["backup.txt", "file.txt"]
+        assert bkp.read_text() == TEXT
+        assert p.read_text() == TEXT.swapcase()
     assert pylistdir(tmp_path) == ["backup.txt", "file.txt"]
     assert bkp.read_text() == TEXT
     assert p.read_text() == TEXT.swapcase()
 
 
-def test_early_close_and_write_backup(tmp_path: Path) -> None:
+def test_late_close(tmp_path: Path) -> None:
     assert pylistdir(tmp_path) == []
     p = tmp_path / "file.txt"
     p.write_text(TEXT)
-    bkp = tmp_path / "backup.txt"
-    with pytest.raises(ValueError):
-        with InPlace(p, backup=bkp) as fp:
-            for line in fp:
-                fp.write(line.swapcase())
-            fp.close()
-            fp.write("And another thing...\n")
-    assert pylistdir(tmp_path) == ["backup.txt", "file.txt"]
-    assert bkp.read_text() == TEXT
+    with InPlace(p) as fp:
+        for line in fp:
+            fp.write(line.swapcase())
+    assert pylistdir(tmp_path) == ["file.txt"]
+    assert p.read_text() == TEXT.swapcase()
+    fp.close()
+    assert pylistdir(tmp_path) == ["file.txt"]
     assert p.read_text() == TEXT.swapcase()
 
 
@@ -194,48 +178,55 @@ def test_rollback_nobackup(tmp_path: Path) -> None:
         for line in fp:
             fp.write(line.swapcase())
         fp.rollback()
+        assert pylistdir(tmp_path) == ["file.txt"]
+        assert p.read_text() == TEXT
     assert pylistdir(tmp_path) == ["file.txt"]
     assert p.read_text() == TEXT
 
 
-def test_rollback_and_write_nobackup(tmp_path: Path) -> None:
+def test_rollback_backup(tmp_path: Path) -> None:
     assert pylistdir(tmp_path) == []
     p = tmp_path / "file.txt"
     p.write_text(TEXT)
-    with pytest.raises(ValueError):
-        with InPlace(p) as fp:
-            for line in fp:
-                fp.write(line.swapcase())
-            fp.rollback()
-            fp.write("And another thing...\n")
+    bkp = tmp_path / "backup.txt"
+    with InPlace(p, backup=bkp) as fp:
+        for line in fp:
+            fp.write(line.swapcase())
+        fp.rollback()
+        assert pylistdir(tmp_path) == ["file.txt"]
+        assert p.read_text() == TEXT
     assert pylistdir(tmp_path) == ["file.txt"]
     assert p.read_text() == TEXT
 
 
-def test_rollback_backup(tmp_path: Path) -> None:
+def test_rollback_then_inner_close(tmp_path: Path) -> None:
     assert pylistdir(tmp_path) == []
     p = tmp_path / "file.txt"
     p.write_text(TEXT)
-    bkp = tmp_path / "backup.txt"
-    with InPlace(p, backup=bkp) as fp:
+    with InPlace(p) as fp:
         for line in fp:
             fp.write(line.swapcase())
         fp.rollback()
+        assert pylistdir(tmp_path) == ["file.txt"]
+        assert p.read_text() == TEXT
+        fp.close()
+        assert pylistdir(tmp_path) == ["file.txt"]
+        assert p.read_text() == TEXT
     assert pylistdir(tmp_path) == ["file.txt"]
     assert p.read_text() == TEXT
 
 
-def test_rollback_and_write_backup(tmp_path: Path) -> None:
+def test_rollback_then_outer_close(tmp_path: Path) -> None:
     assert pylistdir(tmp_path) == []
     p = tmp_path / "file.txt"
     p.write_text(TEXT)
-    bkp = tmp_path / "backup.txt"
-    with pytest.raises(ValueError):
-        with InPlace(p, backup=bkp) as fp:
-            for line in fp:
-                fp.write(line.swapcase())
-            fp.rollback()
-            fp.write("And another thing...\n")
+    with InPlace(p) as fp:
+        for line in fp:
+            fp.write(line.swapcase())
+        fp.rollback()
+    assert pylistdir(tmp_path) == ["file.txt"]
+    assert p.read_text() == TEXT
+    fp.close()
     assert pylistdir(tmp_path) == ["file.txt"]
     assert p.read_text() == TEXT
 
@@ -269,7 +260,7 @@ def test_rollback_overwrite_backup(tmp_path: Path) -> None:
     assert p.read_text() == TEXT
 
 
-def test_prechdir_backup(tmp_path: Path, monkeypatch: pytest.MonkeyPach) -> None:
+def test_prechdir_backup(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
     assert pylistdir(tmp_path) == []
     monkeypatch.chdir(tmp_path)
     p = tmp_path / "file.txt"
@@ -282,34 +273,6 @@ def test_prechdir_backup(tmp_path: Path, monkeypatch: pytest.MonkeyPach) -> None
     assert p.read_text() == TEXT.swapcase()
 
 
-def test_midchdir_backup(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
-    """
-    Assert that changing directory between creating an InPlace object and
-    opening it works
-    """
-    filedir = tmp_path / "filedir"
-    filedir.mkdir()
-    wrongdir = tmp_path / "wrongdir"
-    wrongdir.mkdir()
-    p = filedir / "file.txt"
-    p.write_text(TEXT)
-    monkeypatch.chdir(filedir)
-    fp = InPlace("file.txt", backup="backup.txt", delay_open=True)
-    monkeypatch.chdir(wrongdir)
-    assert fp.closed
-    with fp:
-        assert not fp.closed
-        for line in fp:
-            fp.write(line.swapcase())
-        assert not fp.closed
-    assert fp.closed
-    assert os.getcwd() == str(wrongdir)
-    assert pylistdir(wrongdir) == []
-    assert pylistdir(filedir) == ["backup.txt", "file.txt"]
-    assert (filedir / "backup.txt").read_text() == TEXT
-    assert p.read_text() == TEXT.swapcase()
-
-
 def test_postchdir_backup(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
     """Assert that changing directory after opening an InPlace object works"""
     filedir = tmp_path / "filedir"
@@ -374,94 +337,6 @@ def test_different_dir_file_backup(
     assert p.read_text() == TEXT.swapcase()
 
 
-def test_backup_dirpath(tmp_path: Path) -> None:
-    """
-    Assert that using a path to a directory as the backup path raises an error
-    when closing
-    """
-    assert pylistdir(tmp_path) == []
-    p = tmp_path / "file.txt"
-    p.write_text(TEXT)
-    not_a_file = tmp_path / "not-a-file"
-    not_a_file.mkdir()
-    assert pylistdir(not_a_file) == []
-    fp = InPlace(p, backup=not_a_file)
-    fp.write("This will be discarded.\n")
-    with pytest.raises(EnvironmentError):
-        fp.close()
-    assert pylistdir(tmp_path) == ["file.txt", "not-a-file"]
-    assert p.read_text() == TEXT
-    assert pylistdir(not_a_file) == []
-
-
-def test_backup_nosuchdir(tmp_path: Path) -> None:
-    """
-    Assert that using a path to a file in a nonexistent directory as the backup
-    path raises an error when closing
-    """
-    assert pylistdir(tmp_path) == []
-    p = tmp_path / "file.txt"
-    p.write_text(TEXT)
-    fp = InPlace(p, backup=tmp_path / "nonexistent" / "backup.txt")
-    fp.write("This will be discarded.\n")
-    with pytest.raises(EnvironmentError):
-        fp.close()
-    assert pylistdir(tmp_path) == ["file.txt"]
-    assert p.read_text() == TEXT
-
-
-def test_double_open_nobackup(tmp_path: Path) -> None:
-    assert pylistdir(tmp_path) == []
-    p = tmp_path / "file.txt"
-    p.write_text(TEXT)
-    with InPlace(p) as fp:
-        with pytest.raises(ValueError):
-            fp.open()
-        assert not fp.closed
-        for line in fp:
-            fp.write(line.swapcase())
-        assert not fp.closed
-    assert fp.closed
-    assert pylistdir(tmp_path) == ["file.txt"]
-    assert p.read_text() == TEXT.swapcase()
-
-
-def test_nonexistent(tmp_path: Path) -> None:
-    assert pylistdir(tmp_path) == []
-    p = tmp_path / "file.txt"
-    fp = InPlace(p, delay_open=True)
-    with pytest.raises(EnvironmentError):
-        fp.open()
-    assert pylistdir(tmp_path) == []
-
-
-def test_with_nonexistent(tmp_path: Path) -> None:
-    assert pylistdir(tmp_path) == []
-    p = tmp_path / "file.txt"
-    with pytest.raises(EnvironmentError):
-        with InPlace(p):
-            assert False
-    assert pylistdir(tmp_path) == []
-
-
-def test_nonexistent_backup_ext(tmp_path: Path) -> None:
-    assert pylistdir(tmp_path) == []
-    p = tmp_path / "file.txt"
-    fp = InPlace(p, backup_ext="~", delay_open=True)
-    with pytest.raises(EnvironmentError):
-        fp.open()
-    assert pylistdir(tmp_path) == []
-
-
-def test_with_nonexistent_backup_ext(tmp_path: Path) -> None:
-    assert pylistdir(tmp_path) == []
-    p = tmp_path / "file.txt"
-    with pytest.raises(EnvironmentError):
-        with InPlace(p, backup_ext="~"):
-            assert False
-    assert pylistdir(tmp_path) == []
-
-
 def test_reentrant_backup_ext(tmp_path: Path) -> None:
     assert pylistdir(tmp_path) == []
     p = tmp_path / "file.txt"
@@ -489,73 +364,99 @@ def test_use_and_reenter_backup_ext(tmp_path: Path) -> None:
     assert p.read_text() == TEXT.swapcase()
 
 
-def test_var_changes(tmp_path: Path) -> None:
+def test_same_backup_path(tmp_path: Path) -> None:
     assert pylistdir(tmp_path) == []
     p = tmp_path / "file.txt"
     p.write_text(TEXT)
-    with InPlace(p, backup_ext="~") as fp:
-        assert not fp.closed
-        assert fp.input is not None
-        assert fp.output is not None
-        assert fp._tmppath is not None
-        assert fp._state == fp.OPEN
-    assert fp.closed
-    assert fp.input is None
-    assert fp.output is None
-    assert fp._tmppath is None
-    assert fp._state == fp.CLOSED
-
-
-def test_useless_after_close(tmp_path: Path) -> None:
-    assert pylistdir(tmp_path) == []
-    p = tmp_path / "file.txt"
-    p.write_text(TEXT)
-    with InPlace(p, backup_ext="~") as fp:
-        assert not fp.closed
-    assert fp.closed
-    with pytest.raises(ValueError):
-        fp.flush()
-    with pytest.raises(ValueError):
-        iter(fp)
-    with pytest.raises(ValueError):
-        fp.read()
-    with pytest.raises(ValueError):
-        fp.readline()
-    with pytest.raises(ValueError):
-        fp.readlines()
-    with pytest.raises(ValueError):
-        fp.write("")
-    with pytest.raises(ValueError):
-        fp.writelines([""])
-
-
-def test_rollback_too_late(tmp_path: Path) -> None:
-    assert pylistdir(tmp_path) == []
-    p = tmp_path / "file.txt"
-    p.write_text(TEXT)
-    with InPlace(p, backup_ext="~") as fp:
+    with InPlace(p, backup=p) as fp:
         for line in fp:
             fp.write(line.swapcase())
-    with pytest.raises(ValueError):
-        fp.rollback()
-    assert pylistdir(tmp_path) == ["file.txt", "file.txt~"]
-    assert p.with_suffix(".txt~").read_text() == TEXT
+    assert pylistdir(tmp_path) == ["file.txt"]
     assert p.read_text() == TEXT.swapcase()
 
 
-def test_rollback_too_early(tmp_path: Path) -> None:
-    assert pylistdir(tmp_path) == []
+@pytest.mark.skipif(
+    platform.system() == "Windows", reason="Windows barely has file modes"
+)
+def test_copy_executable_perm(tmp_path: Path) -> None:
     p = tmp_path / "file.txt"
     p.write_text(TEXT)
-    fp = InPlace(p, backup_ext="~", delay_open=True)
-    with pytest.raises(ValueError):
-        fp.rollback()
-    assert fp.closed
-    assert pylistdir(tmp_path) == ["file.txt"]
-    assert p.read_text() == TEXT
-    with fp:
+    p.chmod(0o755)
+    with InPlace(p) as fp:
         for line in fp:
             fp.write(line.swapcase())
-    assert pylistdir(tmp_path) == ["file.txt", "file.txt~"]
-    assert p.with_suffix(".txt~").read_text() == TEXT
+    assert pylistdir(tmp_path) == ["file.txt"]
     assert p.read_text() == TEXT.swapcase()
+    assert p.stat().st_mode & 0o777 == 0o755
+
+
+def test_empty_newline(tmp_path: Path) -> None:
+    BYTES = (
+        b"'Twas brillig, and the slithy toves\n"
+        b"\tDid gyre and gimble in the wabe;\r"
+        b"All mimsy were the borogoves,\r\n"
+        b"\tAnd the mome raths outgrabe.\n"
+    )
+    p = tmp_path / "file.txt"
+    p.write_bytes(BYTES)
+    with InPlace(p, newline="") as fp:
+        lines = fp.readlines()
+        assert lines == [
+            "'Twas brillig, and the slithy toves\n",
+            "\tDid gyre and gimble in the wabe;\r",
+            "All mimsy were the borogoves,\r\n",
+            "\tAnd the mome raths outgrabe.\n",
+        ]
+        fp.writelines(ln.swapcase() for ln in lines)
+    assert pylistdir(tmp_path) == ["file.txt"]
+    assert p.read_bytes() == BYTES.swapcase()
+
+
+def test_unix_newline(tmp_path: Path) -> None:
+    BYTES = (
+        b"'Twas brillig, and the slithy toves\n"
+        b"\tDid gyre and gimble in the wabe;\r"
+        b"All mimsy were the borogoves,\r\n"
+        b"\tAnd the mome raths outgrabe.\n"
+    )
+    p = tmp_path / "file.txt"
+    p.write_bytes(BYTES)
+    with InPlace(p, newline="\n") as fp:
+        lines = fp.readlines()
+        assert lines == [
+            "'Twas brillig, and the slithy toves\n",
+            "\tDid gyre and gimble in the wabe;\rAll mimsy were the borogoves,\r\n",
+            "\tAnd the mome raths outgrabe.\n",
+        ]
+        fp.writelines(ln.swapcase() for ln in lines)
+    assert pylistdir(tmp_path) == ["file.txt"]
+    assert p.read_bytes() == BYTES.swapcase()
+
+
+def test_dos_newline(tmp_path: Path) -> None:
+    BYTES = (
+        b"'Twas brillig, and the slithy toves\n"
+        b"\tDid gyre and gimble in the wabe;\r"
+        b"All mimsy were the borogoves,\r\n"
+        b"\tAnd the mome raths outgrabe.\n"
+    )
+    p = tmp_path / "file.txt"
+    p.write_bytes(BYTES)
+    with InPlace(p, newline="\r\n") as fp:
+        lines = fp.readlines()
+        assert lines == [
+            (
+                "'Twas brillig, and the slithy toves\n"
+                "\tDid gyre and gimble in the wabe;\r"
+                "All mimsy were the borogoves,\r\n"
+            ),
+            "\tAnd the mome raths outgrabe.\n",
+        ]
+        fp.writelines(ln.swapcase() for ln in lines)
+    assert pylistdir(tmp_path) == ["file.txt"]
+    assert p.read_bytes() == (
+        b"'tWAS BRILLIG, AND THE SLITHY TOVES\r\n"
+        b"\tdID GYRE AND GIMBLE IN THE WABE;\r"
+        b"aLL MIMSY WERE THE BOROGOVES,\r\r\n"
+        b"\taND THE MOME RATHS OUTGRABE.\r\n"
+    )
diff --git a/test/test_io_methods.py b/test/test_io_methods.py
index 7a76f54..1490563 100644
--- a/test/test_io_methods.py
+++ b/test/test_io_methods.py
@@ -1,7 +1,9 @@
 from __future__ import annotations
+import os.path
 from pathlib import Path
+import pytest
 from in_place import InPlace
-from test_in_place_util import TEXT, UNICODE, pylistdir
+from test_in_place_util import NLB, TEXT, UNICODE, pylistdir
 
 
 def test_print_backup(tmp_path: Path) -> None:
@@ -17,6 +19,16 @@ def test_print_backup(tmp_path: Path) -> None:
     assert p.read_text() == TEXT.swapcase()
 
 
+def test_read1_nobackup(tmp_path: Path) -> None:
+    assert pylistdir(tmp_path) == []
+    p = tmp_path / "file.txt"
+    p.write_text(UNICODE, encoding="utf-8")
+    with InPlace(p, "b") as fp:
+        bs = fp.read1(5)
+        assert 1 <= len(bs) <= 5
+        assert bs[0] == 0xC3
+
+
 def test_readinto_bytearray_nobackup(tmp_path: Path) -> None:
     assert pylistdir(tmp_path) == []
     p = tmp_path / "file.txt"
@@ -30,6 +42,18 @@ def test_readinto_bytearray_nobackup(tmp_path: Path) -> None:
     assert p.read_bytes() == b"\xC3\xA5\xC3\xA9\xC3"
 
 
+def test_readinto1_bytearray_nobackup(tmp_path: Path) -> None:
+    assert pylistdir(tmp_path) == []
+    p = tmp_path / "file.txt"
+    p.write_text(UNICODE, encoding="utf-8")
+    with InPlace(p, "b") as fp:
+        ba = bytearray(5)
+        r = fp.readinto1(ba)
+        assert 1 <= r <= 5
+        assert len(ba) == r
+        assert ba[0] == 0xC3
+
+
 def test_readlines_nobackup(tmp_path: Path) -> None:
     assert pylistdir(tmp_path) == []
     p = tmp_path / "file.txt"
@@ -61,3 +85,30 @@ def test_readline_nobackup(tmp_path: Path) -> None:
             fp.write(line.swapcase())
     assert pylistdir(tmp_path) == ["file.txt"]
     assert p.read_text() == TEXT.swapcase()
+
+
+def test_misc(tmp_path: Path) -> None:
+    p = tmp_path / "file.txt"
+    p.touch()
+    with InPlace(p) as fp:
+        assert fp.name == os.path.realpath(p)
+        assert fp.readable()
+        assert fp.writable()
+        assert not fp.seekable()
+        assert not fp.isatty()
+        with pytest.raises(OSError):
+            fp.seek(0)
+        with pytest.raises(OSError):
+            fp.tell()
+        with pytest.raises(OSError):
+            fp.truncate()
+        with pytest.raises(OSError):
+            fp.fileno()
+
+
+def test_binary_iteration(tmp_path: Path) -> None:
+    p = tmp_path / "file.txt"
+    p.write_text(TEXT)
+    with InPlace(p, mode="b") as fp:
+        assert next(fp) == b"'Twas brillig, and the slithy toves" + NLB
+        assert next(fp) == b"\tDid gyre and gimble in the wabe;" + NLB
diff --git a/test/test_move_first.py b/test/test_move_first.py
deleted file mode 100644
index 233287c..0000000
--- a/test/test_move_first.py
+++ /dev/null
@@ -1,577 +0,0 @@
-from __future__ import annotations
-import os
-from pathlib import Path
-import platform
-import pytest
-from in_place import InPlace
-from test_in_place_util import TEXT, pylistdir
-
-
-def test_move_first_nobackup(tmp_path: Path) -> None:
-    assert pylistdir(tmp_path) == []
-    p = tmp_path / "file.txt"
-    p.write_text(TEXT)
-    with InPlace(p, move_first=True) 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(tmp_path) == ["file.txt"]
-    assert p.read_text() == TEXT.swapcase()
-
-
-def test_move_first_backup_ext(tmp_path: Path) -> None:
-    assert pylistdir(tmp_path) == []
-    p = tmp_path / "file.txt"
-    p.write_text(TEXT)
-    with InPlace(p, backup_ext="~", move_first=True) as fp:
-        for line in fp:
-            fp.write(line.swapcase())
-    assert pylistdir(tmp_path) == ["file.txt", "file.txt~"]
-    assert p.with_suffix(".txt~").read_text() == TEXT
-    assert p.read_text() == TEXT.swapcase()
-
-
-def test_move_first_backup(tmp_path: Path) -> None:
-    assert pylistdir(tmp_path) == []
-    p = tmp_path / "file.txt"
-    p.write_text(TEXT)
-    bkp = tmp_path / "backup.txt"
-    with InPlace(p, backup=bkp, move_first=True) as fp:
-        assert not fp.closed
-        for line in fp:
-            fp.write(line.swapcase())
-        assert not fp.closed
-    assert fp.closed
-    assert pylistdir(tmp_path) == ["backup.txt", "file.txt"]
-    assert bkp.read_text() == TEXT
-    assert p.read_text() == TEXT.swapcase()
-
-
-def test_move_first_backup_ext_and_backup(tmp_path: Path) -> None:
-    assert pylistdir(tmp_path) == []
-    p = tmp_path / "file.txt"
-    p.write_text(TEXT)
-    bkp = tmp_path / "backup.txt"
-    with pytest.raises(ValueError):
-        with InPlace(p, backup=bkp, backup_ext="~", move_first=True):
-            assert False
-    assert pylistdir(tmp_path) == ["file.txt"]
-    assert p.read_text() == TEXT
-
-
-def test_move_first_empty_backup_ext(tmp_path: Path) -> None:
-    assert pylistdir(tmp_path) == []
-    p = tmp_path / "file.txt"
-    p.write_text(TEXT)
-    with pytest.raises(ValueError):
-        with InPlace(p, backup_ext="", move_first=True):
-            assert False
-    assert pylistdir(tmp_path) == ["file.txt"]
-    assert p.read_text() == TEXT
-
-
-def test_move_first_error_backup_ext(tmp_path: Path) -> None:
-    assert pylistdir(tmp_path) == []
-    p = tmp_path / "file.txt"
-    p.write_text(TEXT)
-    with pytest.raises(RuntimeError):
-        with InPlace(p, backup_ext="~", move_first=True) as fp:
-            for i, line in enumerate(fp):
-                fp.write(line.swapcase())
-                if i > 5:
-                    raise RuntimeError("I changed my mind.")
-    assert pylistdir(tmp_path) == ["file.txt"]
-    assert p.read_text() == TEXT
-
-
-def test_move_first_pass_nobackup(tmp_path: Path) -> None:
-    assert pylistdir(tmp_path) == []
-    p = tmp_path / "file.txt"
-    p.write_text(TEXT)
-    with InPlace(p, move_first=True):
-        pass
-    assert pylistdir(tmp_path) == ["file.txt"]
-    assert p.read_text() == ""
-
-
-@pytest.mark.skipif(
-    platform.system() == "Windows",
-    reason="Cannot delete open file on Windows",
-)
-def test_move_first_delete_nobackup(tmp_path: Path) -> None:
-    assert pylistdir(tmp_path) == []
-    p = tmp_path / "file.txt"
-    p.write_text(TEXT)
-    with InPlace(p, move_first=True) as fp:
-        for i, line in enumerate(fp):
-            fp.write(line.swapcase())
-            if i == 5:
-                p.unlink()
-    assert pylistdir(tmp_path) == []
-
-
-@pytest.mark.skipif(
-    platform.system() == "Windows",
-    reason="Cannot delete open file on Windows",
-)
-def test_move_first_delete_backup(tmp_path: Path) -> None:
-    assert pylistdir(tmp_path) == []
-    p = tmp_path / "file.txt"
-    p.write_text(TEXT)
-    bkp = tmp_path / "backup.txt"
-    with InPlace(p, backup=bkp, move_first=True) as fp:
-        for i, line in enumerate(fp):
-            fp.write(line.swapcase())
-            if i == 5:
-                p.unlink()
-    assert pylistdir(tmp_path) == ["backup.txt"]
-    assert bkp.read_text() == TEXT
-
-
-def test_move_first_early_close_nobackup(tmp_path: Path) -> None:
-    assert pylistdir(tmp_path) == []
-    p = tmp_path / "file.txt"
-    p.write_text(TEXT)
-    with InPlace(p, move_first=True) as fp:
-        for line in fp:
-            fp.write(line.swapcase())
-        fp.close()
-    assert pylistdir(tmp_path) == ["file.txt"]
-    assert p.read_text() == TEXT.swapcase()
-
-
-def test_move_first_early_close_and_write_nobackup(tmp_path: Path) -> None:
-    assert pylistdir(tmp_path) == []
-    p = tmp_path / "file.txt"
-    p.write_text(TEXT)
-    with pytest.raises(ValueError):
-        with InPlace(p, move_first=True) as fp:
-            for line in fp:
-                fp.write(line.swapcase())
-            fp.close()
-            fp.write("And another thing...\n")
-    assert pylistdir(tmp_path) == ["file.txt"]
-    assert p.read_text() == TEXT.swapcase()
-
-
-def test_move_first_early_close_backup(tmp_path: Path) -> None:
-    assert pylistdir(tmp_path) == []
-    p = tmp_path / "file.txt"
-    p.write_text(TEXT)
-    bkp = tmp_path / "backup.txt"
-    with InPlace(p, backup=bkp, move_first=True) as fp:
-        for line in fp:
-            fp.write(line.swapcase())
-        fp.close()
-    assert pylistdir(tmp_path) == ["backup.txt", "file.txt"]
-    assert bkp.read_text() == TEXT
-    assert p.read_text() == TEXT.swapcase()
-
-
-def test_move_first_early_close_and_write_backup(tmp_path: Path) -> None:
-    assert pylistdir(tmp_path) == []
-    p = tmp_path / "file.txt"
-    p.write_text(TEXT)
-    bkp = tmp_path / "backup.txt"
-    with pytest.raises(ValueError):
-        with InPlace(p, backup=bkp, move_first=True) as fp:
-            for line in fp:
-                fp.write(line.swapcase())
-            fp.close()
-            fp.write("And another thing...\n")
-    assert pylistdir(tmp_path) == ["backup.txt", "file.txt"]
-    assert bkp.read_text() == TEXT
-    assert p.read_text() == TEXT.swapcase()
-
-
-def test_move_first_rollback_nobackup(tmp_path: Path) -> None:
-    assert pylistdir(tmp_path) == []
-    p = tmp_path / "file.txt"
-    p.write_text(TEXT)
-    with InPlace(p, move_first=True) as fp:
-        for line in fp:
-            fp.write(line.swapcase())
-        fp.rollback()
-    assert pylistdir(tmp_path) == ["file.txt"]
-    assert p.read_text() == TEXT
-
-
-def test_move_first_rollback_and_write_nobackup(tmp_path: Path) -> None:
-    assert pylistdir(tmp_path) == []
-    p = tmp_path / "file.txt"
-    p.write_text(TEXT)
-    with pytest.raises(ValueError):
-        with InPlace(p, move_first=True) as fp:
-            for line in fp:
-                fp.write(line.swapcase())
-            fp.rollback()
-            fp.write("And another thing...\n")
-    assert pylistdir(tmp_path) == ["file.txt"]
-    assert p.read_text() == TEXT
-
-
-def test_move_first_rollback_backup(tmp_path: Path) -> None:
-    assert pylistdir(tmp_path) == []
-    p = tmp_path / "file.txt"
-    p.write_text(TEXT)
-    bkp = tmp_path / "backup.txt"
-    with InPlace(p, backup=bkp, move_first=True) as fp:
-        for line in fp:
-            fp.write(line.swapcase())
-        fp.rollback()
-    assert pylistdir(tmp_path) == ["file.txt"]
-    assert p.read_text() == TEXT
-
-
-def test_move_first_rollback_and_write_backup(tmp_path: Path) -> None:
-    assert pylistdir(tmp_path) == []
-    p = tmp_path / "file.txt"
-    p.write_text(TEXT)
-    bkp = tmp_path / "backup.txt"
-    with pytest.raises(ValueError):
-        with InPlace(p, backup=bkp, move_first=True) as fp:
-            for line in fp:
-                fp.write(line.swapcase())
-            fp.rollback()
-            fp.write("And another thing...\n")
-    assert pylistdir(tmp_path) == ["file.txt"]
-    assert p.read_text() == TEXT
-
-
-def test_move_first_overwrite_backup(tmp_path: Path) -> None:
-    assert pylistdir(tmp_path) == []
-    p = tmp_path / "file.txt"
-    p.write_text(TEXT)
-    bkp = tmp_path / "backup.txt"
-    bkp.write_text("This is not the file you are looking for.\n")
-    with InPlace(p, backup=bkp, move_first=True) as fp:
-        for line in fp:
-            fp.write(line.swapcase())
-    assert pylistdir(tmp_path) == ["backup.txt", "file.txt"]
-    assert bkp.read_text() == TEXT
-    assert p.read_text() == TEXT.swapcase()
-
-
-def test_move_first_rollback_overwrite_backup(tmp_path: Path) -> None:
-    assert pylistdir(tmp_path) == []
-    p = tmp_path / "file.txt"
-    p.write_text(TEXT)
-    bkp = tmp_path / "backup.txt"
-    bkp.write_text("This is not the file you are looking for.\n")
-    with InPlace(p, backup=bkp, move_first=True) as fp:
-        for line in fp:
-            fp.write(line.swapcase())
-        fp.rollback()
-    assert pylistdir(tmp_path) == ["backup.txt", "file.txt"]
-    assert bkp.read_text() == "This is not the file you are looking for.\n"
-    assert p.read_text() == TEXT
-
-
-def test_move_first_prechdir_backup(
-    tmp_path: Path, monkeypatch: pytest.MonkeyPatch
-) -> None:
-    assert pylistdir(tmp_path) == []
-    monkeypatch.chdir(tmp_path)
-    p = tmp_path / "file.txt"
-    p.write_text(TEXT)
-    with InPlace(p, backup="backup.txt", move_first=True) as fp:
-        for line in fp:
-            fp.write(line.swapcase())
-    assert pylistdir(tmp_path) == ["backup.txt", "file.txt"]
-    assert (tmp_path / "backup.txt").read_text() == TEXT
-    assert p.read_text() == TEXT.swapcase()
-
-
-def test_move_first_midchdir_backup(
-    tmp_path: Path, monkeypatch: pytest.MonkeyPatch
-) -> None:
-    """
-    Assert that changing directory between creating an InPlace object and
-    opening it works
-    """
-    filedir = tmp_path / "filedir"
-    filedir.mkdir()
-    wrongdir = tmp_path / "wrongdir"
-    wrongdir.mkdir()
-    p = filedir / "file.txt"
-    p.write_text(TEXT)
-    monkeypatch.chdir(filedir)
-    fp = InPlace("file.txt", backup="backup.txt", delay_open=True, move_first=True)
-    monkeypatch.chdir(wrongdir)
-    assert fp.closed
-    with fp:
-        assert not fp.closed
-        for line in fp:
-            fp.write(line.swapcase())
-        assert not fp.closed
-    assert fp.closed
-    assert os.getcwd() == str(wrongdir)
-    assert pylistdir(wrongdir) == []
-    assert pylistdir(filedir) == ["backup.txt", "file.txt"]
-    assert (filedir / "backup.txt").read_text() == TEXT
-    assert p.read_text() == TEXT.swapcase()
-
-
-def test_move_first_postchdir_backup(
-    tmp_path: Path, monkeypatch: pytest.MonkeyPatch
-) -> None:
-    """Assert that changing directory after opening an InPlace object works"""
-    filedir = tmp_path / "filedir"
-    filedir.mkdir()
-    wrongdir = tmp_path / "wrongdir"
-    wrongdir.mkdir()
-    p = filedir / "file.txt"
-    p.write_text(TEXT)
-    monkeypatch.chdir(filedir)
-    with InPlace("file.txt", backup="backup.txt", move_first=True) as fp:
-        monkeypatch.chdir(wrongdir)
-        for line in fp:
-            fp.write(line.swapcase())
-    assert os.getcwd() == str(wrongdir)
-    assert pylistdir(wrongdir) == []
-    assert pylistdir(filedir) == ["backup.txt", "file.txt"]
-    assert (filedir / "backup.txt").read_text() == TEXT
-    assert p.read_text() == TEXT.swapcase()
-
-
-def test_move_first_different_dir_backup(
-    tmp_path: Path, monkeypatch: pytest.MonkeyPatch
-) -> None:
-    monkeypatch.chdir(tmp_path)
-    filedir = tmp_path / "filedir"
-    filedir.mkdir()
-    bkpdir = tmp_path / "bkpdir"
-    bkpdir.mkdir()
-    p = filedir / "file.txt"
-    p.write_text(TEXT)
-    with InPlace(
-        os.path.join("filedir", "file.txt"),
-        backup=os.path.join("bkpdir", "backup.txt"),
-        move_first=True,
-    ) as fp:
-        for line in fp:
-            fp.write(line.swapcase())
-    assert pylistdir(filedir) == ["file.txt"]
-    assert pylistdir(bkpdir) == ["backup.txt"]
-    assert (bkpdir / "backup.txt").read_text() == TEXT
-    assert p.read_text() == TEXT.swapcase()
-
-
-def test_move_first_different_dir_file_backup(
-    tmp_path: Path, monkeypatch: pytest.MonkeyPatch
-) -> None:
-    """
-    Assert that if the input filepath contains a directory component and the
-    backup path does not, the backup file will be created in the current
-    directory
-    """
-    monkeypatch.chdir(tmp_path)
-    filedir = tmp_path / "filedir"
-    filedir.mkdir()
-    p = filedir / "file.txt"
-    p.write_text(TEXT)
-    with InPlace(
-        os.path.join("filedir", "file.txt"),
-        backup="backup.txt",
-        move_first=True,
-    ) as fp:
-        for line in fp:
-            fp.write(line.swapcase())
-    assert pylistdir(tmp_path) == ["backup.txt", "filedir"]
-    assert pylistdir(filedir) == ["file.txt"]
-    assert (tmp_path / "backup.txt").read_text() == TEXT
-    assert p.read_text() == TEXT.swapcase()
-
-
-def test_move_first_backup_dirpath(tmp_path: Path) -> None:
-    """
-    Assert that using a path to a directory as the backup path raises an error
-    when closing
-    """
-    assert pylistdir(tmp_path) == []
-    p = tmp_path / "file.txt"
-    p.write_text(TEXT)
-    not_a_file = tmp_path / "not-a-file"
-    not_a_file.mkdir()
-    assert pylistdir(not_a_file) == []
-    fp = InPlace(p, backup=not_a_file, move_first=True)
-    fp.write("This will be discarded.\n")
-    with pytest.raises(EnvironmentError):
-        fp.close()
-    assert pylistdir(tmp_path) == ["file.txt", "not-a-file"]
-    assert p.read_text() == TEXT
-    assert pylistdir(not_a_file) == []
-
-
-def test_move_first_backup_nosuchdir(tmp_path: Path) -> None:
-    """
-    Assert that using a path to a file in a nonexistent directory as the backup
-    path raises an error when opening
-    """
-    assert pylistdir(tmp_path) == []
-    p = tmp_path / "file.txt"
-    p.write_text(TEXT)
-    fp = InPlace(
-        p,
-        backup=tmp_path / "nonexistent" / "backup.txt",
-        move_first=True,
-        delay_open=True,
-    )
-    with pytest.raises(EnvironmentError):
-        fp.open()
-    assert pylistdir(tmp_path) == ["file.txt"]
-    assert p.read_text() == TEXT
-
-
-def test_move_first_double_open_nobackup(tmp_path: Path) -> None:
-    assert pylistdir(tmp_path) == []
-    p = tmp_path / "file.txt"
-    p.write_text(TEXT)
-    with InPlace(p, move_first=True) as fp:
-        with pytest.raises(ValueError):
-            fp.open()
-        assert not fp.closed
-        for line in fp:
-            fp.write(line.swapcase())
-        assert not fp.closed
-    assert fp.closed
-    assert pylistdir(tmp_path) == ["file.txt"]
-    assert p.read_text() == TEXT.swapcase()
-
-
-def test_move_first_nonexistent(tmp_path: Path) -> None:
-    assert pylistdir(tmp_path) == []
-    p = tmp_path / "file.txt"
-    fp = InPlace(p, move_first=True, delay_open=True)
-    with pytest.raises(EnvironmentError):
-        fp.open()
-    assert pylistdir(tmp_path) == []
-
-
-def test_move_first_with_nonexistent(tmp_path: Path) -> None:
-    assert pylistdir(tmp_path) == []
-    p = tmp_path / "file.txt"
-    with pytest.raises(EnvironmentError):
-        with InPlace(p, move_first=True):
-            assert False
-    assert pylistdir(tmp_path) == []
-
-
-def test_move_first_nonexistent_backup_ext(tmp_path: Path) -> None:
-    assert pylistdir(tmp_path) == []
-    p = tmp_path / "file.txt"
-    fp = InPlace(p, backup_ext="~", move_first=True, delay_open=True)
-    with pytest.raises(EnvironmentError):
-        fp.open()
-    assert pylistdir(tmp_path) == []
-
-
-def test_move_first_with_nonexistent_backup_ext(tmp_path: Path) -> None:
-    assert pylistdir(tmp_path) == []
-    p = tmp_path / "file.txt"
-    with pytest.raises(EnvironmentError):
-        with InPlace(p, backup_ext="~", move_first=True):
-            assert False
-    assert pylistdir(tmp_path) == []
-
-
-def test_move_first_reentrant_backup_ext(tmp_path: Path) -> None:
-    assert pylistdir(tmp_path) == []
-    p = tmp_path / "file.txt"
-    p.write_text(TEXT)
-    with InPlace(p, backup_ext="~", move_first=True) as fp:
-        with fp:
-            for line in fp:
-                fp.write(line.swapcase())
-    assert pylistdir(tmp_path) == ["file.txt", "file.txt~"]
-    assert p.with_suffix(".txt~").read_text() == TEXT
-    assert p.read_text() == TEXT.swapcase()
-
-
-def test_move_first_use_and_reenter_backup_ext(tmp_path: Path) -> None:
-    assert pylistdir(tmp_path) == []
-    p = tmp_path / "file.txt"
-    p.write_text(TEXT)
-    with InPlace(p, backup_ext="~", move_first=True) as fp:
-        fp.write(fp.readline().swapcase())
-        with fp:
-            for line in fp:
-                fp.write(line.swapcase())
-    assert pylistdir(tmp_path) == ["file.txt", "file.txt~"]
-    assert p.with_suffix(".txt~").read_text() == TEXT
-    assert p.read_text() == TEXT.swapcase()
-
-
-def test_move_first_var_changes(tmp_path: Path) -> None:
-    assert pylistdir(tmp_path) == []
-    p = tmp_path / "file.txt"
-    p.write_text(TEXT)
-    with InPlace(p, backup_ext="~", move_first=True) as fp:
-        assert not fp.closed
-        assert fp.input is not None
-        assert fp.output is not None
-        assert fp._tmppath is not None
-        assert fp._state == fp.OPEN
-    assert fp.closed
-    assert fp.input is None
-    assert fp.output is None
-    assert fp._tmppath is None
-    assert fp._state == fp.CLOSED
-
-
-def test_move_first_useless_after_close(tmp_path: Path) -> None:
-    assert pylistdir(tmp_path) == []
-    p = tmp_path / "file.txt"
-    p.write_text(TEXT)
-    with InPlace(p, backup_ext="~", move_first=True) as fp:
-        assert not fp.closed
-    assert fp.closed
-    with pytest.raises(ValueError):
-        fp.flush()
-    with pytest.raises(ValueError):
-        iter(fp)
-    with pytest.raises(ValueError):
-        fp.read()
-    with pytest.raises(ValueError):
-        fp.readline()
-    with pytest.raises(ValueError):
-        fp.readlines()
-    with pytest.raises(ValueError):
-        fp.write("")
-    with pytest.raises(ValueError):
-        fp.writelines([""])
-
-
-def test_move_first_rollback_too_late(tmp_path: Path) -> None:
-    assert pylistdir(tmp_path) == []
-    p = tmp_path / "file.txt"
-    p.write_text(TEXT)
-    with InPlace(p, backup_ext="~", move_first=True) as fp:
-        for line in fp:
-            fp.write(line.swapcase())
-    with pytest.raises(ValueError):
-        fp.rollback()
-    assert pylistdir(tmp_path) == ["file.txt", "file.txt~"]
-    assert p.with_suffix(".txt~").read_text() == TEXT
-    assert p.read_text() == TEXT.swapcase()
-
-
-def test_move_first_rollback_too_early(tmp_path: Path) -> None:
-    assert pylistdir(tmp_path) == []
-    p = tmp_path / "file.txt"
-    p.write_text(TEXT)
-    fp = InPlace(p, backup_ext="~", delay_open=True, move_first=True)
-    with pytest.raises(ValueError):
-        fp.rollback()
-    assert fp.closed
-    assert pylistdir(tmp_path) == ["file.txt"]
-    assert p.read_text() == TEXT
-    with fp:
-        for line in fp:
-            fp.write(line.swapcase())
-    assert pylistdir(tmp_path) == ["file.txt", "file.txt~"]
-    assert p.with_suffix(".txt~").read_text() == TEXT
-    assert p.read_text() == TEXT.swapcase()
diff --git a/test/test_nonstr_path.py b/test/test_nonstr_path.py
index b8fe409..52133be 100644
--- a/test/test_nonstr_path.py
+++ b/test/test_nonstr_path.py
@@ -1,15 +1,16 @@
 from __future__ import annotations
 from os import fsencode
 from pathlib import Path
+from typing import AnyStr, Generic
 from in_place import InPlace
 from test_in_place_util import TEXT, pylistdir
 
 
-class PathLike:
-    def __init__(self, path):
-        self.path = path
+class PathLike(Generic[AnyStr]):
+    def __init__(self, path: AnyStr) -> None:
+        self.path: AnyStr = path
 
-    def __fspath__(self):
+    def __fspath__(self) -> AnyStr:
         return self.path
 
 
@@ -18,12 +19,8 @@ def test_pathlike(tmp_path: Path) -> None:
     p = tmp_path / "file.txt"
     p.write_text(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(tmp_path) == ["file.txt"]
     assert p.read_text() == TEXT.swapcase()
 
@@ -46,11 +43,8 @@ def test_pathlike_backup(tmp_path: Path) -> None:
     p.write_text(TEXT)
     bkp = tmp_path / "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(tmp_path) == ["backup.txt", "file.txt"]
     assert bkp.read_text() == TEXT
     assert p.read_text() == TEXT.swapcase()
@@ -61,12 +55,8 @@ def test_pathlike_bytes(tmp_path: Path) -> None:
     p = tmp_path / "file.txt"
     p.write_text(TEXT)
     with InPlace(PathLike(fsencode(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(tmp_path) == ["file.txt"]
     assert p.read_text() == TEXT.swapcase()
 
@@ -89,11 +79,8 @@ def test_pathlike_bytes_backup(tmp_path: Path) -> None:
     p.write_text(TEXT)
     bkp = tmp_path / "backup.txt"
     with InPlace(PathLike(fsencode(p)), backup=PathLike(fsencode(bkp))) as fp:
-        assert not fp.closed
         for line in fp:
             fp.write(line.swapcase())
-        assert not fp.closed
-    assert fp.closed
     assert pylistdir(tmp_path) == ["backup.txt", "file.txt"]
     assert bkp.read_text() == TEXT
     assert p.read_text() == TEXT.swapcase()
@@ -104,12 +91,8 @@ def test_bytes(tmp_path: Path) -> None:
     p = tmp_path / "file.txt"
     p.write_text(TEXT)
     with InPlace(fsencode(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(tmp_path) == ["file.txt"]
     assert p.read_text() == TEXT.swapcase()
 
@@ -132,11 +115,8 @@ def test_bytes_backup(tmp_path: Path) -> None:
     p.write_text(TEXT)
     bkp = tmp_path / "backup.txt"
     with InPlace(fsencode(p), backup=fsencode(bkp)) as fp:
-        assert not fp.closed
         for line in fp:
             fp.write(line.swapcase())
-        assert not fp.closed
-    assert fp.closed
     assert pylistdir(tmp_path) == ["backup.txt", "file.txt"]
     assert bkp.read_text() == TEXT
     assert p.read_text() == TEXT.swapcase()
diff --git a/test/test_py3.py b/test/test_py3.py
deleted file mode 100644
index e40812e..0000000
--- a/test/test_py3.py
+++ /dev/null
@@ -1,35 +0,0 @@
-from __future__ import annotations
-from pathlib import Path
-import pytest
-from in_place import InPlace
-from test_in_place_util import UNICODE, pylistdir
-
-
-def test_py3_textstr(tmp_path: Path) -> None:
-    """Assert that `InPlace` works with text strings in Python 3"""
-    assert pylistdir(tmp_path) == []
-    p = tmp_path / "file.txt"
-    p.write_text(UNICODE, encoding="utf-8")
-    with InPlace(p, encoding="utf-8") as fp:
-        txt = fp.read()
-        assert isinstance(txt, str)
-        assert txt == UNICODE
-        print(UNICODE, file=fp)
-    assert pylistdir(tmp_path) == ["file.txt"]
-    assert p.read_text(encoding="utf-8") == UNICODE + "\n"
-
-
-def test_py3_not_bytestr(tmp_path: Path) -> None:
-    """Assert that `InPlace` does not work with byte strings in Python 3"""
-    assert pylistdir(tmp_path) == []
-    p = tmp_path / "file.txt"
-    p.write_text(UNICODE, encoding="utf-8")
-    with InPlace(p, encoding="utf-8") as fp:
-        txt = fp.read()
-        assert isinstance(txt, str)
-        assert txt == UNICODE
-        bs = txt.encode("utf-16")
-        with pytest.raises(TypeError):
-            # `print()` would stringify `txt` to `b'...'`, which is not what we
-            # want.
-            fp.write(bs)
diff --git a/test/test_symlinks.py b/test/test_symlinks.py
index f35ecd9..4beda10 100644
--- a/test/test_symlinks.py
+++ b/test/test_symlinks.py
@@ -1,18 +1,22 @@
-from operator import attrgetter
+from __future__ import annotations
 import os
 from os.path import relpath
+from pathlib import Path
 import platform
+import sys
 import pytest
 from in_place import InPlace
-from test_in_place_util import TEXT
+from test_in_place_util import TEXT, pylistdir
 
 pytestmark = pytest.mark.xfail(
-    platform.system() == "Windows" and platform.python_implementation() == "PyPy",
-    reason="Symlinks are not implemented on PyPy on Windows as of v7.3.3",
+    platform.system() == "Windows"
+    and hasattr(sys, "pypy_version_info")
+    and sys.pypy_version_info < (7, 3, 12),
+    reason="Symlinks are not implemented on PyPy on Windows before v7.3.12",
 )
 
 
-def test_symlink_nobackup(tmp_path):
+def test_symlink_nobackup(tmp_path: Path) -> None:
     assert list(tmp_path.iterdir()) == []
     realdir = tmp_path / "real"
     realdir.mkdir()
@@ -23,18 +27,18 @@ def test_symlink_nobackup(tmp_path):
     link = linkdir / "linkfile.txt"
     target = relpath(real, linkdir)
     link.symlink_to(target)
-    with InPlace(str(link)) as fp:
+    with InPlace(link) as fp:
         for line in fp:
             fp.write(line.swapcase())
     assert list(realdir.iterdir()) == [real]
     assert list(linkdir.iterdir()) == [link]
     assert link.is_symlink()
-    assert os.readlink(str(link)) == target
+    assert os.readlink(link) == target
     assert link.read_text() == TEXT.swapcase()
     assert real.read_text() == TEXT.swapcase()
 
 
-def test_symlink_backup_ext(tmp_path):
+def test_symlink_backup_ext(tmp_path: Path) -> None:
     assert list(tmp_path.iterdir()) == []
     realdir = tmp_path / "real"
     realdir.mkdir()
@@ -45,22 +49,21 @@ def test_symlink_backup_ext(tmp_path):
     link = linkdir / "linkfile.txt"
     target = relpath(real, linkdir)
     link.symlink_to(target)
-    with InPlace(str(link), backup_ext="~") as fp:
+    with InPlace(link, backup_ext="~") as fp:
         for line in fp:
             fp.write(line.swapcase())
-    assert list(realdir.iterdir()) == [real]
-    assert sorted(linkdir.iterdir(), key=attrgetter("name")) == [
-        link,
-        link.with_suffix(".txt~"),
-    ]
+    assert pylistdir(realdir) == ["realfile.txt", "realfile.txt~"]
+    assert not real.is_symlink()
+    assert real.read_text() == TEXT.swapcase()
+    assert not (realdir / "realfile.txt~").is_symlink()
+    assert (realdir / "realfile.txt~").read_text() == TEXT
+    assert pylistdir(linkdir) == ["linkfile.txt"]
     assert link.is_symlink()
-    assert os.readlink(str(link)) == target
-    assert link.with_suffix(".txt~").read_text() == TEXT
+    assert os.readlink(link) == target
     assert link.read_text() == TEXT.swapcase()
-    assert real.read_text() == TEXT.swapcase()
 
 
-def test_symlink_backup(tmp_path):
+def test_symlink_backup(tmp_path: Path) -> None:
     assert list(tmp_path.iterdir()) == []
     realdir = tmp_path / "real"
     realdir.mkdir()
@@ -72,18 +75,81 @@ def test_symlink_backup(tmp_path):
     target = relpath(real, linkdir)
     link.symlink_to(target)
     bkp = tmp_path / "backup.txt"
-    with InPlace(str(link), backup=str(bkp)) as fp:
+    with InPlace(link, backup=bkp) as fp:
         for line in fp:
             fp.write(line.swapcase())
-    assert sorted(tmp_path.iterdir(), key=attrgetter("name")) == [
-        bkp,
-        linkdir,
-        realdir,
-    ]
+    assert pylistdir(tmp_path) == ["backup.txt", "link", "real"]
     assert list(realdir.iterdir()) == [real]
     assert list(linkdir.iterdir()) == [link]
     assert link.is_symlink()
-    assert os.readlink(str(link)) == target
+    assert os.readlink(link) == target
     assert bkp.read_text() == TEXT
     assert link.read_text() == TEXT.swapcase()
     assert real.read_text() == TEXT.swapcase()
+
+
+def test_backup_is_symlink(tmp_path: Path) -> None:
+    p = tmp_path / "file.txt"
+    p.write_text(TEXT)
+    realdir = tmp_path / "realdir"
+    realdir.mkdir()
+    realfile = realdir / "realfile.txt"
+    realfile.write_text("This is a symlinked file.\n")
+    linkdir = tmp_path / "linkdir"
+    linkdir.mkdir()
+    linkfile = linkdir / "linkfile.txt"
+    target = relpath(realfile, linkdir)
+    linkfile.symlink_to(target)
+    with InPlace(p, backup=linkfile) as fp:
+        for line in fp:
+            fp.write(line.swapcase())
+    assert pylistdir(tmp_path) == ["file.txt", "linkdir", "realdir"]
+    assert not p.is_symlink()
+    assert p.read_text() == TEXT.swapcase()
+    assert pylistdir(realdir) == ["realfile.txt"]
+    assert not realfile.is_symlink()
+    assert realfile.read_text() == "This is a symlinked file.\n"
+    assert pylistdir(linkdir) == ["linkfile.txt"]
+    assert not linkfile.is_symlink()
+    assert linkfile.read_text() == TEXT
+
+
+def test_file_links_to_backup(tmp_path: Path) -> None:
+    p = tmp_path / "file.txt"
+    backup = tmp_path / "backup.txt"
+    backup.write_text(TEXT)
+    target = "backup.txt"
+    p.symlink_to(target)
+    with InPlace(p, backup=backup) as fp:
+        for line in fp:
+            fp.write(line.swapcase())
+    assert pylistdir(tmp_path) == ["backup.txt", "file.txt"]
+    assert p.is_symlink()
+    assert os.readlink(p) == target
+    assert backup.read_text() == TEXT.swapcase()
+
+
+def test_backup_links_to_file(tmp_path: Path) -> None:
+    p = tmp_path / "file.txt"
+    p.write_text(TEXT)
+    backup = tmp_path / "backup.txt"
+    target = "file.txt"
+    backup.symlink_to(target)
+    with InPlace(p, backup=backup) as fp:
+        for line in fp:
+            fp.write(line.swapcase())
+    assert pylistdir(tmp_path) == ["backup.txt", "file.txt"]
+    assert not p.is_symlink()
+    assert p.read_text() == TEXT.swapcase()
+    assert not backup.is_symlink()
+    assert backup.read_text()
+
+
+def test_broken_symlink(tmp_path: Path) -> None:
+    p = tmp_path / "file.txt"
+    target = "nowhere.txt"
+    p.symlink_to(target)
+    with pytest.raises(OSError):
+        InPlace(p)
+    assert pylistdir(tmp_path) == ["file.txt"]
+    assert os.readlink(p) == target