Repository: pydicom/pydicom

Issue:
Typing issues
Typing issues that require broader changes:

- [x] ~`iter(Dataset)` is `Iterator[BaseTag]` instead of `Iterator[DataElement]`~
- [ ] `DicomBytesIO` gives a type of `Union[TextIO, BinaryIO, BytesIO]`. Suggest refactoring to inherit BytesIO directly.
- [x] A few classes break the [Liskov Substitution Principle](https://en.wikipedia.org/wiki/Liskov_substitution_principle)
  - [x] `Sequence` `append`, `extend`, `__iadd__`, `insert` and maybe `__setitem__`. Basically anything that has the narrower parameter type `Dataset` compared to `MultiValue`
  - [x] ~`Dataset` `__contains__` because of the narrower type~

Other issues
- [x] `FileDataset.file_meta` should be refactored to `FileMetaDataset` rather than `Optional[FileMetaDataset]`
- [x] `Dataset.file_meta` add type hint but not attribute

Discussion:
codecov[bot]: ## [Codecov](https://app.codecov.io/gh/pydicom/pydicom/pull/1954?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pydicom) Report
Attention: `5 lines` in your changes are missing coverage. Please review.
> Comparison is base [(`943912b`)](https://app.codecov.io/gh/pydicom/pydicom/commit/943912bac467ecac33657fcf372d8b775c0a7384?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pydicom) 97.91% compared to head [(`6148a5d`)](https://app.codecov.io/gh/pydicom/pydicom/pull/1954?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pydicom) 97.89%.

| [Files](https://app.codecov.io/gh/pydicom/pydicom/pull/1954?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pydicom) | Patch % | Lines |
|---|---|---|
| [src/pydicom/filebase.py](https://app.codecov.io/gh/pydicom/pydicom/pull/1954?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pydicom#diff-c3JjL3B5ZGljb20vZmlsZWJhc2UucHk=) | 95.19% | [5 Missing :warning: ](https://app.codecov.io/gh/pydicom/pydicom/pull/1954?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pydicom) |

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##             main    #1954      +/-   ##
==========================================
- Coverage   97.91%   97.89%   -0.02%     
==========================================
  Files          63       63              
  Lines       10864    10856       -8     
==========================================
- Hits        10637    10627      -10     
- Misses        227      229       +2     
```



</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/pydicom/pydicom/pull/1954?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pydicom).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pydicom).


Patch:
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 3ea3f7b5c..40f45e813 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -2,17 +2,17 @@
 # See https://pre-commit.com/hooks.html for more hooks
 repos:
 - repo: https://github.com/crate-ci/typos
-  rev: v1.16.24
+  rev: v1.16.25
   hooks:
     - id: typos
       exclude: _.*_dict.py
 - repo: https://github.com/astral-sh/ruff-pre-commit
   # Ruff version.
-  rev: v0.1.7
+  rev: v0.1.8
   hooks:
   - id: ruff
     args: [src, --fix, --exit-non-zero-on-fix]
 - repo: https://github.com/psf/black
-  rev: 23.11.0
+  rev: 23.12.0
   hooks:
   - id: black
diff --git a/doc/release_notes/v3.0.0.rst b/doc/release_notes/v3.0.0.rst
index 544d16ea8..abfa64e89 100644
--- a/doc/release_notes/v3.0.0.rst
+++ b/doc/release_notes/v3.0.0.rst
@@ -49,6 +49,9 @@ Changes
   use :func:`~pydicom.filewriter.write_dataset` instead.
 * The :attr:`~pydicom.dataset.FileDataset.file_meta` elements are no longer
   modified when writing
+* :class:`~pydicom.filebase.DicomIO` now requires a readable or writeable buffer
+  during initialisation and :class:`~pydicom.filebase.DicomBytesIO` directly
+  inherits from it.
 
 Removals
 ~~~~~~~~
@@ -149,7 +152,9 @@ Fixes
   values for odd-length frames (:issue:`1968`)
 * Fixed using the incorrect encoding when writing datasets converted between
   explicit and implicit VR when only the *Transfer Syntax UID* was changed (:issue:`1943`)
-
+* Fixed the ``jpeg_ls``, ``pillow`` and ``rle`` pixel data handlers not working
+  correctly when a frame is spread across multiple fragments (:issue:`1774`)
+  
 
 Deprecations
 ------------
diff --git a/pyproject.toml b/pyproject.toml
index e6169c403..6891280d3 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -140,6 +140,7 @@ ignore_missing_imports = true
 disallow_untyped_calls = true
 disallow_untyped_defs = true
 disallow_incomplete_defs = true
+disable_error_code = ["method-assign"]
 
 [[tool.mypy.overrides]]
 # 2023-06: mypy complains for a line in this file if ignore used or not.
diff --git a/src/pydicom/dataset.py b/src/pydicom/dataset.py
index 7802d354d..7404d6d21 100644
--- a/src/pydicom/dataset.py
+++ b/src/pydicom/dataset.py
@@ -44,8 +44,6 @@ from typing import (
     overload,
 )
 
-from pydicom.filebase import DicomFileLike
-
 try:
     import numpy
 except ImportError:
@@ -66,6 +64,7 @@ from pydicom.datadict import (
 )
 from pydicom.dataelem import DataElement, DataElement_from_raw, RawDataElement
 from pydicom.encaps import encapsulate, encapsulate_extended
+from pydicom.filebase import ReadableBuffer, WriteableBuffer
 from pydicom.fileutil import path_from_pathlike, PathType
 from pydicom.misc import warn_and_log
 from pydicom.pixel_data_handlers.util import (
@@ -2312,7 +2311,7 @@ class Dataset:
 
     def save_as(
         self,
-        filename: "str | os.PathLike[AnyStr] | BinaryIO",
+        filename: str | os.PathLike[AnyStr] | BinaryIO | WriteableBuffer,
         /,
         __write_like_original: bool | None = None,
         *,
@@ -2353,7 +2352,9 @@ class Dataset:
         Parameters
         ----------
         filename : str | PathLike | BinaryIO
-            The path, file-like or buffer to write the encoded dataset to.
+            The path, file-like or writeable buffer to write the encoded
+            dataset to. If using a buffer it must have ``write()``, ``seek()``
+            and ``tell()`` methods.
         write_like_original : bool, optional
             If ``True`` (default) then write the dataset as-is, otherwise
             ensure that the dataset is written in the DICOM File Format or
@@ -2974,7 +2975,7 @@ class FileDataset(Dataset):
 
     def __init__(
         self,
-        filename_or_obj: PathType | BinaryIO | DicomFileLike,
+        filename_or_obj: PathType | BinaryIO | ReadableBuffer,
         dataset: _DatasetType,
         preamble: bytes | None = None,
         file_meta: Optional["FileMetaDataset"] = None,
@@ -2985,9 +2986,12 @@ class FileDataset(Dataset):
 
         Parameters
         ----------
-        filename_or_obj : str or PathLike or BytesIO or None
-            Full path and filename to the file, memory buffer object, or
-            ``None`` if is a :class:`io.BytesIO`.
+        filename_or_obj : str, PathLike, file-like or readable buffer
+
+            * :class:`str` or path: the full path to the dataset file
+            * file-like: a file-like object in "rb" mode
+            * readable buffer: an object with ``read()``, ``tell()`` and
+            ``seek()`` methods such as :class:`io.BytesIO`.
         dataset : Dataset or dict
             Some form of dictionary, usually a :class:`Dataset` returned from
             :func:`~pydicom.filereader.dcmread`.
@@ -3024,22 +3028,22 @@ class FileDataset(Dataset):
         self.filename: PathType | BinaryIO = ""
 
         if isinstance(filename_or_obj, str):
+            # Path to the dataset file
             filename = filename_or_obj
             self.fileobj_type = open
         elif isinstance(filename_or_obj, io.BufferedReader):
+            # File-like in "rb" mode such as open(..., "rb")
             filename = filename_or_obj.name
             # This is the appropriate constructor for io.BufferedReader
             self.fileobj_type = open
         else:
-            # use __class__ python <2.7?;
-            # https://docs.python.org/3/reference/datamodel.html
-            self.fileobj_type = filename_or_obj.__class__
+            # Readable buffer with read(), seek() and tell() methods
+            self.fileobj_type = type(filename_or_obj)
             if hasattr(filename_or_obj, "name"):
                 filename = filename_or_obj.name
             elif hasattr(filename_or_obj, "filename"):
                 filename = filename_or_obj.filename
             else:
-                # e.g. came from BytesIO or something file-like
                 self.filename = filename_or_obj
 
         self.timestamp = None
diff --git a/src/pydicom/encaps.py b/src/pydicom/encaps.py
index d198356d6..9aa210227 100644
--- a/src/pydicom/encaps.py
+++ b/src/pydicom/encaps.py
@@ -2,16 +2,16 @@
 """Functions for working with encapsulated (compressed) pixel data."""
 
 from struct import pack
-from collections.abc import Generator
+from collections.abc import Iterator
 
 import pydicom.config
 from pydicom.misc import warn_and_log
-from pydicom.filebase import DicomBytesIO, DicomFileLike
+from pydicom.filebase import DicomBytesIO, DicomIO
 from pydicom.tag import Tag, ItemTag, SequenceDelimiterTag
 
 
 # Functions for parsing encapsulated data
-def get_frame_offsets(fp: DicomFileLike) -> tuple[bool, list[int]]:
+def get_frame_offsets(fp: DicomIO) -> tuple[bool, list[int]]:
     """Return a list of the fragment offsets from the Basic Offset Table.
 
     **Basic Offset Table**
@@ -50,7 +50,7 @@ def get_frame_offsets(fp: DicomFileLike) -> tuple[bool, list[int]]:
 
     Parameters
     ----------
-    fp : filebase.DicomFileLike
+    fp : filebase.DicomIO
         The encapsulated pixel data positioned at the start of the Basic Offset
         Table. ``fp.is_little_endian`` should be set to ``True``.
 
@@ -98,7 +98,7 @@ def get_frame_offsets(fp: DicomFileLike) -> tuple[bool, list[int]]:
     return bool(length), offsets
 
 
-def get_nr_fragments(fp: DicomFileLike) -> int:
+def get_nr_fragments(fp: DicomIO) -> int:
     """Return the number of fragments in `fp`.
 
     .. versionadded:: 1.4
@@ -137,7 +137,7 @@ def get_nr_fragments(fp: DicomFileLike) -> int:
     return nr_fragments
 
 
-def generate_pixel_data_fragment(fp: DicomFileLike) -> Generator[bytes, None, None]:
+def generate_pixel_data_fragment(fp: DicomIO) -> Iterator[bytes]:
     """Yield the encapsulated pixel data fragments.
 
     For compressed (encapsulated) Transfer Syntaxes, the (7FE0,0010) *Pixel
@@ -173,7 +173,7 @@ def generate_pixel_data_fragment(fp: DicomFileLike) -> Generator[bytes, None, No
 
     Parameters
     ----------
-    fp : filebase.DicomFileLike
+    fp : filebase.DicomIO
         The encoded (7FE0,0010) *Pixel Data* element value, positioned at the
         start of the item tag for the first item after the Basic Offset Table
         item. ``fp.is_little_endian`` should be set to ``True``.
@@ -227,7 +227,7 @@ def generate_pixel_data_fragment(fp: DicomFileLike) -> Generator[bytes, None, No
 
 def generate_pixel_data_frame(
     bytestream: bytes, nr_frames: int | None = None
-) -> Generator[bytes, None, None]:
+) -> Iterator[bytes]:
     """Yield an encapsulated pixel data frame.
 
     Parameters
@@ -256,7 +256,7 @@ def generate_pixel_data_frame(
 
 def generate_pixel_data(
     bytestream: bytes, nr_frames: int | None = None
-) -> Generator[tuple[bytes, ...], None, None]:
+) -> Iterator[tuple[bytes, ...]]:
     """Yield an encapsulated pixel data frame.
 
     For the following transfer syntaxes, a fragment may not contain encoded
@@ -450,7 +450,7 @@ def defragment_data(data: bytes) -> bytes:
 
 
 # read_item modeled after filereader.ReadSequenceItem
-def read_item(fp: DicomFileLike) -> bytes | None:
+def read_item(fp: DicomIO) -> bytes | None:
     """Read and return a single Item in the fragmented data stream.
 
     Parameters
@@ -507,7 +507,7 @@ def read_item(fp: DicomFileLike) -> bytes | None:
 
 
 # Functions for encapsulating data
-def fragment_frame(frame: bytes, nr_fragments: int = 1) -> Generator[bytes, None, None]:
+def fragment_frame(frame: bytes, nr_fragments: int = 1) -> Iterator[bytes]:
     """Yield one or more fragments from `frame`.
 
     .. versionadded:: 1.2
@@ -605,7 +605,7 @@ def itemize_fragment(fragment: bytes) -> bytes:
 itemise_fragment = itemize_fragment
 
 
-def itemize_frame(frame: bytes, nr_fragments: int = 1) -> Generator[bytes, None, None]:
+def itemize_frame(frame: bytes, nr_fragments: int = 1) -> Iterator[bytes]:
     """Yield items generated from `frame`.
 
     .. versionadded:: 1.2
diff --git a/src/pydicom/filebase.py b/src/pydicom/filebase.py
index a5ad08fc9..f80891c5f 100644
--- a/src/pydicom/filebase.py
+++ b/src/pydicom/filebase.py
@@ -1,212 +1,302 @@
 # Copyright 2008-2020 pydicom authors. See LICENSE file for details.
 """Hold DicomFile class, which does basic I/O for a dicom file."""
 
+from collections.abc import Callable
 from io import BytesIO
-from struct import unpack, pack
+import os
+from struct import Struct
 from types import TracebackType
-from typing import BinaryIO, cast, TextIO, Any
-from collections.abc import Callable
+from typing import cast, Any, TypeVar, Protocol
+
+
+ExitException = tuple[
+    type[BaseException] | None, BaseException | None, TracebackType | None
+]
+Self = TypeVar("Self", bound="DicomIO")
+
+
+class ReadableBuffer(Protocol):
+    def read(self, size: int = ..., /) -> bytes:
+        ...  # pragma: no cover
 
-try:
-    from typing import Protocol  # added in 3.8
-except ImportError:
-    Protocol = object  # type: ignore[assignment]
+    def seek(self, offset: int, whence: int = ..., /) -> int:
+        ...  # pragma: no cover
 
-from pydicom.tag import Tag, BaseTag, TagType
+    def tell(self) -> int:
+        ...  # pragma: no cover
 
 
-# Customise the type hints for read() and seek()
-class Reader(Protocol):
-    def __call__(self, size: int = -1) -> bytes:
-        ...
+class WriteableBuffer(Protocol):
+    def seek(self, offset: int, whence: int = ..., /) -> int:
+        ...  # pragma: no cover
 
+    def tell(self) -> int:
+        ...  # pragma: no cover
 
-class Seeker(Protocol):
-    def __call__(self, offset: int, whence: int = 0) -> int:
-        ...
+    def write(self, b: bytes | bytearray | memoryview, /) -> int:
+        ...  # pragma: no cover
 
 
 class DicomIO:
-    """File object which holds transfer syntax info and anything else we need."""
-
-    # number of times to read if don't get requested bytes
-    max_read_attempts = 3
-
-    # default
-    defer_size = None
-
-    def __init__(self, *args: Any, **kwargs: Any) -> None:
-        # start with this by default
-        self._implicit_VR = None
-        self._little_endian = None
-        self.write: Callable[[bytes], int]
-        self.parent_read: Reader
-        self.seek: Seeker
-        self.tell: Callable[[], int]
-
-    def read_le_tag(self) -> tuple[int, int]:
-        """Read and return two unsigned shorts (little endian) from the file."""
-        bytes_read = self.read(4, need_exact_length=True)
-        return cast(tuple[int, int], unpack(b"<HH", bytes_read))
-
-    def read_be_tag(self) -> tuple[int, int]:
-        """Read and return two unsigned shorts (big endian) from the file."""
-        bytes_read = self.read(4, need_exact_length=True)
-        return cast(tuple[int, int], unpack(b">HH", bytes_read))
-
-    def write_tag(self, tag: TagType) -> None:
-        """Write a dicom tag (two unsigned shorts) to the file."""
-        # make sure is an instance of class, not just a tuple or int
-        if not isinstance(tag, BaseTag):
-            tag = Tag(tag)
-        self.write_US(tag.group)
-        self.write_US(tag.element)
-
-    def read_leUS(self) -> int:
-        """Return an unsigned short from the file with little endian byte order"""
-        val: tuple[int, ...] = unpack(b"<H", self.read(2))
-        return val[0]
-
-    def read_beUS(self) -> int:
-        """Return an unsigned short from the file with big endian byte order"""
-        val: tuple[int, ...] = unpack(b">H", self.read(2))
-        return val[0]
-
-    def read_leUL(self) -> int:
-        """Return an unsigned long read with little endian byte order"""
-        val: tuple[int, ...] = unpack(b"<L", self.read(4))
-        return val[0]
-
-    def read(self, length: int | None = None, need_exact_length: bool = False) -> bytes:
-        """Reads the required length, returns EOFError if gets less
-
-        If length is ``None``, then read all bytes
+    """Wrapper for managing buffer-like objects used when reading or writing
+    DICOM datasets.
+    """
+
+    def __init__(self, buffer: ReadableBuffer | WriteableBuffer) -> None:
+        """Create a new ``DicomIO`` instance.
+
+        Parameters
+        ----------
+        buffer : buffer-like object
+            A buffer-like object that implements:
+
+            * ``seek()`` and ``tell()`` methods with the same signatures as
+              :meth:`io.IOBase.seek` and :meth:`io.IOBase.tell`
+            * a ``read()`` method with the same signature as
+              :meth:`io.RawIOBase.read` if it supports reading data from
+              itself, and/or
+            * a ``write()`` method with the same signature as
+              :meth:`io.RawIOBase.write` if it supports writing data to itself
+
+            If `buffer` supports reading it can be used with
+            :func:`~pydicom.filereader.dcmread` as the source to decode a DICOM
+            dataset from, and if it supports writing it can be used with
+            :func:`~pydicom.filewriter.dcmwrite` as the destination for the
+            encoded DICOM dataset.
         """
-        parent_read = self.parent_read  # super(DicomIO, self).read
-        if length is None:
-            return parent_read()  # get all of it
-
-        bytes_read = parent_read(length)
-        if len(bytes_read) < length and need_exact_length:
-            # Didn't get all the desired bytes. Keep trying to get the rest.
-            # If reading across network, might want to add a delay here
-            attempts = 0
-            max_reads = self.max_read_attempts
-            while attempts < max_reads and len(bytes_read) < length:
-                bytes_read += parent_read(length - len(bytes_read))
-                attempts += 1
-            num_bytes = len(bytes_read)
-            if num_bytes < length:
-                start_pos = self.tell() - num_bytes
-                msg = (
-                    f"Unexpected end of file. Read {len(bytes_read)} bytes "
-                    f"of {length} expected starting at position "
-                    f"0x{start_pos:x}"
-                )
-                raise EOFError(msg)
-        return bytes_read
-
-    def write_leUS(self, val: int) -> None:
-        """Write an unsigned short with little endian byte order"""
-        self.write(pack(b"<H", val))
-
-    def write_leUL(self, val: int) -> None:
-        """Write an unsigned long with little endian byte order"""
-        self.write(pack(b"<L", val))
-
-    def write_beUS(self, val: int) -> None:
-        """Write an unsigned short with big endian byte order"""
-        self.write(pack(b">H", val))
-
-    def write_beUL(self, val: int) -> None:
-        """Write an unsigned long with big endian byte order"""
-        self.write(pack(b">L", val))
-
-    write_US = write_leUS
-    write_UL = write_leUL
-
-    def read_beUL(self) -> int:
-        """Return an unsigned long read with big endian byte order"""
-        val: tuple[int, ...] = unpack(b">L", self.read(4))
-        return val[0]
-
-    # Set up properties is_little_endian and is_implicit_VR
-    # Big/Little Endian changes functions to read unsigned
-    # short or long, e.g. length fields etc
+        # Data packers/unpackers
+        self._us_unpacker: Callable[[bytes], tuple[Any, ...]]
+        self._us_packer: Callable[[int], bytes]
+        self._ul_unpacker: Callable[[bytes], tuple[Any, ...]]
+        self._ul_packer: Callable[[int], bytes]
+        self._tag_unpacker: Callable[[bytes], tuple[Any, ...]]
+        self._tag_packer: Callable[[int, int], bytes]
+
+        # Store the encoding method
+        self._implicit_vr: bool
+        self._little_endian: bool
+
+        # The buffer-like object being wrapped
+        self._buffer = buffer
+
+        # It's more efficient to replace the existing class methods
+        #   instead of wrapping them
+        if hasattr(buffer, "read"):
+            self.read = buffer.read
+
+        if hasattr(buffer, "write"):
+            self.write = buffer.write
+
+        if hasattr(buffer, "close"):
+            self.close = buffer.close
+
+        # seek() and tell() are always required
+        self.seek = buffer.seek
+        self.tell = buffer.tell
+
+    def close(self, *args: Any, **kwargs: Any) -> Any:
+        """Close the buffer (if possible)"""
+        pass
+
+    def __enter__(self: Self) -> Self:
+        return self
+
+    def __exit__(self, *exc_info: ExitException) -> None:
+        self.close()
+
     @property
     def is_little_endian(self) -> bool:
-        return cast(bool, self._little_endian)
+        """Get/set the endianness for encoding/decoding, ``True`` for little
+        endian and ``False`` for big endian.
+        """
+        if not hasattr(self, "_little_endian"):
+            raise AttributeError(
+                f"{type(self).__name__}.is_little_endian' has not been set"
+            )
+
+        return self._little_endian
 
     @is_little_endian.setter
     def is_little_endian(self, value: bool) -> None:
-        self._little_endian = value  # type: ignore[assignment]
-        if value:  # Little Endian
-            self.read_US = self.read_leUS
-            self.read_UL = self.read_leUL
-            self.write_US = self.write_leUS  # type: ignore[method-assign]
-            self.write_UL = self.write_leUL  # type: ignore[method-assign]
-            self.read_tag = self.read_le_tag
-        else:  # Big Endian
-            self.read_US = self.read_beUS
-            self.read_UL = self.read_beUL
-            self.write_US = self.write_beUS  # type: ignore[method-assign]
-            self.write_UL = self.write_beUL  # type: ignore[method-assign]
-            self.read_tag = self.read_be_tag
+        if not isinstance(value, bool):
+            raise TypeError(f"'{type(self).__name__}.is_little_endian' must be bool")
+
+        self._little_endian = value
+
+        endianness = "><"[value]
+        self._us_packer = Struct(f"{endianness}H").pack
+        self._us_unpacker = Struct(f"{endianness}H").unpack
+        self._ul_packer = Struct(f"{endianness}L").pack
+        self._ul_unpacker = Struct(f"{endianness}L").unpack
+        self._tag_packer = Struct(f"{endianness}2H").pack
+        self._tag_unpacker = Struct(f"{endianness}2H").unpack
 
     @property
     def is_implicit_VR(self) -> bool:
-        return cast(bool, self._implicit_VR)
+        """Get/set the VR mode for encoding/decoding. ``True`` for implicit VR
+        and ``False`` for explicit VR.
+        """
+        if not hasattr(self, "_implicit_vr"):
+            raise AttributeError(
+                f"{type(self).__name__}.is_implicit_VR' has not been set"
+            )
+
+        return self._implicit_vr
 
     @is_implicit_VR.setter
     def is_implicit_VR(self, value: bool) -> None:
-        self._implicit_VR = value  # type: ignore[assignment]
+        if not isinstance(value, bool):
+            raise TypeError(f"'{type(self).__name__}.is_implicit_VR' must be bool")
+
+        self._implicit_vr = value
+
+    @property
+    def name(self) -> str:
+        """Return the value of the :attr:`~pydicom.filebase.DicomIO.parent`'s
+        ``name`` attribute, or ``"<no filename>"`` if no such attribute.
+        """
+        return getattr(self._buffer, "name", "<no filename>")
+
+    @property
+    def parent(self) -> ReadableBuffer | WriteableBuffer:
+        """Return the buffer object being wrapped."""
+        return self._buffer
+
+    def read(self, size: int = -1, /) -> bytes:
+        """Read up to `size` bytes from the buffer and return them. If `size`
+        is unspecified, all bytes until EOF are returned.
+
+        Fewer than `size` bytes may be returned if the operating system call
+        returns fewer than `size` bytes.
+        """
+        raise TypeError(
+            f"'{type(self).__name__}' cannot be used with "
+            f"'{type(self._buffer).__name__}': object has no read() method"
+        )
+
+    def read_exact(self, length: int, nr_retries: int = 3) -> bytes:
+        """Return `length` bytes read from the buffer.
+
+        Parameters
+        ----------
+        length : int
+            The number of bytes to be read. If ``None`` (default) then read all
+            the bytes available.
+        nr_retries : int, optional
+            The number of tries to read data when the number of bytes read
+            from the buffer is less than `length`. Default ``3``.
+
+        Returns
+        -------
+        bytes
+            The read data.
+
+        Raises
+        ------
+        EOFError
+            If unable to read `length` bytes.
+        """
+        bytes_read = self.read(length)
+        if len(bytes_read) == length:
+            return bytes_read
+
+        # Use a bytearray because concatenating bytes is expensive
+        bytes_read = bytearray(bytes_read)
+        attempts = 0
+        while (num_bytes := len(bytes_read)) < length and attempts < nr_retries:
+            bytes_read += self.read(length - num_bytes)
+            attempts += 1
+
+        if num_bytes == length:
+            return bytes(bytes_read)
+
+        raise EOFError(
+            f"Unexpected end of file. Read {num_bytes} bytes of {length} "
+            f"expected starting at position 0x{self.tell() - num_bytes:x}"
+        )
+
+    def read_tag(self) -> tuple[int, int]:
+        """Return a DICOM tag value read from the buffer."""
+        return cast(
+            tuple[int, int],
+            self._tag_unpacker(self.read_exact(4)),
+        )
+
+    def read_UL(self) -> int:
+        """Return a UL value read from the buffer."""
+        return cast(int, self._ul_unpacker(self.read(4))[0])
+
+    def read_US(self) -> int:
+        """Return a US value read from the buffer."""
+        return cast(int, self._us_unpacker(self.read(2))[0])
+
+    def seek(self, offset: int, whence: int = os.SEEK_SET, /) -> int:
+        """Change the buffer position to the given byte `offset`, relative to
+        the position indicated by `whence` and return the new absolute position.
+        """
+        raise NotImplementedError()  # pragma: no cover
+
+    def tell(self) -> int:
+        """Return the current stream position of the buffer"""
+        raise NotImplementedError()  # pragma: no cover
+
+    def write(self, b: bytes | bytearray | memoryview, /) -> int:
+        """Write the bytes-like object `b` to the buffer and return the number
+        of bytes written.
+        """
+        raise TypeError(
+            f"'{type(self).__name__}' cannot be used with "
+            f"'{type(self._buffer).__name__}': object has no write() method"
+        )
+
+    def write_tag(self, tag: int) -> None:
+        """Write a DICOM tag to the buffer."""
+        self.write(self._tag_packer(tag >> 16, tag & 0xFFFF))
+
+    def write_UL(self, val: int) -> None:
+        """Write a UL value to the buffer."""
+        self.write(self._ul_packer(val))
+
+    def write_US(self, val: int) -> None:
+        """Write a US value to the buffer."""
+        self.write(self._us_packer(val))
 
 
 class DicomFileLike(DicomIO):
-    def __init__(
-        self, file_like_obj: TextIO | BinaryIO | BytesIO, *args: Any, **kwargs: Any
-    ) -> None:
-        super().__init__(*args, **kwargs)
-        self.parent = file_like_obj
-        self.parent_read = getattr(file_like_obj, "read", self.no_read)
-        self.write = getattr(file_like_obj, "write", self.no_write)
-        self.seek = getattr(file_like_obj, "seek", self.no_seek)
-        self.tell = file_like_obj.tell
-        self.close = file_like_obj.close
-        self.name: str = getattr(file_like_obj, "name", "<no filename>")
-
-    def no_write(self, bytes_read: bytes) -> int:
-        """Used for file-like objects where no write is available"""
-        raise OSError("This DicomFileLike object has no write() method")
-
-    def no_read(self, size: int = -1) -> bytes:
-        """Used for file-like objects where no read is available"""
-        raise OSError("This DicomFileLike object has no read() method")
-
-    def no_seek(self, offset: int, whence: int = 0) -> int:
-        """Used for file-like objects where no seek is available"""
-        raise OSError("This DicomFileLike object has no seek() method")
-
-    def __enter__(self) -> "DicomFileLike":
-        return self
+    """Wrapper for file-likes to simplify encoding/decoding DICOM datasets.
 
-    def __exit__(
-        self,
-        *exc_info: tuple[
-            type[BaseException] | None, BaseException | None, TracebackType | None
-        ],
-    ) -> None:
-        self.close()
+    See Also
+    --------
+    :class:`~pydicom.filebase.DicomIO`
+    :class:`~pydicom.filebase.DicomBytesIO`
+    """
+
+    pass
 
 
 def DicomFile(*args: Any, **kwargs: Any) -> DicomFileLike:
+    """Return an opened :class:`~pydicom.filebase.DicomFileLike` from a file-like."""
     return DicomFileLike(open(*args, **kwargs))
 
 
-class DicomBytesIO(DicomFileLike):
-    def __init__(self, *args: Any, **kwargs: Any) -> None:
-        super().__init__(BytesIO(*args, **kwargs))
+class DicomBytesIO(DicomIO):
+    """Wrapper for :class:`io.BytesIO` to simplify encoding/decoding DICOM datasets.
+
+    See Also
+    --------
+    :class:`~pydicom.filebase.DicomIO`
+    :class:`~pydicom.filebase.DicomFileLike`
+    """
+
+    def __init__(self, initial_bytes: bytes | bytearray | memoryview = b"") -> None:
+        """Create a new DicomBytesIO instance.
+
+        Parameters
+        ----------
+        buffer : bytes | bytearray | memoryview, optional
+            The buffer to write to or read from, default is an empty buffer.
+        """
+        buffer = BytesIO(initial_bytes)
+        super().__init__(buffer)
 
-    def getvalue(self) -> bytes:
-        self.parent = cast(BytesIO, self.parent)
-        return self.parent.getvalue()
+        self.getvalue = buffer.getvalue
diff --git a/src/pydicom/filereader.py b/src/pydicom/filereader.py
index 1c49505b2..6cd0c8598 100644
--- a/src/pydicom/filereader.py
+++ b/src/pydicom/filereader.py
@@ -22,7 +22,7 @@ from pydicom.dataelem import (
 )
 from pydicom.dataset import Dataset, FileDataset, FileMetaDataset
 from pydicom.errors import InvalidDicomError
-from pydicom.filebase import DicomFileLike
+from pydicom.filebase import ReadableBuffer
 from pydicom.fileutil import (
     read_undefined_length_value,
     path_from_pathlike,
@@ -935,7 +935,7 @@ def read_partial(
 
 
 def dcmread(
-    fp: PathType | BinaryIO | DicomFileLike,
+    fp: PathType | BinaryIO | ReadableBuffer,
     defer_size: str | int | float | None = None,
     stop_before_pixels: bool = False,
     force: bool = False,
@@ -969,11 +969,11 @@ def dcmread(
 
     Parameters
     ----------
-    fp : str or PathLike or file-like
-        Either a file-like object, a string containing the file name or the
-        path to the file. The file-like object must have ``seek()``,
-        ``read()`` and ``tell()`` methods and the caller is responsible for
-        closing it (if required).
+    fp : str, PathLike, file-like or readable buffer
+        A file-like object, a string containing the file name or the
+        path to the file or a buffer-like object. The buffer-like object must
+        have ``seek()``, ``read()`` and ``tell()`` methods and the caller is
+        responsible for closing it (if required).
     defer_size : int, str or float, optional
         If not used then all elements are read into memory. If specified,
         then if a data element's stored value is larger than `defer_size`, the
@@ -1023,10 +1023,15 @@ def dcmread(
         caller_owns_file = False
         logger.debug(f"Reading file '{fp}'")
         fp = open(fp, "rb")
-    elif fp is None or not hasattr(fp, "read") or not hasattr(fp, "seek"):
+    elif (
+        fp is None
+        or not hasattr(fp, "read")
+        or not hasattr(fp, "seek")
+        or not hasattr(fp, "tell")
+    ):
         raise TypeError(
-            "dcmread: Expected a file path or a file-like, "
-            "but got " + type(fp).__name__
+            "dcmread: Expected a file path, file-like or readable buffer, "
+            f"but got {type(fp).__name__}"
         )
 
     if config.debugging:
diff --git a/src/pydicom/fileutil.py b/src/pydicom/fileutil.py
index 7201d8f2d..c1c1222b6 100644
--- a/src/pydicom/fileutil.py
+++ b/src/pydicom/fileutil.py
@@ -7,7 +7,7 @@ from typing import BinaryIO, cast
 from pydicom.misc import size_in_bytes
 from pydicom.tag import TupleTag, Tag, SequenceDelimiterTag, ItemTag, BaseTag
 from pydicom.datadict import dictionary_description
-from pydicom.filebase import DicomFileLike
+from pydicom.filebase import ReadableBuffer, WriteableBuffer
 
 from pydicom.config import logger
 
@@ -420,7 +420,7 @@ def length_of_undefined_length(
 
 
 def path_from_pathlike(
-    file_object: PathType | BinaryIO | DicomFileLike,
+    file_object: PathType | BinaryIO | ReadableBuffer | WriteableBuffer,
 ) -> str | BinaryIO:
     """Returns the path if `file_object` is a path-like object, otherwise the
     original `file_object`.
diff --git a/src/pydicom/filewriter.py b/src/pydicom/filewriter.py
index 40764567c..357b67970 100644
--- a/src/pydicom/filewriter.py
+++ b/src/pydicom/filewriter.py
@@ -11,7 +11,7 @@ from pydicom import config
 from pydicom.charset import default_encoding, convert_encodings, encode_string
 from pydicom.dataelem import DataElement_from_raw, DataElement, RawDataElement
 from pydicom.dataset import Dataset, validate_file_meta, FileMetaDataset
-from pydicom.filebase import DicomFile, DicomFileLike, DicomBytesIO, DicomIO
+from pydicom.filebase import DicomFile, DicomBytesIO, DicomIO, WriteableBuffer
 from pydicom.fileutil import path_from_pathlike, PathType
 from pydicom.misc import warn_and_log
 from pydicom.multival import MultiValue
@@ -722,7 +722,10 @@ def write_dataset(
     # TODO: Update in v4.0
     # In order of encoding priority
 
-    fp_encoding: EncodingType = (fp.is_implicit_VR, fp.is_little_endian)
+    fp_encoding: EncodingType = (
+        getattr(fp, "_implicit_vr", None),
+        getattr(fp, "_little_endian", None),
+    )
     ds_encoding: EncodingType = (None, None)
     if not config._use_future:
         ds_encoding = (dataset.is_implicit_VR, dataset.is_little_endian)
@@ -1054,7 +1057,7 @@ def _determine_encoding(
 
 
 def dcmwrite(
-    filename: PathType | BinaryIO | DicomFileLike,
+    filename: PathType | BinaryIO | WriteableBuffer,
     dataset: Dataset,
     /,
     __write_like_original: bool | None = None,
@@ -1066,7 +1069,7 @@ def dcmwrite(
     **kwargs: Any,
 ) -> None:
     """Write `dataset` to `filename`, which can be a path, a file-like or a
-    buffer.
+    writeable buffer.
 
     .. versionchanged:: 3.0
 
@@ -1162,8 +1165,10 @@ def dcmwrite(
 
     Parameters
     ----------
-    filename : str or PathLike or file-like
-        File path, file-like or buffer to write the encoded `dataset` to.
+    filename : str, PathLike, file-like or writeable buffer
+        File path, file-like or writeable buffer to write the encoded `dataset`
+        to. If using a writeable buffer it must have ``write()``, ``seek()``
+        and ``tell()`` methods.
     dataset : pydicom.dataset.FileDataset
         The dataset to be encoded.
     write_like_original : bool, optional
@@ -1357,17 +1362,22 @@ def dcmwrite(
     # Open file if not already a file object
     filename = path_from_pathlike(filename)
     if isinstance(filename, str):
-        fp = DicomFile(filename, "wb")
+        # A path-like to be written to
+        fp: DicomIO = DicomFile(filename, "wb")
         # caller provided a file name; we own the file handle
         caller_owns_file = False
+    elif isinstance(filename, DicomIO):
+        # A wrapped writeable buffer, don't wrap it again
+        fp = filename
     else:
+        # Anything else
         try:
-            fp = DicomFileLike(filename)
-        except AttributeError:
+            fp = DicomIO(filename)
+        except AttributeError as exc:
             raise TypeError(
-                "dcmwrite: Expected a file path or a file-like, "
+                "dcmwrite: Expected a file path, file-like or writeable buffer, "
                 f"but got {type(filename).__name__}"
-            )
+            ) from exc
 
     # Set the encoding of the buffer/file-like
     fp.is_implicit_VR, fp.is_little_endian = encoding
@@ -1391,9 +1401,7 @@ def dcmwrite(
 
             # Compress the encoded data and write to file
             compressor = zlib.compressobj(wbits=-zlib.MAX_WBITS)
-            deflated = bytearray(
-                compressor.compress(buffer.parent.getvalue())  # type: ignore[union-attr]
-            )
+            deflated = bytearray(compressor.compress(buffer.getvalue()))
             deflated += compressor.flush()
             fp.write(deflated)
             if len(deflated) % 2:
diff --git a/src/pydicom/pixel_data_handlers/jpeg_ls_handler.py b/src/pydicom/pixel_data_handlers/jpeg_ls_handler.py
index ad187dfba..045f5afa1 100644
--- a/src/pydicom/pixel_data_handlers/jpeg_ls_handler.py
+++ b/src/pydicom/pixel_data_handlers/jpeg_ls_handler.py
@@ -19,7 +19,7 @@ try:
 except ImportError:
     HAVE_JPEGLS = False
 
-from pydicom.encaps import decode_data_sequence, defragment_data
+from pydicom.encaps import generate_pixel_data_frame
 from pydicom.pixel_data_handlers.util import pixel_dtype, get_nr_frames
 import pydicom.uid
 
@@ -107,15 +107,10 @@ def get_pixeldata(ds: "Dataset") -> "numpy.ndarray":
 
     pixel_bytes = bytearray()
 
-    nr_frames = get_nr_frames(ds)
-    if nr_frames > 1:
-        for src in decode_data_sequence(ds.PixelData):
-            frame = jpeg_ls.decode(numpy.frombuffer(src, dtype="u1"))
-            pixel_bytes.extend(frame.tobytes())
-    else:
-        src = defragment_data(ds.PixelData)
-        frame = jpeg_ls.decode(numpy.frombuffer(src, dtype="u1"))
-        pixel_bytes.extend(frame.tobytes())
+    nr_frames = get_nr_frames(ds, warn=False)
+    for frame in generate_pixel_data_frame(ds.PixelData, nr_frames):
+        im = jpeg_ls.decode(numpy.frombuffer(frame, dtype="u1"))
+        pixel_bytes.extend(im.tobytes())
 
     arr = numpy.frombuffer(pixel_bytes, pixel_dtype(ds))
 
diff --git a/src/pydicom/pixel_data_handlers/pillow_handler.py b/src/pydicom/pixel_data_handlers/pillow_handler.py
index aa183b380..f5373df1e 100644
--- a/src/pydicom/pixel_data_handlers/pillow_handler.py
+++ b/src/pydicom/pixel_data_handlers/pillow_handler.py
@@ -29,7 +29,7 @@ except ImportError:
     HAVE_JPEG2K = False
 
 from pydicom import config
-from pydicom.encaps import defragment_data, decode_data_sequence
+from pydicom.encaps import generate_pixel_data_frame
 from pydicom.misc import warn_and_log
 from pydicom.pixel_data_handlers.util import (
     pixel_dtype,
@@ -180,8 +180,8 @@ def get_pixeldata(ds: "Dataset") -> "numpy.ndarray":
 
     if transfer_syntax == JPEGExtended12Bit and ds.BitsAllocated != 8:
         raise NotImplementedError(
-            f"{JPEGExtended12Bit} - {JPEGExtended12Bit.name} only supported "
-            "by Pillow if Bits Allocated = 8"
+            f"{JPEGExtended12Bit} - {JPEGExtended12Bit.name} is only supported "
+            "by Pillow if (0028,0100) Bits Allocated = 8"
         )
 
     photometric_interpretation = cast(str, ds.PhotometricInterpretation)
@@ -189,38 +189,22 @@ def get_pixeldata(ds: "Dataset") -> "numpy.ndarray":
     columns = cast(int, ds.Columns)
     bits_stored = cast(int, ds.BitsStored)
     bits_allocated = cast(int, ds.BitsAllocated)
-    nr_frames = get_nr_frames(ds)
+    nr_frames = get_nr_frames(ds, warn=False)
 
     pixel_bytes = bytearray()
-    if nr_frames > 1:
-        j2k_precision, j2k_sign = None, None
-        # multiple compressed frames
-        for frame in decode_data_sequence(ds.PixelData):
-            im = _decompress_single_frame(
-                frame, transfer_syntax, photometric_interpretation
-            )
-            if "YBR" in photometric_interpretation:
-                im.draft("YCbCr", (rows, columns))
-            pixel_bytes.extend(im.tobytes())
-
-            if not j2k_precision:
-                params = get_j2k_parameters(frame)
-                j2k_precision = cast(int, params.setdefault("precision", bits_stored))
-                j2k_sign = params.setdefault("is_signed", None)
-
-    else:
-        # single compressed frame
-        pixel_data = defragment_data(ds.PixelData)
+    j2k_precision, j2k_sign = None, None
+    for frame in generate_pixel_data_frame(ds.PixelData, nr_frames):
         im = _decompress_single_frame(
-            pixel_data, transfer_syntax, photometric_interpretation
+            frame, transfer_syntax, photometric_interpretation
         )
         if "YBR" in photometric_interpretation:
             im.draft("YCbCr", (rows, columns))
         pixel_bytes.extend(im.tobytes())
 
-        params = get_j2k_parameters(pixel_data)
-        j2k_precision = cast(int, params.setdefault("precision", bits_stored))
-        j2k_sign = params.setdefault("is_signed", None)
+        if not j2k_precision:
+            params = get_j2k_parameters(frame)
+            j2k_precision = cast(int, params.setdefault("precision", bits_stored))
+            j2k_sign = params.setdefault("is_signed", None)
 
     logger.debug(f"Successfully read {len(pixel_bytes)} pixel bytes")
 
diff --git a/src/pydicom/pixel_data_handlers/pylibjpeg_handler.py b/src/pydicom/pixel_data_handlers/pylibjpeg_handler.py
index 6175e7f1b..edeea557c 100644
--- a/src/pydicom/pixel_data_handlers/pylibjpeg_handler.py
+++ b/src/pydicom/pixel_data_handlers/pylibjpeg_handler.py
@@ -258,7 +258,7 @@ def generate_frames(ds: "Dataset", reshape: bool = True) -> Iterable["np.ndarray
     if missing:
         raise AttributeError(
             "Unable to convert the pixel data as the following required "
-            "elements are missing from the dataset: " + ", ".join(missing)
+            f"elements are missing from the dataset: {', '.join(missing)}"
         )
 
     decoder = _DECODERS[tsyntax]
diff --git a/src/pydicom/pixel_data_handlers/rle_handler.py b/src/pydicom/pixel_data_handlers/rle_handler.py
index a1dd8a693..46e5f7c01 100644
--- a/src/pydicom/pixel_data_handlers/rle_handler.py
+++ b/src/pydicom/pixel_data_handlers/rle_handler.py
@@ -46,7 +46,7 @@ try:
 except ImportError:
     HAVE_RLE = False
 
-from pydicom.encaps import decode_data_sequence, defragment_data
+from pydicom.encaps import generate_pixel_data_frame
 from pydicom.misc import warn_and_log
 from pydicom.pixel_data_handlers.util import pixel_dtype, get_nr_frames
 from pydicom.encoders.native import _encode_frame  # noqa: F401
@@ -154,28 +154,17 @@ def get_pixeldata(ds: "Dataset", rle_segment_order: str = ">") -> "np.ndarray":
 
     nr_bits = cast(int, ds.BitsAllocated)
     nr_samples = cast(int, ds.SamplesPerPixel)
-    nr_frames = get_nr_frames(ds)
+    nr_frames = get_nr_frames(ds, warn=False)
     rows = cast(int, ds.Rows)
     cols = cast(int, ds.Columns)
 
     # Decompress each frame of the pixel data
     pixel_data = bytearray()
-    if nr_frames > 1:
-        for rle_frame in decode_data_sequence(ds.PixelData):
-            frame = _rle_decode_frame(
-                rle_frame, rows, cols, nr_samples, nr_bits, rle_segment_order
-            )
-            pixel_data.extend(frame)
-    else:
-        frame = _rle_decode_frame(
-            defragment_data(ds.PixelData),
-            rows,
-            cols,
-            nr_samples,
-            nr_bits,
-            rle_segment_order,
+    for frame in generate_pixel_data_frame(ds.PixelData, nr_frames):
+        im = _rle_decode_frame(
+            frame, rows, cols, nr_samples, nr_bits, rle_segment_order
         )
-        pixel_data.extend(frame)
+        pixel_data.extend(im)
 
     arr = np.frombuffer(pixel_data, pixel_dtype(ds))
 


Test patch:
diff --git a/tests/test_dataset.py b/tests/test_dataset.py
index 21122866f..52fdaa862 100644
--- a/tests/test_dataset.py
+++ b/tests/test_dataset.py
@@ -115,8 +115,6 @@ class TestDataset:
         ds2.BeamSequence[0].BeamNumber = "2"
         assert ds2 != ds1
 
-        fp.close()
-
     def test_attribute_error_in_property_correct_debug(self):
         """Test AttributeError in property raises correctly."""
 
diff --git a/tests/test_encoders.py b/tests/test_encoders.py
index 6a7024e2d..acf4f837f 100644
--- a/tests/test_encoders.py
+++ b/tests/test_encoders.py
@@ -1058,7 +1058,7 @@ class TestDatasetCompress:
         ds.PixelData = None
         ds._pixel_array = None
         ds.compress(RLELossless, arr, encoding_plugin="pydicom")
-        assert id(ds.pixel_array) != id(arr)
+        assert arr is not ds.pixel_array
         assert np.array_equal(arr, ds.pixel_array)
 
     def test_planar_configuration(self):
diff --git a/tests/test_filebase.py b/tests/test_filebase.py
index a2d814583..482d8a3ff 100644
--- a/tests/test_filebase.py
+++ b/tests/test_filebase.py
@@ -18,26 +18,29 @@ class TestDicomIO:
 
     def test_init(self):
         """Test __init__"""
-        # All the subclasses override this anyway
-        fp = DicomIO()
-        assert fp.is_implicit_VR is None
-        assert fp.is_little_endian is None
-
-    def test_read_le_tag(self):
-        """Test DicomIO.read_le_tag indirectly"""
+        fp = DicomIO(BytesIO())
+        assert not hasattr(fp, "is_implicit_VR")
+        assert not hasattr(fp, "is_little_endian")
+
+    def test_parent(self):
+        """Test DicomIO.parent"""
+        buffer = BytesIO()
+        fp = DicomIO(buffer)
+        assert fp.parent is buffer
+
+    def test_read_tag(self):
+        """Test DicomIO.read_tag indirectly"""
         # Tags are 2 + 2 = 4 bytes
         bytestream = b"\x01\x02\x03\x04\x05\x06"
+        # Little endian
         fp = DicomBytesIO(bytestream)
         fp.is_little_endian = True
-        assert Tag(fp.read_le_tag()) == 0x02010403
+        assert Tag(fp.read_tag()) == 0x02010403
 
-    def test_read_be_tag(self):
-        """Test DicomIO.read_be_tag indirectly"""
-        # Tags are 2 + 2 = 4 bytes
-        bytestream = b"\x01\x02\x03\x04\x05\x06"
+        # Big endian
         fp = DicomBytesIO(bytestream)
         fp.is_little_endian = False
-        assert Tag(fp.read_be_tag()) == 0x01020304
+        assert Tag(fp.read_tag()) == 0x01020304
 
     def test_write_tag(self):
         """Test DicomIO.write_tag indirectly"""
@@ -55,129 +58,141 @@ class TestDicomIO:
         fp.write_tag(tag)
         assert fp.getvalue() == b"\x01\x02\x03\x04"
 
-    def test_read_le_us(self):
-        """Test DicomIO.read_leUS indirectly"""
+    def test_read_us(self):
+        """Test DicomIO.read_US"""
         # US are 2 bytes fixed
         bytestream = b"\x00\x00\xFF\x00\xFE\xFF"
+        # Little endian
         fp = DicomBytesIO(bytestream)
         fp.is_little_endian = True
-        assert fp.read_leUS() == 0
-        assert fp.read_leUS() == 255
-        assert fp.read_leUS() == 65534
+        assert fp.read_US() == 0
+        assert fp.read_US() == 255
+        assert fp.read_US() == 65534
 
-    def test_read_be_us(self):
-        """Test DicomIO.read_beUS indirectly"""
-        # US are 2 bytes fixed
-        bytestream = b"\x00\x00\x00\xFF\xFF\xFE"
+        # Big endian
         fp = DicomBytesIO(bytestream)
         fp.is_little_endian = True
-        assert fp.read_beUS() == 0
-        assert fp.read_beUS() == 255
-        assert fp.read_beUS() == 0xFFFE
+        assert fp.read_US() == 0
+        assert fp.read_US() == 255
+        assert fp.read_US() == 0xFFFE
 
-    def test_write_le_us(self):
-        """Test DicomIO.write_leUS indirectly"""
+    def test_write_us(self):
+        """Test DicomIO.write_US"""
+        # Little endian
         fp = DicomBytesIO()
         fp.is_little_endian = True
         assert fp.getvalue() == b""
-        fp.write_leUS(0)
+        fp.write_US(0)
         assert fp.getvalue() == b"\x00\x00"
-        fp.write_leUS(255)
+        fp.write_US(255)
         assert fp.getvalue() == b"\x00\x00\xFF\x00"
-        fp.write_leUS(65534)
+        fp.write_US(65534)
         assert fp.getvalue() == b"\x00\x00\xFF\x00\xFE\xFF"
 
-    def test_write_be_us(self):
-        """Test DicomIO.write_beUS indirectly"""
+        # Big endian
         fp = DicomBytesIO()
         fp.is_little_endian = False
         assert fp.getvalue() == b""
-        fp.write_beUS(0)
+        fp.write_US(0)
         assert fp.getvalue() == b"\x00\x00"
-        fp.write_beUS(255)
+        fp.write_US(255)
         assert fp.getvalue() == b"\x00\x00\x00\xFF"
-        fp.write_beUS(65534)
+        fp.write_US(65534)
         assert fp.getvalue() == b"\x00\x00\x00\xFF\xFF\xFE"
 
-    def test_read_le_ul(self):
-        """Test DicomIO.read_leUL indirectly"""
+    def test_read_ul(self):
+        """Test DicomIO.read_UL"""
         # UL are 4 bytes fixed
         bytestream = b"\x00\x00\x00\x00\xFF\xFF\x00\x00\xFE\xFF\xFF\xFF"
+        # Little endian
         fp = DicomBytesIO(bytestream)
         fp.is_little_endian = True
-        assert fp.read_leUL() == 0
-        assert fp.read_leUL() == 0xFFFF
-        assert fp.read_leUL() == 0xFFFFFFFE
+        assert fp.read_UL() == 0
+        assert fp.read_UL() == 0xFFFF
+        assert fp.read_UL() == 0xFFFFFFFE
 
-    def test_read_be_ul(self):
-        """Test DicomIO.read_beUL indirectly"""
-        # UL are 4 bytes fixed
+        # Big endian
         bytestream = b"\x00\x00\x00\x00\x00\x00\xFF\xFF\xFF\xFF\xFF\xFE"
         fp = DicomBytesIO(bytestream)
         fp.is_little_endian = False
-        assert fp.read_beUL() == 0
-        assert fp.read_beUL() == 0xFFFF
-        assert fp.read_beUL() == 0xFFFFFFFE
+        assert fp.read_UL() == 0
+        assert fp.read_UL() == 0xFFFF
+        assert fp.read_UL() == 0xFFFFFFFE
 
-    def test_write_le_ul(self):
-        """Test DicomIO.write_leUL indirectly"""
+    def test_write_ul(self):
+        """Test DicomIO.write_UL indirectly"""
+        # Little endian
         fp = DicomBytesIO()
         fp.is_little_endian = True
         assert fp.getvalue() == b""
-        fp.write_leUL(0)
+        fp.write_UL(0)
         assert fp.getvalue() == b"\x00\x00\x00\x00"
-        fp.write_leUL(65535)
+        fp.write_UL(65535)
         assert fp.getvalue() == b"\x00\x00\x00\x00\xFF\xFF\x00\x00"
-        fp.write_leUL(4294967294)
+        fp.write_UL(4294967294)
         assert fp.getvalue() == (b"\x00\x00\x00\x00\xFF\xFF\x00\x00\xFE\xFF\xFF\xFF")
 
-    def test_write_be_ul(self):
-        """Test DicomIO.write_beUL indirectly"""
+        # Big endian
         fp = DicomBytesIO()
         fp.is_little_endian = False
         assert fp.getvalue() == b""
-        fp.write_beUL(0)
+        fp.write_UL(0)
         assert fp.getvalue() == b"\x00\x00\x00\x00"
-        fp.write_beUL(65535)
+        fp.write_UL(65535)
         assert fp.getvalue() == b"\x00\x00\x00\x00\x00\x00\xFF\xFF"
-        fp.write_beUL(4294967294)
+        fp.write_UL(4294967294)
         assert fp.getvalue() == (b"\x00\x00\x00\x00\x00\x00\xFF\xFF\xFF\xFF\xFF\xFE")
 
     def test_read(self):
         """Test DicomIO.read entire length"""
         fp = DicomBytesIO(b"\x00\x01\x03")
-        fp.is_little_endian = True
-        bytestream = fp.read(length=None, need_exact_length=False)
+        bytestream = fp.read()
         assert bytestream == b"\x00\x01\x03"
 
     def test_read_length(self):
         """Test DicomIO.read specific length"""
         fp = DicomBytesIO(b"\x00\x01\x03")
-        fp.is_little_endian = True
-        bytestream = fp.read(length=2, need_exact_length=False)
+        bytestream = fp.read(2)
         assert bytestream == b"\x00\x01"
 
-    def test_read_exact_length(self):
+    def test_read_exact(self):
         """Test DicomIO.read exact length"""
         fp = DicomBytesIO(b"\x00\x01\x03\x04")
-        fp.is_little_endian = True
-        bytestream = fp.read(length=4, need_exact_length=True)
+        bytestream = fp.read_exact(length=4)
+        assert bytestream == b"\x00\x01\x03\x04"
+
+    def test_read_exact_retry(self):
+        """Test DicomIO.read exact length success after retry"""
+
+        class Foo:
+            idx = 0
+            seek = None
+            tell = None
+
+            def read(self, length):
+                if self.idx == 0:
+                    self.idx += 1
+                    return b"\x00"
+
+                return b"\x01\x03\x04"
+
+        fp = DicomIO(Foo())
+        bytestream = fp.read_exact(length=4)
         assert bytestream == b"\x00\x01\x03\x04"
 
     def test_read_exact_length_raises(self):
         """Test DicomIO.read exact length raises if short"""
         fp = DicomBytesIO(b"\x00\x01\x03")
-        fp.is_little_endian = True
         with pytest.raises(
             EOFError,
             match="Unexpected end of file. Read 3 bytes of 4 "
             "expected starting at position 0x0",
         ):
-            fp.read(length=4, need_exact_length=True)
+            fp.read_exact(length=4)
 
     def test_getter_is_little_endian(self):
         """Test DicomIO.is_little_endian getter"""
-        fp = DicomIO()
+        fp = DicomIO(BytesIO())
         fp.is_little_endian = True
         assert fp.is_little_endian
         fp.is_little_endian = False
@@ -185,69 +200,80 @@ class TestDicomIO:
 
     def test_setter_is_little_endian(self):
         """Test DicomIO.is_little_endian setter"""
-        fp = DicomIO()
-        fp.is_little_endian = True
-        assert fp.read_US == fp.read_leUS
-        assert fp.read_UL == fp.read_leUL
-        assert fp.write_US == fp.write_leUS
-        assert fp.write_UL == fp.write_leUL
-        assert fp.read_tag == fp.read_le_tag
-
-        fp.is_little_endian = False
-        assert fp.read_US == fp.read_beUS
-        assert fp.read_UL == fp.read_beUL
-        assert fp.write_US == fp.write_beUS
-        assert fp.write_UL == fp.write_beUL
-        assert fp.read_tag == fp.read_be_tag
+        fp = DicomIO(BytesIO())
+        for is_little in (True, False):
+            fp.is_little_endian = is_little
+            assert hasattr(fp, "_us_packer")
+            assert hasattr(fp, "_us_unpacker")
+            assert hasattr(fp, "_ul_packer")
+            assert hasattr(fp, "_ul_unpacker")
+            assert hasattr(fp, "_us_packer")
+            assert hasattr(fp, "_tag_packer")
+            assert hasattr(fp, "_tag_unpacker")
+
+        msg = "'DicomIO.is_little_endian' must be bool"
+        with pytest.raises(TypeError, match=msg):
+            fp.is_little_endian = None
 
     def test_is_implicit_vr(self):
         """Test DicomIO.is_implicit_VR"""
-        fp = DicomIO()
+        fp = DicomIO(BytesIO())
         fp.is_implicit_VR = True
         assert fp.is_implicit_VR
         fp.is_implicit_VR = False
         assert not fp.is_implicit_VR
 
+        msg = "'DicomIO.is_implicit_VR' must be bool"
+        with pytest.raises(TypeError, match=msg):
+            fp.is_implicit_VR = None
 
-class TestDicomFileLike:
-    """Test filebase.DicomFileLike class"""
+    def test_methods_raise(self):
+        """Test various DicomIO methods raise exceptions."""
 
-    def test_init_good_parent(self):
-        """Test methods are set OK if parent is good"""
-        fp = DicomFileLike(BytesIO())
-        assert fp.parent_read == fp.parent.read
-        assert fp.write == fp.parent.write
-        assert fp.seek == fp.parent.seek
+        class Reader:
+            def read(self):
+                pass
 
-    def test_init_bad_parent(self):
-        """Test exceptions raised if parent has no IO methods"""
+            def seek(self):
+                pass
 
-        class IntPlus(int):
             def tell(self):
                 pass
 
-            def close(self):
+        class Writer:
+            def write(self, b):
                 pass
 
-        fp = DicomFileLike(IntPlus)
-        with pytest.raises(
-            OSError, match=r"This DicomFileLike object has no write\(\) " r"method"
-        ):
-            fp.write(b"")
-        with pytest.raises(
-            OSError, match=r"This DicomFileLike object has no read\(\) " r"method"
-        ):
-            fp.parent_read(b"")
-        with pytest.raises(
-            OSError, match=r"This DicomFileLike object has no seek\(\) " r"method"
-        ):
-            fp.seek(0, 1)
+            def seek(self):
+                pass
+
+            def tell(self):
+                pass
+
+        with pytest.raises(TypeError, match=r"object has no read\(\) method"):
+            DicomIO(Writer()).read(2)
+        with pytest.raises(TypeError, match=r"object has no write\(\) method"):
+            DicomIO(Reader()).write(b"")
+
+        fp = DicomIO(Reader())
         assert fp.name == "<no filename>"
+        fp.close()  # no exceptions
+
+    def test_init_good_buffer(self):
+        """Test methods are set OK if buffer is good"""
+        buffer = BytesIO()
+        fp = DicomFileLike(buffer)
+        assert fp._buffer == buffer
+        assert fp.write == buffer.write
+        assert fp.seek == buffer.seek
+        assert fp.read == buffer.read
+        assert fp.tell == buffer.tell
+        assert fp.close == buffer.close
 
     def test_context(self):
         """Test using DicomFileLike as a context"""
-        with DicomFileLike(BytesIO(b"\x00\x01")) as fp:
-            assert fp.parent_read(2) == b"\x00\x01"
+        with DicomIO(BytesIO(b"\x00\x01")) as fp:
+            assert fp.read(2) == b"\x00\x01"
 
 
 class TestDicomBytesIO:
@@ -265,7 +291,7 @@ class TestDicomFile:
     def test_read(self):
         """Test the function"""
         with DicomFile(TEST_FILE, "rb") as fp:
-            assert not fp.parent.closed
+            assert not fp._buffer.closed
             # Weird issue with Python 3.6 sometimes returning
             #   lowercase file path on Windows
             assert "ct_small.dcm" in fp.name.lower()
diff --git a/tests/test_filereader.py b/tests/test_filereader.py
index b09aea1b2..3c8abddd1 100644
--- a/tests/test_filereader.py
+++ b/tests/test_filereader.py
@@ -805,12 +805,18 @@ class TestReader:
             dcmread("InvalidFilePath")
         with pytest.raises(
             TypeError,
-            match="dcmread: Expected a file path or " "a file-like, but got None",
+            match=(
+                "dcmread: Expected a file path, file-like or readable "
+                "buffer, but got NoneType"
+            ),
         ):
             dcmread(None)
         with pytest.raises(
             TypeError,
-            match="dcmread: Expected a file path or " "a file-like, but got int",
+            match=(
+                "dcmread: Expected a file path, file-like or readable "
+                "buffer, but got int"
+            ),
         ):
             dcmread(42)
 
diff --git a/tests/test_fileset.py b/tests/test_fileset.py
index 7d45582b4..9fb9fcdde 100644
--- a/tests/test_fileset.py
+++ b/tests/test_fileset.py
@@ -149,7 +149,7 @@ def private(dicomdir):
         fp.is_little_endian = True
         write_dataset(fp, ds)
 
-        return fp.parent.getvalue()
+        return fp.getvalue()
 
     def private_record():
         record = Dataset()
diff --git a/tests/test_filewriter.py b/tests/test_filewriter.py
index 79ad65e3b..08905c59c 100644
--- a/tests/test_filewriter.py
+++ b/tests/test_filewriter.py
@@ -393,7 +393,7 @@ class TestWriteDataElement:
             fp.is_implicit_VR = is_implicit_VR
             fp.is_little_endian = is_little_endian
             write_data_element(fp, elem)
-            return fp.parent.getvalue()
+            return fp.getvalue()
 
     def test_empty_AT(self):
         """Write empty AT correctly.........."""
@@ -1476,12 +1476,18 @@ class TestDCMWrite:
         ds = dcmread(ct_name)
         with pytest.raises(
             TypeError,
-            match="dcmwrite: Expected a file path or a file-like, but got None",
+            match=(
+                "dcmwrite: Expected a file path, file-like or writeable "
+                "buffer, but got NoneType"
+            ),
         ):
             ds.save_as(None)
         with pytest.raises(
             TypeError,
-            match="dcmwrite: Expected a file path or a file-like, but got int",
+            match=(
+                "dcmwrite: Expected a file path, file-like or writeable "
+                "buffer, but got int"
+            ),
         ):
             ds.save_as(42)
 
diff --git a/tests/test_jpeg_ls_pixel_data.py b/tests/test_jpeg_ls_pixel_data.py
index 979ff4c63..d5f5bad42 100644
--- a/tests/test_jpeg_ls_pixel_data.py
+++ b/tests/test_jpeg_ls_pixel_data.py
@@ -4,11 +4,19 @@
 import os
 import sys
 
+try:
+    import numpy as np
+
+    HAVE_NP = True
+except ImportError:
+    HAVE_NP = False
+
 import pytest
 
 import pydicom
-from pydicom.filereader import dcmread
 from pydicom.data import get_testdata_file
+from pydicom.encaps import encapsulate, generate_pixel_data_frame
+from pydicom.filereader import dcmread
 from pydicom.pixel_data_handlers import numpy_handler
 from pydicom.pixel_data_handlers import jpeg_ls_handler
 
@@ -180,6 +188,16 @@ class TestJPEGLS_JPEG_LS_with_jpeg_ls:
         b = self.emri_small.pixel_array
         assert b.mean() == a.mean()
 
+    @pytest.mark.skipif(not HAVE_NP, reason="Numpy not available")
+    def test_frame_multiple_fragments(self):
+        """Test a frame split across multiple fragments."""
+        ds = dcmread(jpeg_ls_lossless_name)
+        ref = ds.pixel_array
+        frame = next(generate_pixel_data_frame(ds.PixelData, 1))
+        ds.PixelData = encapsulate([frame, frame], fragments_per_frame=4)
+        ds.NumberOfFrames = 2
+        assert np.array_equal(ds.pixel_array[0], ref)
+
 
 @pytest.mark.skipif(not test_jpeg_ls_decoder, reason=jpeg_ls_missing_message)
 class TestJPEGLS_JPEG2000_with_jpeg_ls:
diff --git a/tests/test_pillow_pixel_data.py b/tests/test_pillow_pixel_data.py
index b560df72a..bceb9a351 100644
--- a/tests/test_pillow_pixel_data.py
+++ b/tests/test_pillow_pixel_data.py
@@ -4,7 +4,7 @@ import pytest
 
 import pydicom
 from pydicom.data import get_testdata_file
-from pydicom.encaps import defragment_data
+from pydicom.encaps import defragment_data, generate_pixel_data_frame, encapsulate
 from pydicom.filereader import dcmread
 from pydicom.pixel_data_handlers.util import convert_color_space, get_j2k_parameters
 from pydicom.uid import (
@@ -682,8 +682,8 @@ class TestPillowHandler_JPEG:
         """Test decoding JPEG lossy with pillow handler fails."""
         ds = dcmread(JPGE_16_12_1_0_1F_M2)
         msg = (
-            r"1.2.840.10008.1.2.4.51 - JPEG Extended \(Process 2 and 4\) only "
-            r"supported by Pillow if Bits Allocated = 8"
+            r"1.2.840.10008.1.2.4.51 - JPEG Extended \(Process 2 and 4\) is only "
+            r"supported by Pillow if \(0028,0100\) Bits Allocated = 8"
         )
         with pytest.raises(NotImplementedError, match=msg):
             ds.pixel_array
@@ -701,3 +701,11 @@ class TestPillowHandler_JPEG:
         pixel_data = ds.pixel_array
         assert pixel_data.nbytes == 27
         assert pixel_data.shape == (3, 3, 3)
+
+    def test_frame_multiple_fragments(self):
+        """Test a frame split across multiple fragments."""
+        ds = dcmread(JPGB_08_08_3_0_120F_YBR_FULL_422)
+        ref = ds.pixel_array
+        frames = [f for f in generate_pixel_data_frame(ds.PixelData, ds.NumberOfFrames)]
+        ds.PixelData = encapsulate(frames, fragments_per_frame=4)
+        assert np.array_equal(ds.pixel_array, ref)
diff --git a/tests/test_rle_pixel_data.py b/tests/test_rle_pixel_data.py
index f0a887596..013c74145 100644
--- a/tests/test_rle_pixel_data.py
+++ b/tests/test_rle_pixel_data.py
@@ -30,7 +30,7 @@ import pytest
 from pydicom import dcmread
 import pydicom.config
 from pydicom.data import get_testdata_file
-from pydicom.encaps import defragment_data
+from pydicom.encaps import defragment_data, generate_pixel_data_frame, encapsulate
 from pydicom.uid import RLELossless, AllTransferSyntaxes, ExplicitVRLittleEndian
 
 try:
@@ -719,6 +719,15 @@ class TestNumpy_RLEHandler:
         # Frame 2 is frame 1 inverted
         assert np.array_equal((2**ds.BitsAllocated - 1) - arr[1], arr[0])
 
+    def test_frame_multiple_fragments(self):
+        """Test a frame split across multiple fragments."""
+        ds = dcmread(RLE_8_1_2F)
+        ref = ds.pixel_array
+
+        frames = [f for f in generate_pixel_data_frame(ds.PixelData, ds.NumberOfFrames)]
+        ds.PixelData = encapsulate(frames, fragments_per_frame=4)
+        assert np.array_equal(ds.pixel_array, ref)
+
     def test_writing_decompressed(self):
         """Test writing a decompressed dataset."""
         ds = dcmread(RLE_8_1_1F)