Repository: pyxem/kikuchipy (language: python)

Issue:
Support HyperSpy 2.0
HyperSpy 2.0 is slated for release around mid-September according to https://github.com/hyperspy/hyperspy/issues/2996#issuecomment-1657103453. What does it take for kikuchipy to be compatible with this release?

An example of kikuchipy's failing test suite run with HyperSpy 2.0: https://github.com/hyperspy/hyperspy-extensions-list/actions/runs/5563155043/job/15068218388.

Current incompatibilities that I'm aware of are related to HyperSpy's IO moving to RosettaSciIO, which we should use. Features that must be handled:
- [x] Saving to HSPY/ZSPY and image formats.
- [x]  Internal use of the function `overwrite_dataset()` for overwriting. We can import this from the `hspy` plugin in HyperSpy 1.7.5, but not from the same plugin in RosettaSciIO 0.1.
- [x] Use of `rgb_tools`. We can import this from HyperSpy 1.7.5, but not from RosettaSciIO 0.1.

Other incompatibilities:
- [x] Our creation of markers from geometrical EBSD simulations may will not work with the new marker collections in https://github.com/hyperspy/hyperspy/pull/3148. Our markers are updated in #695.
Use RosettaSciIO and adapt to their IO plugin specification architecture
#### Description of the change
<!-- Remember to branch off the develop branch for new features and the main branch for patches. -->
This PR is a step towards supporting HyperSpy 2.0 (see #650).

kikuchipy's signals can currently be written to HyperSpy's HSPY (HDF5) file format via e.g. `EBSD.save()`. This reader is now imported from RosettaSciIO.

In Rosetta, the HSPY plugin specification is moved from the implementation file (previously accessible via `hspy.file_extensions` etc.) to a separate YAML file. A new plugin look-up architecture to access these specifications have been added there. This PR adopts this architecture for our plugins, so that they (1) work with Rosetta and (2) can be ported there with little effort later on.

HyperSpy's ZSPY (zarr) file format will be available like the HSPY file format is with this PR.

~I hope kikuchipy v0.9 after this PR can be compatible with both HyperSpy 1.7.3 and 2.0 and use RosettaSciIO 0.1.~

**Challenges**

kikuchipy's plugin look-up is "smarter" than Rosetta's for HDF5 files in that it doesn't need a format name when several plugins use the same file extension (say, .hdf5); instead, it checks for a "footprint" in the HDF5 file. The footprint is an HDF5 dataset path, such as "EMdata/EBSD/EBSDPatterns" for EMsoft's simulated EBSD pattern file format. The correct plugin for the file is determined when checking this footprint. The plugin specification therefore differs from Rosetta's in that it requires this footprint field. I hope this "smart" look-up can be added to Rosetta when the plugins are moved there.

Another new thing in Rosetta is that `file_writer()` expects a dictionary and not a signal. How is this dictionary obtained? In HyperSpy, it is done via a private `BaseSignal._to_dictionary()`. In kikuchipy, the `file_writer()` functions are public. I'm not sure if they are used anywhere else than in kikuchipy's `save()` methods. By requiring a dictionary instead of a signal in these functions, and not giving users an easy way to obtain this dictionary, the file writers become more difficult to use than they currently are. This must be handled correctly. Anyway, to comply with Rosetta's architecture, our `file_writer()` functions should accept dictionaries as well. We should use these dictionaries internally instead of passing signals. For now, we should allow both signals and dictionaries, but perhaps deprecate passing signals.

#### Progress of the PR
- [x] [Docstrings for all functions](https://github.com/numpy/numpy/blob/master/doc/example.py)
- [x] Unit tests with pytest for all lines
- [x] Clean code style by [running black via pre-commit](https://kikuchipy.org/en/latest/contributing.html#code-style)
- [x] Allow a signal dictionary in `file_writer()`
- [x] Load `rgb_tools` from Rosetta
- [ ] Update user guide examples and tutorials (e.g. add ZSPY to load/save tutorial)
- [ ] Fix use of new markers with ragged arrays

#### For reviewers
<!-- Don't remove the checklist below. -->
- [ ] The PR title is short, concise, and will make sense 1 year later.
- [ ] New functions are imported in corresponding `__init__.py`.
- [ ] New features, API changes, and deprecations are mentioned in the unreleased
      section in `CHANGELOG.rst`.
- [ ] New contributors are added to `release.py`, `.zenodo.json` and
      `.all-contributorsrc` with the table regenerated.



Discussion / hints:
> * Use of `rgb_tools`. We can import this from HyperSpy 1.7.5, but not from RosettaSciIO 0.1.

I have to correct myself. This is not part of the [listed public API](https://rosettasciio.readthedocs.io/en/stable/supported_formats/hspy.html), but it can be imported as `from rsciio.utils import rgb_tools`.

> * Internal use of the function `overwrite_dataset()` for overwriting. We can import this from the `hspy` plugin in HyperSpy 1.7.5, but not from the same plugin in RosettaSciIO 0.1.

This function, however, can only be imported as `from rsciio.hspy._api import overwrite_dataset`. Thus, it is not part of the public API nor can it be imported from a "public looking" module.
@hakonanes assuming that the markers PR gets merged that will also have to be changed. 

I was planning on making a PR for that once everything starts to get fixed there. 

It might be worthwhile to think if there is anything that better annotation might help with.  I've been thinking about overlaying phase information on the navigator.  That or it might be nice to  make something like arrange multiple navigators using an abstraction of https://github.com/hyperspy/hyperspy/pull/3140. 

It might also be good to let hyperspy plot colored plots for navigators as well.  
The use of markers must be changed as well, of course. I didn't think of this. Thanks. A PR with the necessary changes would be great!

We navigate EBSD signals using phase and orientation color maps in kikuchipy already (see e.g. our [visualization tutorial](https://kikuchipy.org/en/stable/tutorials/visualizing_patterns.html#Any-image)). It is very useful. We have a convenience function for this, [get_rgb_navigator()](https://kikuchipy.org/en/stable/reference/generated/kikuchipy.draw.get_rgb_navigator.html), which takes an array of shape (n rows, n columns, 3) and returns a suitable signal with `rgb8` or `rgb16` data type. This might be a good utility function to move to HyperSpy?

Linking multiple navigators would be useful, indeed. This might already be possible with the current events functionality? I haven't tried it. A convenience function would be useful, accepting a signal and multiple suitable arrays or signals to use as navigators. 
This work will be pushed to a v0.10.0 release as the new release of PyEBSDIndex v0.2.0 breaks kikuchipy from conda-forge (not from PyPI). See the progress of that work at #652.
@hakonanes I was going to see about changing the marker implementation but I'm not sure I fully understand how the io plugins work in kikuchipy. 
Thank you for showing interest in using HyperSpy 2.0 in kikuchipy! I'm almost done repurposing our IO plugins to use RosettaSciIO in #651. It might be easier to make kikuchipy compatible with HS 2.0 after that.

I don't think I'll finish that PR the coming week, though. I plan to use my spare time on orix instead. I'll get back to you once the PR is done!
@hakonanes any movement on this?  It would be good to make a hyperspy-bundle release relatively soon.
None since mid December. I plan to finish this in the latter half of February. A minor release will be made soon after that. If the bundle can't wait, I guess releasing one without kikuchipy is the only option.
@hakonanes I don't think there is any major rush on the bundle, these things just take time. At the very latest it would be good to make a bundle release before the next hyperspy/hyperspy affiliated training session.  (I'm not sure exactly when that is)

Let me know if you need some help!  I realize that starting a new job etc. means that you might not have as much to time to devote to some of these things.
> Let me know if you need some help!

Thank you, that is very much appreciated.

> I realize that starting a new job etc. means that you might not have as much to time to devote to some of these things.

Yes, I naturally have to reduce my commitment to adding new features to softwares I help maintain. But I will help maintain them (discuss here, fix issues and the like).
I've restarted this work as I tried to do all compatibility changes at once in https://github.com/pyxem/kikuchipy/pull/651. Instead, I've created a hyperspy2-support branch in this repo. Smaller PRs towards v2 compatiblity goes into this branch. This is hopefully more manageable.
Check out this pull request on&nbsp; <a href="https://app.reviewnb.com/pyxem/kikuchipy/pull/651"><img align="absmiddle"  alt="ReviewNB" height="28" class="BotMessageButtonImage" src="https://raw.githubusercontent.com/ReviewNB/support/master/images/button_reviewnb.png"/></a> 

 See visual diffs & provide feedback on Jupyter Notebooks. 

---

 <i>Powered by <a href='https://www.reviewnb.com/?utm_source=gh'>ReviewNB</a></i>
Rebased on #652.

Fix patch:
diff --git a/CHANGELOG.rst b/CHANGELOG.rst
index 35d3029f..c67ee3ea 100644
--- a/CHANGELOG.rst
+++ b/CHANGELOG.rst
@@ -18,11 +18,13 @@ Unreleased
 
 Added
 -----
+- Dependency on RosettaSciIO for read/write of some file formats.
+  (`#? <https://github.com/pyxem/kikuchipy/pull/?>`_)
 
 Changed
 -------
 - Minimum Python version is now 3.10.
-  (`#? <https://github.com/pyxem/kikuchipy/pull/?>`_)
+  (`#689 <https://github.com/pyxem/kikuchipy/pull/689>`_)
 
 Removed
 -------
diff --git a/doc/conf.py b/doc/conf.py
index 5e85394f..083caef8 100644
--- a/doc/conf.py
+++ b/doc/conf.py
@@ -79,6 +79,7 @@
     "pyvista": ("https://docs.pyvista.org", None),
     "pyxem": ("https://pyxem.readthedocs.io/en/latest", None),
     "readthedocs": ("https://docs.readthedocs.io/en/stable", None),
+    "rosettasciio": ("https://rosettasciio.readthedocs.io/en/stable", None),
     "scipy": ("https://docs.scipy.org/doc/scipy", None),
     "skimage": ("https://scikit-image.org/docs/stable", None),
     "sklearn": ("https://scikit-learn.org/stable", None),
diff --git a/doc/environment.yml b/doc/environment.yml
index f72b6022..a08a04dc 100644
--- a/doc/environment.yml
+++ b/doc/environment.yml
@@ -3,4 +3,5 @@ channels:
 dependencies:
   - pip
   - python=3.11
-  - pandoc>=2.14.2, <4.0.0
\ No newline at end of file
+  - pandoc>=2.14.2, <4.0.0
+  - ipykernel
\ No newline at end of file
diff --git a/doc/user/installation.rst b/doc/user/installation.rst
index be19d15c..b3915834 100644
--- a/doc/user/installation.rst
+++ b/doc/user/installation.rst
@@ -106,11 +106,11 @@ This is a list of core package dependencies:
 * :doc:`orix <orix:index>`: Handling of rotations and vectors using crystal symmetry
 * :doc:`pooch <pooch:api/index>`: Downloading and caching of datasets
 * `pyyaml <https://pyyaml.org/>`__: Parcing of YAML files
-* `tqdm <https://tqdm.github.io/>`__: Progressbars
 * :doc:`scikit-image <skimage:index>`: Image processing like adaptive histogram
   equalization
 * `scikit-learn <https://scikit-learn.org/stable/>`__: Multivariate analysis
 * :doc:`scipy <scipy:index>`: Optimization algorithms, filtering and more
+* `tqdm <https://tqdm.github.io/>`__: Progressbars
 
 .. _lazy_loader: https://scientific-python.org/specs/spec-0001/#lazy_loader
 
diff --git a/examples/selecting_data/crop_signal_axes.py b/examples/selecting_data/crop_signal_axes.py
index b13a7321..ea4db775 100644
--- a/examples/selecting_data/crop_signal_axes.py
+++ b/examples/selecting_data/crop_signal_axes.py
@@ -5,7 +5,7 @@
 This example shows various ways to crop the signal axes of an
 :class:`~kikuchipy.signals.EBSD` signal using HyperSpy's ``isig`` slicer and the
 :meth:`~kikuchipy.signals.EBSD.crop` and
-:meth:`~hyperspy._signals.signal2d.Signal2D.crop_image` methods (see
+:meth:`~hyperspy._signals.signal2d.Signal2D.crop_signal` methods (see
 :ref:`hyperspy:signal.indexing` for details).
 """
 
@@ -49,10 +49,10 @@
 print(s3.detector)
 
 # %%
-# Do the same inplace using ``crop_image()``
+# Do the same inplace using ``crop_signal()``
 
 s4 = s.deepcopy()
-s4.crop_image(top=10, bottom=50, left=5, right=55)
+s4.crop_signal(top=10, bottom=50, left=5, right=55)
 
 _ = hs.plot.plot_images(s4, **plot_kwds)
 print(s4)
diff --git a/hyperspy_extension.yaml b/hyperspy_extension.yaml
deleted file mode 100644
index 0a366982..00000000
--- a/hyperspy_extension.yaml
+++ /dev/null
@@ -1,75 +0,0 @@
-# Copyright 2019-2024 The kikuchipy developers
-#
-# This file is part of kikuchipy.
-#
-# kikuchipy is free software: you can redistribute it and/or modify
-# it under the terms of the GNU General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-#
-# kikuchipy is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-# GNU General Public License for more details.
-#
-# You should have received a copy of the GNU General Public License
-# along with kikuchipy. If not, see <http://www.gnu.org/licenses/>.
-
-signals:
-  EBSD:
-    signal_type: EBSD
-    signal_type_aliases:
-      - electron_backscatter_diffraction
-    signal_dimension: 2
-    dtype: real
-    lazy: False
-    module: kikuchipy.signals.ebsd
-  LazyEBSD:
-    signal_type: EBSD
-    signal_dimension: 2
-    dtype: real
-    lazy: True
-    module: kikuchipy.signals.ebsd
-  EBSDMasterPattern:
-    signal_type: EBSDMasterPattern
-    signal_type_aliases:
-      - ebsd_master_pattern
-      - master_pattern
-    signal_dimension: 2
-    dtype: real
-    lazy: False
-    module: kikuchipy.signals.ebsd_master_pattern
-  LazyEBSDMasterPattern:
-    signal_type: EBSDMasterPattern
-    signal_dimension: 2
-    dtype: real
-    lazy: True
-    module: kikuchipy.signals.ebsd_master_pattern
-  ECPMasterPattern:
-    signal_type: ECPMasterPattern
-    signal_type_aliases:
-      - ecp_master_pattern
-    signal_dimension: 2
-    dtype: real
-    lazy: False
-    module: kikuchipy.signals.ecp_master_pattern
-  LazyECPMasterPattern:
-    signal_type: ECPMasterPattern
-    signal_dimension: 2
-    dtype: real
-    lazy: True
-    module: kikuchipy.signals.ecp_master_pattern
-  LazyVirtualBSEImage:
-    signal_type: VirtualBSEImage
-    signal_dimension: 2
-    dtype: real
-    lazy: True
-    module: kikuchipy.signals.virtual_bse_image
-  VirtualBSEImage:
-    signal_type: VirtualBSEImage
-    signal_type_aliases:
-      - virtual_backscatter_electron_image
-    signal_dimension: 2
-    dtype: real
-    lazy: False
-    module: kikuchipy.signals.virtual_bse_image
diff --git a/pyproject.toml b/pyproject.toml
index fc208df3..75015376 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -2,6 +2,8 @@
 requires = ["hatchling"]
 build-backend = "hatchling.build"
 
+# ----------------------------- Project ------------------------------ #
+
 [project]
 name = "kikuchipy"
 authors = [{name = "kikuchipy developers"}]
@@ -40,33 +42,23 @@ dependencies = [
     "dask[array]      >= 2021.8.1",
     "diffpy.structure >= 3",
     "diffsims         >= 0.5.2",
-    "hyperspy         >= 2",
+    # TODO: Replace with HyperSpy 2.2 once it's out
+    "hyperspy @ git+https://github.com/hyperspy/hyperspy.git@RELEASE_next_patch",
     "h5py             >= 2.10",
     "imageio",
     "lazy_loader",
     "matplotlib       >= 3.5",
     "numba            >= 0.57",
-    # TODO: Remove pinning of NumPy <2 once HyperSpy 2.0 is supported
-    "numpy            >= 1.23.0, <2",
+    "numpy            >= 1.23.0",
     "orix             >= 0.12.1",
     "pooch            >= 1.3.0",
     "pyyaml",
-    "tqdm             >= 0.5.2",
     "scikit-image     >= 0.16.2",
     "scikit-learn",
     "scipy            >= 1.7",
+    "tqdm             >= 0.5.2",
 ]
 
-[project.entry-points."hyperspy.extensions"]
-kikuchipy = "kikuchipy"
-
-[project.urls]
-Documentation = "https://kikuchipy.org"
-Download = "https://pypi.python.org/pypi/kikuchipy"
-"Bug Tracker" = "https://github.com/pyxem/kikuchipy/issues"
-"Source Code" = "https://github.com/pyxem/kikuchipy"
-Changelog = "https://kikuchipy.org/en/stable/changelog.html"
-
 [project.optional-dependencies]
 all = [
     "nlopt",
@@ -108,22 +100,34 @@ dev = [
     "kikuchipy[doc,tests,coverage]",
 ]
 
+[project.entry-points."hyperspy.extensions"]
+kikuchipy = "kikuchipy"
+
+[project.urls]
+Documentation = "https://kikuchipy.org"
+Download = "https://pypi.python.org/pypi/kikuchipy"
+"Bug Tracker" = "https://github.com/pyxem/kikuchipy/issues"
+"Source Code" = "https://github.com/pyxem/kikuchipy"
+Changelog = "https://kikuchipy.org/en/stable/changelog.html"
+
+# ------------------------------- Tool ------------------------------- #
+
 [tool.hatch.version]
 path = "src/kikuchipy/__init__.py"
 
-# TODO: Remove this section and the duplicated file when HS 2.0 is
-# supported
-[tool.hatch.build.force-include]
-"hyperspy_extension.yaml" = "hyperspy_extension.yaml"
-
 # https://github.com/pypa/hatch/discussions/427
 # https://github.com/pypa/hatch/issues/492
+[tool.hatch.build.targets.wheel.sources]
+src = ""
+
 [tool.hatch.build.targets.wheel]
 include = [
     "src/kikuchipy",
 ]
-[tool.hatch.build.targets.wheel.sources]
-"src" = ""
+
+# TODO: Remove once HyperSpy is not installed from a direct reference (GitHub)
+[tool.hatch.metadata]
+allow-direct-references = true
 
 [tool.coverage.report]
 precision = 2
diff --git a/src/kikuchipy/_util/_deprecated.py b/src/kikuchipy/_util/_deprecated.py
index 0676ee68..f3bea08c 100644
--- a/src/kikuchipy/_util/_deprecated.py
+++ b/src/kikuchipy/_util/_deprecated.py
@@ -31,7 +31,7 @@
 from typing import Callable
 import warnings
 
-import numpy as np
+from kikuchipy.constants import VisibleDeprecationWarning
 
 
 class deprecated:
@@ -93,12 +93,12 @@ def __call__(self, func: Callable) -> None:
         @functools.wraps(func)
         def wrapped(*args, **kwargs) -> Callable:
             warnings.simplefilter(
-                action="always", category=np.VisibleDeprecationWarning, append=True
+                action="always", category=VisibleDeprecationWarning, append=True
             )
             func_code = func.__code__
             warnings.warn_explicit(
                 message=msg,
-                category=np.VisibleDeprecationWarning,
+                category=VisibleDeprecationWarning,
                 filename=func_code.co_filename,
                 lineno=func_code.co_firstlineno + 1,
             )
@@ -151,12 +151,12 @@ def wrapped(*args, **kwargs):
                     msg += f"Use `{self.alternative}` instead. "
                 msg += f"See the documentation of `{func.__name__}()` for more details."
                 warnings.simplefilter(
-                    action="always", category=np.VisibleDeprecationWarning
+                    action="always", category=VisibleDeprecationWarning
                 )
                 func_code = func.__code__
                 warnings.warn_explicit(
                     message=msg,
-                    category=np.VisibleDeprecationWarning,
+                    category=VisibleDeprecationWarning,
                     filename=func_code.co_filename,
                     lineno=func_code.co_firstlineno + 1,
                 )
diff --git a/src/kikuchipy/constants.py b/src/kikuchipy/constants.py
index 71041b46..6cd6f049 100644
--- a/src/kikuchipy/constants.py
+++ b/src/kikuchipy/constants.py
@@ -51,3 +51,15 @@
     # Have to use bare except because PyOpenCL might raise its own
     # LogicError, but we also want to catch import errors here
     pyopencl_context_available = False
+
+
+# TODO: Remove and use numpy.exceptions.VisibleDeprecationWarning once
+# NumPy 1.25 is minimal supported version
+try:
+    # Added in NumPy 1.25.0
+    from numpy.exceptions import VisibleDeprecationWarning
+except ImportError:  # pragma: no cover
+    # Removed in NumPy 2.0.0
+    from numpy import VisibleDeprecationWarning
+
+del optional_deps, version
diff --git a/src/kikuchipy/detectors/ebsd_detector.py b/src/kikuchipy/detectors/ebsd_detector.py
index fc411af7..99ddf8d7 100644
--- a/src/kikuchipy/detectors/ebsd_detector.py
+++ b/src/kikuchipy/detectors/ebsd_detector.py
@@ -226,7 +226,7 @@ def __init__(
 
     def __repr__(self) -> str:
         decimals = 3
-        pc_average = tuple(self.pc_average.round(decimals))
+        pc_average = tuple(map(float, self.pc_average.round(decimals)))
         sample_tilt = np.round(self.sample_tilt, decimals)
         tilt = np.round(self.tilt, decimals)
         azimuthal = np.round(self.azimuthal, decimals)
@@ -234,7 +234,7 @@ def __repr__(self) -> str:
         return (
             f"{type(self).__name__}"
             f"(shape={self.shape}, "
-            f"pc={pc_average!r}, "
+            f"pc={pc_average}, "
             f"sample_tilt={sample_tilt}, "
             f"tilt={tilt}, "
             f"azimuthal={azimuthal}, "
diff --git a/src/kikuchipy/draw/markers.py b/src/kikuchipy/draw/markers.py
deleted file mode 100644
index 83051e7d..00000000
--- a/src/kikuchipy/draw/markers.py
+++ /dev/null
@@ -1,117 +0,0 @@
-# Copyright 2019-2024 The kikuchipy developers
-#
-# This file is part of kikuchipy.
-#
-# kikuchipy is free software: you can redistribute it and/or modify
-# it under the terms of the GNU General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-#
-# kikuchipy is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-# GNU General Public License for more details.
-#
-# You should have received a copy of the GNU General Public License
-# along with kikuchipy. If not, see <http://www.gnu.org/licenses/>.
-
-"""Creation of lists of HyperSpy markers."""
-
-from hyperspy.utils.markers import line_segment, point, text
-import numpy as np
-
-
-def get_line_segment_list(lines: np.ndarray | list, **kwargs) -> list:
-    """Return a list of line segment markers.
-
-    Parameters
-    ----------
-    lines
-        On the form [[x00, y00, x01, y01], [x10, y10, x11, y11], ...].
-    kwargs
-        Keyword arguments allowed by :func:`matplotlib.pyplot.axvline`.
-
-    Returns
-    -------
-    marker_list
-        List of
-        :class:`hyperspy.drawing._markers.line_segment.LineSegment`.
-    """
-    lines = np.asarray(lines)
-    if lines.ndim == 1:  # No navigation shape, one line
-        lines = lines[np.newaxis, ...]
-
-    marker_list = []
-    for i in range(lines.shape[-2]):  # Iterate over bands
-        if not np.allclose(lines[..., i, :], np.nan, equal_nan=True):
-            x1 = lines[..., i, 0]
-            y1 = lines[..., i, 1]
-            x2 = lines[..., i, 2]
-            y2 = lines[..., i, 3]
-            marker_list.append(line_segment(x1=x1, y1=y1, x2=x2, y2=y2, **kwargs))
-    return marker_list
-
-
-def get_point_list(points: np.ndarray | list, **kwargs) -> list:
-    """Return a list of point markers.
-
-    Parameters
-    ----------
-    points
-        On the form [[x0, y0], [x1, y1], ...].
-    kwargs
-        Keyword arguments allowed by :func:`matplotlib.pyplot.scatter`.
-
-    Returns
-    -------
-    marker_list
-        List of :class:`hyperspy.drawing._markers.point.Point`.
-    """
-    points = np.asarray(points)
-    if points.ndim == 1:
-        points = points[np.newaxis, ...]
-
-    marker_list = []
-    for i in range(points.shape[-2]):  # Iterate over zone axes
-        if not np.allclose(points[..., i, :], np.nan, equal_nan=True):
-            marker_list.append(
-                point(x=points[..., i, 0], y=points[..., i, 1], **kwargs)
-            )
-    return marker_list
-
-
-def get_text_list(
-    texts: np.ndarray | list, coordinates: np.ndarray | list, **kwargs
-) -> list:
-    """Return a list of text markers.
-
-    Parameters
-    ----------
-    texts
-        A list of texts.
-    coordinates
-        On the form [[x0, y0], [x1, y1], ...].
-    kwargs
-        Keyword arguments allowed by :func:`matplotlib.pyplot.axvline.`
-
-    Returns
-    -------
-    marker_list
-        List of :class:`hyperspy.drawing._markers.text.Text`.
-    """
-    coordinates = np.asarray(coordinates)
-    if coordinates.ndim == 1:
-        coordinates = coordinates[np.newaxis, ...]
-
-    marker_list = []
-    is_finite = np.isfinite(coordinates)[..., 0]
-    coordinates[~is_finite] = -1
-    for i in range(coordinates.shape[-2]):  # Iterate over zone axes
-        if not np.allclose(coordinates[..., i, :], -1):  # All NaNs
-            x = coordinates[..., i, 0]
-            y = coordinates[..., i, 1]
-            x[~is_finite[..., i]] = np.nan
-            y[~is_finite[..., i]] = np.nan
-            text_marker = text(x=x, y=y, text=texts[i], **kwargs)
-            marker_list.append(text_marker)
-    return marker_list
diff --git a/src/kikuchipy/imaging/vbse.py b/src/kikuchipy/imaging/vbse.py
index bb9f6f72..fda6ca2d 100644
--- a/src/kikuchipy/imaging/vbse.py
+++ b/src/kikuchipy/imaging/vbse.py
@@ -17,10 +17,6 @@
 
 from dask.array import Array
 from hyperspy._signals.signal2d import Signal2D
-from hyperspy.drawing._markers.horizontal_line import HorizontalLine
-from hyperspy.drawing._markers.rectangle import Rectangle
-from hyperspy.drawing._markers.text import Text
-from hyperspy.drawing._markers.vertical_line import VerticalLine
 from hyperspy.roi import BaseInteractiveROI, RectangularROI
 import numpy as np
 
@@ -48,7 +44,11 @@ class VirtualBSEImager:
 
     def __init__(self, signal: EBSD | LazyEBSD) -> None:
         self.signal = signal
-        self._grid_shape = (5, 5)
+        sig_shape = signal._signal_shape_rc
+        grid_shape = ()
+        for axis_length in sig_shape:
+            grid_shape += (min(5, axis_length),)
+        self.grid_shape = grid_shape
 
     def __repr__(self) -> str:
         return self.__class__.__name__ + " for " + repr(self.signal)
@@ -70,20 +70,36 @@ def grid_cols(self) -> np.ndarray:
         return np.linspace(0, sx, gx + 1)
 
     @property
-    def grid_shape(self) -> tuple:
+    def grid_shape(self) -> tuple[int, int]:
         """Return or set the generator grid shape.
 
         Parameters
         ----------
         shape : tuple or list of int
-            Generator grid shape.
+            Generator grid shape (n rows, n cols). Must correspond to a
+            tuple of length equal to the number of signal dimensions of
+            :attr:`signal`. Cannot be greater than signal shape of
+            :attr:`signal`.
         """
         return self._grid_shape
 
     @grid_shape.setter
     def grid_shape(self, shape: tuple[int, int] | list[int]) -> None:
         """Set the generator grid shape."""
-        self._grid_shape = tuple(shape)
+        shape = tuple(shape)
+        ndim_sig = self.signal.axes_manager.signal_dimension
+        if len(shape) != ndim_sig:
+            raise ValueError(
+                "Grid shape must have the same length as number of signal dimensions "
+                f"{ndim_sig}"
+            )
+        sig_shape_rc = self.signal._signal_shape_rc
+        if any(i > j for i, j in zip(shape, self.signal._signal_shape_rc)):
+            raise ValueError(
+                f"Grid shape (n rows, n cols) = {shape} cannot be greater than signal "
+                f"shape {sig_shape_rc}"
+            )
+        self._grid_shape = shape
 
     def get_rgb_image(
         self,
diff --git a/src/kikuchipy/indexing/_hough_indexing.py b/src/kikuchipy/indexing/_hough_indexing.py
index a8555492..09906b1e 100644
--- a/src/kikuchipy/indexing/_hough_indexing.py
+++ b/src/kikuchipy/indexing/_hough_indexing.py
@@ -378,10 +378,10 @@ def _indexer_is_compatible_with_kikuchipy(
     # PC and orientation convention
     if indexer.vendor.lower() != "kikuchipy":
         compatible = False
-        error_msg = f"`indexer.vendor` must be 'kikuchipy', but was {indexer.vendor}."
+        error_msg = f"'indexer.vendor' must be 'kikuchipy', but was {indexer.vendor}"
 
     # Detector shape
-    sig_shape_indexer = tuple(indexer.bandDetectPlan.patDim)
+    sig_shape_indexer = tuple(map(int, indexer.bandDetectPlan.patDim))
     if compatible and sig_shape != sig_shape_indexer:
         compatible = False
         error_msg = (
@@ -508,10 +508,8 @@ def _get_info_message(nav_size: int, chunksize: int, indexer: "EBSDIndexer") ->
     if pc.size > 3:
         pc = pc.mean(0)
         info += ", mean"
-    pc = pc.round(4)
-    info += (
-        f"): {tuple(pc)}\n" f"  Indexing {nav_size} pattern(s) in {n_chunks} chunk(s)"
-    )
+    pc = tuple(map(float, pc.round(4)))
+    info += f"): {pc}\n" f"  Indexing {nav_size} pattern(s) in {n_chunks} chunk(s)"
 
     return info
 
diff --git a/src/kikuchipy/io/_io.py b/src/kikuchipy/io/_io.py
index 4abd031a..55e083d5 100644
--- a/src/kikuchipy/io/_io.py
+++ b/src/kikuchipy/io/_io.py
@@ -16,51 +16,32 @@
 # along with kikuchipy. If not, see <http://www.gnu.org/licenses/>.
 
 import glob
+import importlib
 import os
 from pathlib import Path
 from typing import TYPE_CHECKING
 
 import h5py
-from hyperspy.io_plugins import hspy
-from hyperspy.misc.io.tools import overwrite as overwrite_method
-from hyperspy.misc.utils import find_subclasses, strlist2enumeration
+from hyperspy.misc.utils import find_subclasses
 from hyperspy.signal import BaseSignal
 import numpy as np
+import yaml
 
-from kikuchipy.io._util import _ensure_directory, _get_input_bool
-from kikuchipy.io.plugins import (
-    bruker_h5ebsd,
-    ebsd_directory,
-    edax_binary,
-    edax_h5ebsd,
-    emsoft_ebsd,
-    emsoft_ebsd_master_pattern,
-    emsoft_ecp_master_pattern,
-    emsoft_tkd_master_pattern,
-    kikuchipy_h5ebsd,
-    nordif,
-    nordif_calibration_patterns,
-    oxford_binary,
-    oxford_h5ebsd,
-)
+from kikuchipy.io._util import _ensure_directory, _get_input_bool, _overwrite
 import kikuchipy.signals
 
-plugins: list = [
-    bruker_h5ebsd,
-    ebsd_directory,
-    edax_binary,
-    edax_h5ebsd,
-    emsoft_ebsd,
-    emsoft_ebsd_master_pattern,
-    emsoft_ecp_master_pattern,
-    emsoft_tkd_master_pattern,
-    hspy,
-    kikuchipy_h5ebsd,
-    nordif,
-    nordif_calibration_patterns,
-    oxford_binary,
-    oxford_h5ebsd,
-]
+PLUGINS: list = []
+WRITE_EXTENSIONS = []
+specification_paths = list(Path(__file__).parent.rglob("specification.yaml"))
+for path in specification_paths:
+    with open(path) as file:
+        spec = yaml.safe_load(file)
+        spec["api"] = ".".join(path.parts[-5:-1])
+        PLUGINS.append(spec)
+        if spec["writes"]:
+            for ext in spec["file_extensions"]:
+                WRITE_EXTENSIONS.append(ext)
+
 
 if TYPE_CHECKING:  # pragma: no cover
     from kikuchipy.signals.ebsd import EBSD, LazyEBSD
@@ -70,22 +51,13 @@
     )
     from kikuchipy.signals.ecp_master_pattern import ECPMasterPattern
 
-default_write_ext = set()
-for plugin in plugins:
-    if plugin.writes:
-        default_write_ext.add(plugin.file_extensions[plugin.default_extension])
-
 
 def load(
     filename: str | Path, lazy: bool = False, **kwargs
 ) -> "EBSD | EBSDMasterPattern | ECPMasterPattern | list[EBSD] | list[EBSDMasterPattern] | list[ECPMasterPattern]":
-    """Load an :class:`~kikuchipy.signals.EBSD`,
-    :class:`~kikuchipy.signals.EBSDMasterPattern` or
-    :class:`~kikuchipy.signals.ECPMasterPattern` signal from one of the
+    """Load a supported signal from one of the
     :ref:`/tutorials/load_save_data.ipynb#Supported-file-formats`.
 
-    This function is a modified version of :func:`hyperspy.io.load`.
-
     Parameters
     ----------
     filename
@@ -93,7 +65,7 @@ def load(
     lazy
         Open the data lazily without actually reading the data from disk
         until required. Allows opening arbitrary sized datasets. Default
-        is ``False``.
+        is False.
     **kwargs
         Keyword arguments passed to the corresponding kikuchipy reader.
         See their individual documentation for available options.
@@ -108,9 +80,13 @@ def load(
     IOError
         If the file was not found or could not be read.
 
+    Notes
+    -----
+    This function is a modified version of :func:`hyperspy.io.load`.
+
     Examples
     --------
-    Import nine patterns from an HDF5 file in a directory ``DATA_PATH``
+    Import nine patterns from an HDF5 file in a directory DATA_PATH
 
     >>> import kikuchipy as kp
     >>> s = kp.load(DATA_PATH / "patterns.h5")
@@ -130,22 +106,26 @@ def load(
     # Find matching reader for file extension
     extension = os.path.splitext(filename)[1][1:]
     readers = []
-    for plugin in plugins:
-        if extension.lower() in plugin.file_extensions:
+    for plugin in PLUGINS:
+        if extension.lower() in plugin["file_extensions"]:
             readers.append(plugin)
-    if len(readers) == 0:
+
+    reader = None
+    if len(readers) == 1:
+        reader = readers[0]
+    elif len(readers) > 1 and h5py.is_hdf5(filename):
+        reader = _plugin_from_footprints(filename, plugins=readers)
+
+    if len(readers) == 0 or reader is None:
         raise IOError(
             f"Could not read {filename!r}. If the file format is supported, please "
             "report this error"
         )
-    elif len(readers) > 1 and h5py.is_hdf5(filename):
-        reader = _plugin_from_footprints(filename, plugins=readers)
-    else:
-        reader = readers[0]
 
-    # Get data and metadata (from potentially multiple signals if an h5ebsd
-    # file)
-    signal_dicts = reader.file_reader(filename, lazy=lazy, **kwargs)
+    # Get data and metadata (from potentially multiple signals if we're
+    # reading from an h5ebsd file)
+    file_reader = importlib.import_module(reader["api"]).file_reader
+    signal_dicts = file_reader(filename, lazy=lazy, **kwargs)
     out = []
     for signal in signal_dicts:
         out.append(_dict2signal(signal, lazy=lazy))
@@ -166,13 +146,11 @@ def _dict2signal(
 ) -> "EBSD | LazyEBSD | EBSDMasterPattern | LazyEBSDMasterPattern":
     """Create a signal instance from a dictionary.
 
-    This function is a modified version :func:`hyperspy.io.dict2signal`.
-
     Parameters
     ----------
     signal_dict
-        Signal dictionary with ``data``, ``metadata`` and
-        ``original_metadata``.
+        Signal dictionary with "data", "metadata", and
+        "original_metadata" keys.
     lazy
         Open the data lazily without actually reading the data from disk
         until required. Allows opening arbitrary sized datasets. Default
@@ -181,8 +159,13 @@ def _dict2signal(
     Returns
     -------
     signal
-        Signal instance with ``data``, ``metadata`` and
-        ``original_metadata`` from dictionary.
+        Signal instance with "data", "metadata", and "original_metadata"
+        keys.
+
+    Notes
+    -----
+    This function is a modified version of
+    :func:`hyperspy.io.dict2signal`.
     """
     signal_type = ""
     if "metadata" in signal_dict:
@@ -191,7 +174,7 @@ def _dict2signal(
             record_by = md["Signal"]["record_by"]
             if record_by != "image":
                 raise ValueError(
-                    "kikuchipy only supports `record_by = image`, not " f"{record_by}."
+                    "kikuchipy only supports `record_by = image`, not " f"{record_by}"
                 )
             del md["Signal"]["record_by"]
         if "Signal" in md and "signal_type" in md["Signal"]:
@@ -211,15 +194,16 @@ def _dict2signal(
 
 
 def _plugin_from_footprints(filename: str, plugins) -> object:
-    """Get HDF5 correct plugin from a list of potential plugins based on
-    their unique footprints.
+    """Return correct HDF5 plugin from a list of potential plugins based
+    on their unique footprints.
 
     The unique footprint is a list of strings that can take on either of
     two formats:
-        * group/dataset names separated by "/", indicating nested
-          groups/datasets
-        * single group/dataset name indicating that the groups/datasets
-          are in the top group
+
+    * group/dataset names separated by "/", indicating nested
+      groups/datasets
+    * single group/dataset name indicating that the groups/datasets are
+      in the top group
 
     Parameters
     ----------
@@ -231,8 +215,7 @@ def _plugin_from_footprints(filename: str, plugins) -> object:
     Returns
     -------
     plugin
-        One of the potential plugins, or ``None`` if no footprint was
-        found.
+        One of the potential plugins, or None if no footprint was found.
     """
 
     def _hdf5group2dict(group):
@@ -247,46 +230,46 @@ def _hdf5group2dict(group):
                 d[key_lower] = 1
         return d
 
-    def _exists(obj, chain):
+    def _exists(obj: dict, chain: list[str]) -> dict | None:
         key = chain.pop(0)
         if key in obj:
             return _exists(obj[key], chain) if chain else obj[key]
 
-    with h5py.File(filename) as f:
-        d = _hdf5group2dict(f["/"])
-
-        plugins_with_footprints = [p for p in plugins if hasattr(p, "footprint")]
-        plugins_with_manufacturer = [
-            p for p in plugins_with_footprints if hasattr(p, "manufacturer")
-        ]
+    with h5py.File(filename) as file:
+        top_group_keys = _hdf5group2dict(file["/"])
 
         matching_plugin = None
-        # Check manufacturer if possible (all h5ebsd files have this)
-        for key, val in d.items():
+
+        plugins_matching_manufacturer = []
+        # Find plugins matching the manufacturer dataset in the file
+        for key, val in top_group_keys.items():
             if key == "manufacturer":
-                # Extracting the manufacturer is finicky
-                man = f[val][()]
-                if isinstance(man, np.ndarray) and len(man) == 1:
-                    man = man[0]
-                if isinstance(man, bytes):
-                    man = man.decode("latin-1")
-                for p in plugins_with_manufacturer:
-                    if man.lower() == p.manufacturer:
-                        matching_plugin = p
-                        break
-
-        # If no match found, continue searching
-        if matching_plugin is None:
-            for p in plugins_with_footprints:
+                manufacturer = file[val][()]
+                if isinstance(manufacturer, np.ndarray) and len(manufacturer) == 1:
+                    manufacturer = manufacturer[0]
+                if isinstance(manufacturer, bytes):
+                    manufacturer = manufacturer.decode("latin-1")
+                for plugin in plugins:
+                    if manufacturer.lower() == plugin["manufacturer"]:
+                        plugins_matching_manufacturer.append(plugin)
+                break
+
+        if len(plugins_matching_manufacturer) == 1:
+            matching_plugin = plugins_matching_manufacturer[0]
+        else:
+            # Search for a unique footprint
+            plugins_matching_footprints = []
+            for plugin in plugins:
                 n_matches = 0
-                n_desired_matches = len(p.footprint)
-                for fp in p.footprint:
-                    fp = fp.lower().split("/")
-                    if _exists(d, fp) is not None:
+                n_desired_matches = len(plugin["footprints"])
+                for footprint in plugin["footprints"]:
+                    footprint = footprint.lower().split("/")
+                    if _exists(top_group_keys, footprint) is not None:
                         n_matches += 1
-                if n_matches == n_desired_matches:
-                    matching_plugin = p
-                    break
+                if n_matches > 0 and n_matches == n_desired_matches:
+                    plugins_matching_footprints.append(plugin)
+            if len(plugins_matching_footprints) == 1:
+                matching_plugin = plugins_matching_footprints[0]
 
     return matching_plugin
 
@@ -297,16 +280,13 @@ def _assign_signal_subclass(
     signal_type: str = "",
     lazy: bool = False,
 ) -> "EBSD | EBSDMasterPattern | ECPMasterPattern":
-    """Given ``record_by`` and ``signal_type`` return the matching
-    signal subclass.
-
-    This function is a modified version of
-    :func:`hyperspy.io.assign_signal_subclass`.
+    """Return matching signal subclass given by *record_by* and
+    *signal_type*.
 
     Parameters
     ----------
     dtype
-        Data type of signal data.
+        Data type of the signal data.
     signal_dimension
         Number of signal dimensions.
     signal_type
@@ -319,6 +299,11 @@ def _assign_signal_subclass(
     Returns
     -------
     Signal or subclass
+
+    Notes
+    -----
+    This function is a modified version of
+    :func:`hyperspy.io.assign_signal_subclass`.
     """
     # Check if parameter values are allowed
     if (
@@ -344,14 +329,14 @@ def _assign_signal_subclass(
 
     # Get signals matching both input signal's dtype and signal dimension
     dtype_matches = [s for s in signals.values() if s._dtype == dtype]
-    dtype_dim_matches = [
-        s for s in dtype_matches if s._signal_dimension == signal_dimension
-    ]
-    dtype_dim_type_matches = [
-        s
-        for s in dtype_dim_matches
-        if signal_type == s._signal_type or signal_type in s._alias_signal_types
-    ]
+    dtype_dim_matches = []
+    for s in dtype_matches:
+        if s._signal_dimension == signal_dimension:
+            dtype_dim_matches.append(s)
+    dtype_dim_type_matches = []
+    for s in dtype_dim_matches:
+        if signal_type == s._signal_type or signal_type in s._alias_signal_types:
+            dtype_dim_type_matches.append(s)
 
     if len(dtype_dim_type_matches) == 1:
         matches = dtype_dim_type_matches
@@ -371,9 +356,7 @@ def _save(
     add_scan: bool | None = None,
     **kwargs,
 ) -> None:
-    """Write signal to a file in a supported format.
-
-    This function is a modified version of :func:`hyperspy.io.save`.
+    """Write a signal to file in a supported format.
 
     Parameters
     ----------
@@ -389,75 +372,89 @@ def _save(
         file.
     **kwargs
         Keyword arguments passed to the writer.
+
+    Raises
+    ------
+    ValueError
+        If file extension does not correspond to any supported format.
+    ValueError
+        If the file format cannot write the signal data.
+    ValueError
+        If the overwrite parameter is invalid.
+
+    Notes
+    -----
+    This function is a modified version of :func:`hyperspy.io.save`.
     """
     filename = str(filename)
 
     ext = os.path.splitext(filename)[1][1:]
     if ext == "":  # Will write to kikuchipy's h5ebsd format
         ext = "h5"
-        filename = filename + "." + ext
+        filename += "." + ext
 
     writer = None
-    for plugin in plugins:
-        if ext.lower() in plugin.file_extensions and plugin.writes:
+    for plugin in PLUGINS:
+        if ext.lower() in plugin["file_extensions"] and plugin["writes"]:
             writer = plugin
             break
 
     if writer is None:
         raise ValueError(
             f"{ext!r} does not correspond to any supported format. Supported file "
-            f"extensions are: {strlist2enumeration(default_write_ext)!r}"
+            f"extensions are: {WRITE_EXTENSIONS!r}"
         )
     else:
-        sd = signal.axes_manager.signal_dimension
-        nd = signal.axes_manager.navigation_dimension
-        if writer.writes is not True and (sd, nd) not in writer.writes:
-            # Get writers that can write this data
-            writing_plugins = []
-            for plugin in plugins:
+        sig_dim = signal.axes_manager.signal_dimension
+        nav_dim = signal.axes_manager.navigation_dimension
+        if writer["writes"] is not True and [sig_dim, nav_dim] not in writer["writes"]:
+            compatible_plugin_names = []
+            for plugin in PLUGINS:
                 if (
-                    plugin.writes is True
-                    or plugin.writes is not False
-                    and (sd, nd) in plugin.writes
+                    plugin["writes"] is True
+                    or plugin["writes"] is not False
+                    and [sig_dim, nav_dim] in plugin["writes"]
                 ):
-                    writing_plugins.append(plugin)
+                    compatible_plugin_names.append(plugin["name"])
             raise ValueError(
-                "This file format cannot write this data. The following formats can: "
-                f"{strlist2enumeration(writing_plugins)!r}"
+                f"Chosen IO plugin {writer['name']!r} cannot write this data. The "
+                f"following plugins can: {compatible_plugin_names}"
             )
 
         _ensure_directory(filename)
         is_file = os.path.isfile(filename)
 
         # Check if we are to add signal to an already existing h5ebsd file
-        if (
-            writer.format_name == "kikuchipy_h5ebsd"
-            and overwrite is not True
-            and is_file
-        ):
+        if writer["name"] == "kikuchipy_h5ebsd" and overwrite is not True and is_file:
             if add_scan is None:
-                q = f"Add scan to {filename!r} (y/n)?\n"
-                add_scan = _get_input_bool(q)
+                add_scan = _get_input_bool(
+                    f"Add scan to {filename!r} (y/n)?\n",
+                    (
+                        "Your terminal does not support raw input. Not adding scan. To "
+                        "add the scan, pass 'add_scan=True'"
+                    ),
+                )
             if add_scan:
-                overwrite = True  # So that the 2nd statement below triggers
+                # So that the 2nd statement below triggers
+                overwrite = True
             kwargs["add_scan"] = add_scan
 
         # Determine if signal is to be written to file or not
         if overwrite is None:
-            write = overwrite_method(filename)  # Ask what to do
+            write = _overwrite(filename)  # Ask what to do
         elif overwrite is True or (overwrite is False and not is_file):
-            write = True  # Write the file
+            write = True
         elif overwrite is False and is_file:
-            write = False  # Don't write the file
+            write = False
         else:
             raise ValueError(
-                "overwrite parameter can only be None, True or False, and not "
+                "overwrite parameter can only be None, True, or False, and not "
                 f"{overwrite}"
             )
 
-        # Finally, write file
         if write:
-            writer.file_writer(filename, signal, **kwargs)
+            file_writer = importlib.import_module(writer["api"]).file_writer
+            file_writer(filename, signal, **kwargs)
             directory, filename = os.path.split(os.path.abspath(filename))
             signal.tmp_parameters.set_item("folder", directory)
             signal.tmp_parameters.set_item("filename", os.path.splitext(filename)[0])
diff --git a/src/kikuchipy/io/_util.py b/src/kikuchipy/io/_util.py
index 30633166..43f0480e 100644
--- a/src/kikuchipy/io/_util.py
+++ b/src/kikuchipy/io/_util.py
@@ -16,17 +16,20 @@
 # along with kikuchipy. If not, see <http://www.gnu.org/licenses/>.
 
 import os
+from pathlib import Path
 from typing import Any
 import warnings
 
 
-def _get_input_bool(question: str) -> bool | None:
+def _get_input_bool(question: str, warning: str) -> bool:
     """Get input from user on boolean choice, returning the answer.
 
     Parameters
     ----------
     question
         Question to ask user.
+    warning
+        Warning to display if no good answer was given.
     """
     try:
         answer = input(question)
@@ -39,11 +42,21 @@ def _get_input_bool(question: str) -> bool | None:
         elif answer.lower() == "n":
             return False
     except OSError:
-        warnings.warn(
-            "Your terminal does not support raw input. Not adding scan. To add the scan"
-            " use `add_scan=True`"
+        warnings.warn(warning)
+    return False
+
+
+def _overwrite(fname: Path | str) -> bool:
+    if Path(fname).is_file():
+        return _get_input_bool(
+            f"Overwrite {fname!r} (y/n)?\n",
+            (
+                "Your terminal does not support raw input. Not overwriting. To "
+                "overwrite the file, pass overwrite=True."
+            ),
         )
-        return False
+    else:
+        return True
 
 
 def _get_input_variable(question: str, var_type: Any) -> Any | None:
diff --git a/src/kikuchipy/io/plugins/__init__.pyi b/src/kikuchipy/io/plugins/__init__.pyi
index b6c69ec8..8a6e3a21 100644
--- a/src/kikuchipy/io/plugins/__init__.pyi
+++ b/src/kikuchipy/io/plugins/__init__.pyi
@@ -15,34 +15,6 @@
 # You should have received a copy of the GNU General Public License
 # along with kikuchipy. If not, see <http://www.gnu.org/licenses/>.
 
-from . import (
-    bruker_h5ebsd,
-    ebsd_directory,
-    edax_binary,
-    edax_h5ebsd,
-    emsoft_ebsd,
-    emsoft_ebsd_master_pattern,
-    emsoft_ecp_master_pattern,
-    emsoft_tkd_master_pattern,
-    kikuchipy_h5ebsd,
-    nordif,
-    nordif_calibration_patterns,
-    oxford_binary,
-    oxford_h5ebsd,
-)
+from . import plugins
 
-__all__ = [
-    "bruker_h5ebsd",
-    "ebsd_directory",
-    "edax_binary",
-    "edax_h5ebsd",
-    "emsoft_ebsd",
-    "emsoft_ebsd_master_pattern",
-    "emsoft_ecp_master_pattern",
-    "emsoft_tkd_master_pattern",
-    "kikuchipy_h5ebsd",
-    "nordif",
-    "nordif_calibration_patterns",
-    "oxford_binary",
-    "oxford_h5ebsd",
-]
+__all__ = ["plugins"]
diff --git a/src/kikuchipy/io/plugins/_emsoft_master_pattern.py b/src/kikuchipy/io/plugins/_emsoft_master_pattern.py
index 43bf0fcb..3a9b783d 100644
--- a/src/kikuchipy/io/plugins/_emsoft_master_pattern.py
+++ b/src/kikuchipy/io/plugins/_emsoft_master_pattern.py
@@ -24,7 +24,7 @@
 import numpy as np
 
 from kikuchipy.io.plugins._h5ebsd import _hdf5group2dict
-from kikuchipy.io.plugins.emsoft_ebsd import _crystaldata2phase
+from kikuchipy.io.plugins.emsoft_ebsd._api import _crystaldata2phase
 
 
 class EMsoftMasterPatternReader(abc.ABC):
@@ -77,9 +77,9 @@ def read(self, **kwargs) -> list[dict]:
         fpath = Path(self.filename)
 
         mode = kwargs.pop("mode", "r")
-        f = h5py.File(fpath, mode=mode, **kwargs)
+        file = h5py.File(fpath, mode, **kwargs)
 
-        _check_file_format(f, self.diffraction_type)
+        _check_file_format(file, self.diffraction_type)
 
         # Set data group names
         diff_type = self.diffraction_type
@@ -97,11 +97,11 @@ def read(self, **kwargs) -> list[dict]:
                 "original_filename": fpath.name,
             },
         }
-        nml_params = _hdf5group2dict(f["NMLparameters"], recursive=True)
+        nml_params = _hdf5group2dict(file["NMLparameters"], recursive=True)
 
         # Get phase information and add it to both the original metadata
         # and a Phase object
-        crystal_data = _hdf5group2dict(f["CrystalData"])
+        crystal_data = _hdf5group2dict(file["CrystalData"])
         nml_params["CrystalData"] = crystal_data
         phase = _crystaldata2phase(crystal_data)
 
@@ -114,7 +114,7 @@ def read(self, **kwargs) -> list[dict]:
                 phase.name = None
 
         # Get data shape and slices
-        data_group = f[data_group_path]
+        data_group = file[data_group_path]
         energies = data_group[self.energy_string][()]
         data_shape, data_slices = _get_data_shape_slices(
             npx=nml_params[name_list_name]["npx"], energies=energies, energy=self.energy
@@ -192,8 +192,9 @@ def read(self, **kwargs) -> list[dict]:
         dim_idx += [2, 3]
 
         # Create axis object
-        axes = [
-            {
+        axes = []
+        for i, j in zip(range(data.ndim), dim_idx):
+            axis = {
                 "size": data.shape[i],
                 "index_in_array": i,
                 "name": names[j],
@@ -201,8 +202,7 @@ def read(self, **kwargs) -> list[dict]:
                 "offset": offsets[j],
                 "units": units[j],
             }
-            for i, j in zip(range(data.ndim), dim_idx)
-        ]
+            axes.append(axis)
 
         output = {
             "axes": axes,
@@ -215,7 +215,7 @@ def read(self, **kwargs) -> list[dict]:
         }
 
         if not self.lazy:
-            f.close()
+            file.close()
 
         return [output]
 
@@ -229,13 +229,13 @@ def _check_file_format(file: h5py.File, diffraction_type: str) -> None:
     file
         HDF5 file.
     diffraction_type
-        ``"EBSD"``, ``"TKD"`` or ``"ECP"``.
+        "EBSD", "TKD", or "ECP".
 
     Raises
     ------
     KeyError
         If the program that created the file is not named
-        ``"EM<diffraction_type>master.f90"``.
+        "EM<diffraction_type>master.f90".
     IOError
         If the file is not in the EMsoft master pattern file format.
     """
@@ -245,7 +245,7 @@ def _check_file_format(file: h5py.File, diffraction_type: str) -> None:
         if program_name != f"EM{diffraction_type}master.f90":
             raise KeyError
     except KeyError:
-        raise IOError(f"'{file.filename}' is not in EMsoft's master pattern format")
+        raise IOError(f"{file.filename!r} is not in EMsoft's master pattern format")
 
 
 def _get_data_shape_slices(
@@ -273,7 +273,7 @@ def _get_data_shape_slices(
     data_shape
         Shape of data.
     data_slices
-        Data to get, determined from ``energy``.
+        Data to get, determined from *energy*.
     """
     data_shape = (npx * 2 + 1,) * 2
     data_slices = (slice(None, None),) * 2
@@ -303,37 +303,41 @@ def _get_datasets(
     data_group
         HDF5 data group with data sets.
     projection
-        ``"stereographic"`` or ``"lambert"`` projection.
+        "stereographic" or "lambert" projection.
     hemisphere
-        ``"upper"``, ``"lower"`` or ``"both"`` hemisphere(s).
+        "upper", "lower", or "both" hemisphere(s).
 
     Returns
     -------
     datasets
         List of HDF5 data sets.
+
+    Raises
+    ------
+    ValueError
+        If *projection* or *hemisphere* is not among the options.
     """
-    hemisphere = hemisphere.lower()
     projection = projection.lower()
-
     projections = {"stereographic": "masterSP", "lambert": "mLP"}
-    hemispheres = {"upper": "NH", "lower": "SH"}
-
-    if projection not in projections.keys():
+    if projection not in projections:
         raise ValueError(
-            f"'projection' value {projection} must be one of {list(projections.keys())}"
+            f"'projection' value {projection!r} must be one of {list(projections)}"
         )
 
+    hemisphere = hemisphere.lower()
+    hemispheres = {"upper": "NH", "lower": "SH"}
+
+    projection_label = projections[projection]
     if hemisphere == "both":
-        dset_names = [
-            projections[projection] + hemispheres[h] for h in hemispheres.keys()
-        ]
-        datasets = [data_group[n] for n in dset_names]
+        datasets = []
+        for hemi_label in hemispheres.values():
+            datasets.append(data_group[projection_label + hemi_label])
     elif hemisphere in hemispheres:
-        dset_name = projections[projection] + hemispheres[hemisphere]
+        dset_name = projection_label + hemispheres[hemisphere]
         datasets = [data_group[dset_name]]
     else:
         raise ValueError(
-            f"'hemisphere' value {hemisphere} must be one of {list(hemispheres.keys())}"
+            f"'hemisphere' value {hemisphere!r} must be one of {list(hemispheres)}"
         )
 
     return datasets
diff --git a/src/kikuchipy/io/plugins/_h5ebsd.py b/src/kikuchipy/io/plugins/_h5ebsd.py
index 1de92d3c..d583e258 100644
--- a/src/kikuchipy/io/plugins/_h5ebsd.py
+++ b/src/kikuchipy/io/plugins/_h5ebsd.py
@@ -33,7 +33,7 @@ def _hdf5group2dict(
     group: h5py.Group,
     dictionary: dict | None = None,
     recursive: bool = False,
-    data_dset_names: list | None = None,
+    data_dset_names: list[str] | None = None,
 ) -> dict:
     """Return a dictionary with values from datasets in a group.
 
@@ -44,14 +44,14 @@ def _hdf5group2dict(
     dictionary
         To fill dataset values into.
     recursive
-        Whether to add subgroups to ``dictionary`` (default is False).
+        Whether to add subgroups to *dictionary*. Default is False.
     data_dset_names
         List of names of HDF5 data sets with data to not read.
 
     Returns
     -------
     dictionary
-        Dataset values in group (and subgroups if ``recursive=True``).
+        Dataset values in group (and subgroups if *recursive* is True).
     """
     if data_dset_names is None:
         data_dset_names = []
@@ -137,7 +137,7 @@ class H5EBSDReader(abc.ABC):
         Keyword arguments passed to :class:`h5py.File`.
     """
 
-    manufacturer_patterns = {
+    manufacturer_patterns: dict[str, str] = {
         "bruker nano": "RawPatterns",
         "edax": "Pattern",
         "kikuchipy": "patterns",
@@ -158,7 +158,12 @@ def __repr__(self) -> str:
     @property
     def scan_group_names(self) -> list[str]:
         """Return a list of available scan group names."""
-        return [group.name.lstrip("/") for group in self.scan_groups]
+        names = []
+        for group in self.scan_groups:
+            # Fails if group is anynomous, with None as name
+            name = group.name.lstrip("/")
+            names.append(name)
+        return names
 
     def check_file(self) -> None:
         """Check if the file is a valid h5ebsd file by searching for
@@ -196,8 +201,8 @@ def check_file(self) -> None:
         if error is not None:
             raise IOError(f"{self.filename} is not a supported h5ebsd file, as {error}")
 
-    def get_manufacturer_version(self) -> tuple[str | None, str | None]:
-        """Get manufacturer and version from the top group.
+    def get_manufacturer_version(self) -> tuple[str, str]:
+        """Return manufacturer and version, read from the top group.
 
         Returns
         -------
@@ -205,6 +210,12 @@ def get_manufacturer_version(self) -> tuple[str | None, str | None]:
             File manufacturer.
         version
             File version.
+
+        Raises
+        ------
+        IOError
+            If either 'manufacturer' or 'version' datasets could not be
+            found in the top group.
         """
         manufacturer = None
         version = None
@@ -213,6 +224,10 @@ def get_manufacturer_version(self) -> tuple[str | None, str | None]:
                 manufacturer = val.lower()
             elif key.lower() in ["version", "format version"]:
                 version = val.lower()
+        if manufacturer is None:
+            raise IOError(f"Could not find 'manufacturer' key in {self.filename!r}")
+        elif version is None:
+            raise IOError(f"Could not find 'version' key in {self.filename!r}")
         return manufacturer, version
 
     def get_scan_groups(self) -> list[h5py.Group]:
@@ -246,6 +261,11 @@ def get_desired_scan_groups(
         -------
         scan_groups
             A list of the desired scan group(s).
+
+        Raises
+        ------
+        IOError
+            If the desired scan group is not among the available scans.
         """
         # Get desired scan groups
         scan_groups = []
@@ -263,8 +283,8 @@ def get_desired_scan_groups(
                         break
                 if not scan_is_here:
                     error_str = (
-                        f"Scan '{desired_name}' is not among the available scans "
-                        f"{self.scan_group_names} in '{self.filename}'."
+                        f"Scan {desired_name!r} is not among the available scans "
+                        f"{self.scan_group_names} in {self.filename!r}"
                     )
                     if len(group_names) == 1:
                         raise IOError(error_str)
@@ -446,7 +466,7 @@ def read(
         """Return a list of dictionaries which can be used to create
         :class:`~kikuchipy.signals.EBSD` signals.
 
-        The file is closed after reading if ``lazy=False``.
+        The file is closed after reading if *lazy* is False.
 
         Parameters
         ----------
@@ -454,8 +474,8 @@ def read(
             Name or a list of names of the desired top HDF5 group(s). If
             not given, the first scan group is returned.
         lazy
-            Read dataset lazily (default is False). If False, the file
-            is closed after reading.
+            Read dataset lazily. Default is False. If False, the file is
+            closed after reading.
 
         Returns
         -------
diff --git a/src/kikuchipy/io/plugins/bruker_h5ebsd/__init__.py b/src/kikuchipy/io/plugins/bruker_h5ebsd/__init__.py
new file mode 100644
index 00000000..2f857066
--- /dev/null
+++ b/src/kikuchipy/io/plugins/bruker_h5ebsd/__init__.py
@@ -0,0 +1,23 @@
+# Copyright 2019-2024 The kikuchipy developers
+#
+# This file is part of kikuchipy.
+#
+# kikuchipy is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# kikuchipy is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with kikuchipy. If not, see <http://www.gnu.org/licenses/>.
+
+import lazy_loader
+
+__getattr__, __dir__, __all__ = lazy_loader.attach_stub(__name__, __file__)
+
+
+del lazy_loader
diff --git a/src/kikuchipy/io/plugins/bruker_h5ebsd/__init__.pyi b/src/kikuchipy/io/plugins/bruker_h5ebsd/__init__.pyi
new file mode 100644
index 00000000..65a3a663
--- /dev/null
+++ b/src/kikuchipy/io/plugins/bruker_h5ebsd/__init__.pyi
@@ -0,0 +1,20 @@
+# Copyright 2019-2024 The kikuchipy developers
+#
+# This file is part of kikuchipy.
+#
+# kikuchipy is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# kikuchipy is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with kikuchipy. If not, see <http://www.gnu.org/licenses/>.
+
+from ._api import file_reader
+
+__all__ = ["file_reader"]
diff --git a/src/kikuchipy/io/plugins/bruker_h5ebsd.py b/src/kikuchipy/io/plugins/bruker_h5ebsd/_api.py
similarity index 68%
rename from src/kikuchipy/io/plugins/bruker_h5ebsd.py
rename to src/kikuchipy/io/plugins/bruker_h5ebsd/_api.py
index c49d3937..8eaf220a 100644
--- a/src/kikuchipy/io/plugins/bruker_h5ebsd.py
+++ b/src/kikuchipy/io/plugins/bruker_h5ebsd/_api.py
@@ -26,44 +26,21 @@
 from kikuchipy.detectors.ebsd_detector import EBSDDetector
 from kikuchipy.io.plugins._h5ebsd import H5EBSDReader, _hdf5group2dict
 
-__all__ = ["file_reader"]
-
-
-# Plugin characteristics
-# ----------------------
-format_name = "bruker_h5ebsd"
-description = (
-    "Read support for electron backscatter diffraction patterns stored "
-    "in an HDF5 file formatted in Bruker Nano's h5ebsd format, similar "
-    "to the format described in Jackson et al.: h5ebsd: an archival "
-    "data format for electron back-scatter diffraction data sets. "
-    "Integrating Materials and Manufacturing Innovation 2014 3:4, doi: "
-    "https://dx.doi.org/10.1186/2193-9772-3-4."
-)
-full_support = False
-# Recognised file extension
-file_extensions = ["h5", "hdf5", "h5ebsd"]
-default_extension = 0
-# Writing capabilities (signal dimensions, navigation dimensions)
-writes = False
-
-# Unique HDF5 footprint
-footprint = ["manufacturer", "version"]
-manufacturer = "bruker nano"
-
 
 class BrukerH5EBSDReader(H5EBSDReader):
     """Bruker Nano h5ebsd file reader.
 
-    The file contents are ment to be used for initializing a
-    :class:`~kikuchipy.signals.EBSD` signal.
-
     Parameters
     ----------
     filename
         Full file path of the HDF5 file.
     **kwargs
         Keyword arguments passed to :class:`h5py.File`.
+
+    Notes
+    -----
+    The file contents are meant to be used for initializing a
+    :class:`~kikuchipy.signals.EBSD` signal.
     """
 
     def __init__(self, filename: str, **kwargs) -> None:
@@ -77,16 +54,15 @@ def scan2dict(self, group: h5py.Group, lazy: bool = False) -> dict:
         group
             Group with patterns.
         lazy
-            Whether to read dataset lazily (default is ``False``).
+            Whether to read dataset lazily. Default is False.
 
         Returns
         -------
         scan_dict
-            Dictionary with keys ``"axes"``, ``"data"``, ``"metadata"``,
-            ``"original_metadata"``, ``"detector"``,
-            ``"static_background"``, and ``"xmap"``. This dictionary can
-             be passed as keyword arguments to create an
-             :class:`~kikuchipy.signals.EBSD` signal.
+            Dictionary with keys "axes", "data", "metadata",
+            "original_metadata", "detector", "static_background", and
+            "xmap". This dictionary can be passed as keyword arguments
+            to create an :class:`~kikuchipy.signals.EBSD` signal.
 
         Raises
         ------
@@ -103,25 +79,27 @@ def scan2dict(self, group: h5py.Group, lazy: bool = False) -> dict:
             If pattern array is smaller than the data shape determined
             from other datasets in the file.
         """
-        hd = _hdf5group2dict(group["EBSD/Header"], recursive=True)
-        dd = _hdf5group2dict(group["EBSD/Data"], data_dset_names=self.patterns_name)
+        header_dset = _hdf5group2dict(group["EBSD/Header"], recursive=True)
+        data_dset = _hdf5group2dict(
+            group["EBSD/Data"], data_dset_names=[self.patterns_name]
+        )
 
         # Ensure file can be read
-        grid_type = hd.get("Grid Type")
+        grid_type = header_dset.get("Grid Type")
         if grid_type != "isometric":
-            raise IOError(f"Only square grids are supported, not {grid_type}")
+            raise IOError(f"Only square grids are supported, not {grid_type!r}")
 
         # Get region of interest (ROI, only rectangular shape supported)
         indices = None
-        roi = False
         try:
             sd = _hdf5group2dict(group["EBSD/SEM"])
             iy = sd["IY"][()]
             ix = sd["IX"][()]
             roi = True
         except KeyError:
-            ny = hd["NROWS"]
-            nx = hd["NCOLS"]
+            ny = header_dset["NROWS"]
+            nx = header_dset["NCOLS"]
+            roi = False
         if roi:
             ny_roi, nx_roi, is_rectangular = _bruker_roi_is_rectangular(iy, ix)
             if is_rectangular:
@@ -134,9 +112,9 @@ def scan2dict(self, group: h5py.Group, lazy: bool = False) -> dict:
                 raise ValueError("Only a rectangular region of interest is supported")
 
         # Get other data shapes
-        sy, sx = hd["PatternHeight"], hd["PatternWidth"]
-        dy, dx = hd["YSTEP"], hd["XSTEP"]
-        px_size = hd.get("DetectorFullHeightMicrons", 1) / hd.get(
+        sy, sx = header_dset["PatternHeight"], header_dset["PatternWidth"]
+        dy, dx = header_dset["YSTEP"], header_dset["XSTEP"]
+        px_size = header_dset.get("DetectorFullHeightMicrons", 1) / header_dset.get(
             "UnClippedPatternHeight", 1
         )
 
@@ -145,20 +123,19 @@ def scan2dict(self, group: h5py.Group, lazy: bool = False) -> dict:
         metadata = {
             "Acquisition_instrument": {
                 "SEM": {
-                    "beam_energy": hd.get("KV"),
-                    "magnification": hd.get("Magnification"),
-                    "working_distance": hd.get("WD"),
+                    "beam_energy": header_dset.get("KV"),
+                    "magnification": header_dset.get("Magnification"),
+                    "working_distance": header_dset.get("WD"),
                 },
             },
             "General": {
-                "original_filename": hd.get("OriginalFile", fname),
+                "original_filename": header_dset.get("OriginalFile", fname),
                 "title": title,
             },
             "Signal": {"signal_type": "EBSD", "record_by": "image"},
         }
         scan_dict = {"metadata": metadata}
 
-        # --- Data
         data = self.get_data(
             group,
             data_shape=(ny, nx, sy, sx),
@@ -173,24 +150,28 @@ def scan2dict(self, group: h5py.Group, lazy: bool = False) -> dict:
             "manufacturer": self.manufacturer,
             "version": self.version,
         }
-        scan_dict["original_metadata"].update(hd)
+        scan_dict["original_metadata"].update(header_dset)
 
         # TODO: Use reader from orix
         xmap = CrystalMap.empty(shape=(ny, nx), step_sizes=(dy, dx))
         scan_dict["xmap"] = xmap
 
-        scan_dict["static_background"] = hd.get("StaticBackground")
+        scan_dict["static_background"] = header_dset.get("StaticBackground")
 
         pc = np.column_stack(
-            (dd.get("PCX", 0.5), dd.get("PCY", 0.5), dd.get("DD", 0.5))
+            (
+                data_dset.get("PCX", 0.5),
+                data_dset.get("PCY", 0.5),
+                data_dset.get("DD", 0.5),
+            )
         )
         if pc.size > 3:
             pc = pc.reshape((ny, nx, 3))
         scan_dict["detector"] = EBSDDetector(
             shape=(sy, sx),
             px_size=px_size,
-            tilt=hd.get("CameraTilt", 0.0),
-            sample_tilt=hd.get("Sample Tilt", 0.0),
+            tilt=header_dset.get("CameraTilt", 0.0),
+            sample_tilt=header_dset.get("Sample Tilt", 0.0),
             pc=pc,
         )
 
@@ -217,11 +198,11 @@ def file_reader(
     lazy: bool = False,
     **kwargs,
 ) -> list[dict]:
-    """Read electron backscatter diffraction patterns, a crystal map,
+    """Return electron backscatter diffraction patterns, a crystal map,
     and an EBSD detector from a Bruker h5ebsd file
     :cite:`jackson2014h5ebsd`.
 
-    Not ment to be used directly; use :func:`~kikuchipy.load`.
+    Not meant to be used directly; use :func:`~kikuchipy.load` instead.
 
     Parameters
     ----------
@@ -234,17 +215,17 @@ def file_reader(
     lazy
         Open the data lazily without actually reading the data from disk
         until required. Allows opening arbitrary sized datasets. Default
-        is ``False``.
+        is False.
     **kwargs
         Keyword arguments passed to :class:`h5py.File`.
 
     Returns
     -------
     scan_dict_list
-        List of one or more dictionaries with the keys ``"axes"``,
-        ``"data"``, ``"metadata"``, ``"original_metadata"``,
-        ``"detector"``, ``"static_background"``, and ``"xmap"``. This
-        dictionary can be passed as keyword arguments to create an
+        List of one or more dictionaries with the keys "axes", "data",
+        "metadata", "original_metadata", "detector",
+        "static_background", and "xmap". This dictionary can be passed
+        as keyword arguments to create an
         :class:`~kikuchipy.signals.EBSD` signal.
     """
     reader = BrukerH5EBSDReader(filename, **kwargs)
diff --git a/src/kikuchipy/io/plugins/bruker_h5ebsd/specification.yaml b/src/kikuchipy/io/plugins/bruker_h5ebsd/specification.yaml
new file mode 100644
index 00000000..ecc958b9
--- /dev/null
+++ b/src/kikuchipy/io/plugins/bruker_h5ebsd/specification.yaml
@@ -0,0 +1,13 @@
+name: bruker_h5ebsd
+description: >
+  Read support for electron backscatter diffraction patterns stored in
+  an HDF5 file formatted in Bruker Nano's h5ebsd format, similar to the
+  format described in Jackson et al.: h5ebsd: an archival data format
+  for electron back-scatter diffraction data sets. Integrating Materials
+  and Manufacturing Innovation 2014 3:4,
+  doi: https://dx.doi.org/10.1186/2193-9772-3-4.
+file_extensions: ['h5', 'hdf5', 'h5ebsd']
+default_extension: 0
+writes: False
+manufacturer: bruker nano
+footprints: ['manufacturer', 'version']
diff --git a/src/kikuchipy/io/plugins/ebsd_directory/__init__.py b/src/kikuchipy/io/plugins/ebsd_directory/__init__.py
new file mode 100644
index 00000000..2f857066
--- /dev/null
+++ b/src/kikuchipy/io/plugins/ebsd_directory/__init__.py
@@ -0,0 +1,23 @@
+# Copyright 2019-2024 The kikuchipy developers
+#
+# This file is part of kikuchipy.
+#
+# kikuchipy is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# kikuchipy is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with kikuchipy. If not, see <http://www.gnu.org/licenses/>.
+
+import lazy_loader
+
+__getattr__, __dir__, __all__ = lazy_loader.attach_stub(__name__, __file__)
+
+
+del lazy_loader
diff --git a/src/kikuchipy/io/plugins/ebsd_directory/__init__.pyi b/src/kikuchipy/io/plugins/ebsd_directory/__init__.pyi
new file mode 100644
index 00000000..65a3a663
--- /dev/null
+++ b/src/kikuchipy/io/plugins/ebsd_directory/__init__.pyi
@@ -0,0 +1,20 @@
+# Copyright 2019-2024 The kikuchipy developers
+#
+# This file is part of kikuchipy.
+#
+# kikuchipy is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# kikuchipy is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with kikuchipy. If not, see <http://www.gnu.org/licenses/>.
+
+from ._api import file_reader
+
+__all__ = ["file_reader"]
diff --git a/src/kikuchipy/io/plugins/ebsd_directory.py b/src/kikuchipy/io/plugins/ebsd_directory/_api.py
similarity index 87%
rename from src/kikuchipy/io/plugins/ebsd_directory.py
rename to src/kikuchipy/io/plugins/ebsd_directory/_api.py
index 6a35e211..65e1e391 100644
--- a/src/kikuchipy/io/plugins/ebsd_directory.py
+++ b/src/kikuchipy/io/plugins/ebsd_directory/_api.py
@@ -30,24 +30,9 @@
 import imageio.v3 as iio
 import numpy as np
 
-__all__ = ["file_reader"]
-
-
 _logger = logging.getLogger(__name__)
 
 
-# Plugin characteristics
-# ----------------------
-format_name = "Directory of EBSD patterns"
-description = "Read support for patterns in image files in a directory"
-full_support = False
-# Recognised file extension
-file_extensions = ["tif", "tiff", "bmp", "png"]
-default_extension = 0
-# Writing capabilities (signal dimensions, navigation dimensions)
-writes = False
-
-
 def file_reader(
     filename: str | Path,
     xy_pattern: str | None = None,
@@ -68,17 +53,17 @@ def file_reader(
         Regular expression to extract map coordinates from the
         filenames. If not given, two regular expressions will be tried:
         assuming (x, y) = (5, 10), "_x5y10.tif" or "-5-10.bmp".
-        Valid ``xy_pattern`` equal to these are ``r"_x(\d+)y(\d+).tif"``
+        Valid *xy_pattern* equal to these are ``r"_x(\d+)y(\d+).tif"``
         and ``r"-(\d+)-(\d+).bmp"``, respectively. If none of these
         expressions match the first file's name in the directory, a
         warning is printed and the returned signal will have only one
         navigation dimension.
     show_progressbar
         Whether to show a progressbar when reading the signal into
-        memory when ``lazy=False``.
+        memory when *lazy* is False.
     lazy
         Read the patterns lazily without actually reading them from disk
-        until required. Default is ``False``.
+        until required. Default is False.
 
     Returns
     -------
@@ -119,7 +104,7 @@ def file_reader(
         nav_shape = (n_patterns,)
     else:
         # Read coordinates of each file
-        fn_idx_sets = dict()
+        fn_idx_sets = {}
         xy_coords = np.zeros((n_patterns, 2), dtype=int)
         for j, fn in enumerate(filenames):
             for i, idx in enumerate(re.search(xy_pattern, fn).groups()):
@@ -135,7 +120,7 @@ def file_reader(
             warnings.warn(
                 "Returned signal will have one navigation dimension as the number of "
                 f"patterns found in the directory, {n_patterns}, does not match the "
-                f"navigation shape determined from the filenames, {nav_shape}."
+                f"navigation shape determined from the filenames, {nav_shape}"
             )
             nav_shape = (n_patterns,)
 
@@ -180,8 +165,9 @@ def file_reader(
     units = ["um"] * ndim
     scales = [1] * ndim
     axes_names = ["y", "x"][-nav_dim:] + ["dy", "dx"]
-    axes = [
-        {
+    axes = []
+    for i in range(ndim):
+        axis = {
             "size": data.shape[i],
             "index_in_array": i,
             "name": axes_names[i],
@@ -189,10 +175,9 @@ def file_reader(
             "offset": 0.0,
             "units": units[i],
         }
-        for i in range(ndim)
-    ]
-    metadata = dict(Signal=dict(signal_type="EBSD", record_by="image"))
+        axes.append(axis)
+    metadata = {"Signal": {"signal_type": "EBSD", "record_by": "image"}}
 
-    scan = dict(axes=axes, data=data, metadata=metadata)
+    scan = {"axes": axes, "data": data, "metadata": metadata}
 
     return [scan]
diff --git a/src/kikuchipy/io/plugins/ebsd_directory/specification.yaml b/src/kikuchipy/io/plugins/ebsd_directory/specification.yaml
new file mode 100644
index 00000000..5b812d58
--- /dev/null
+++ b/src/kikuchipy/io/plugins/ebsd_directory/specification.yaml
@@ -0,0 +1,7 @@
+name: ebsd_directory
+description: Read support for patterns in image files in a directory
+file_extensions: ['tif', 'tiff', 'bmp', 'png']
+default_extension: 0
+writes: False
+manufacturer: None
+footprints: []
diff --git a/src/kikuchipy/io/plugins/edax_binary/__init__.py b/src/kikuchipy/io/plugins/edax_binary/__init__.py
new file mode 100644
index 00000000..2f857066
--- /dev/null
+++ b/src/kikuchipy/io/plugins/edax_binary/__init__.py
@@ -0,0 +1,23 @@
+# Copyright 2019-2024 The kikuchipy developers
+#
+# This file is part of kikuchipy.
+#
+# kikuchipy is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# kikuchipy is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with kikuchipy. If not, see <http://www.gnu.org/licenses/>.
+
+import lazy_loader
+
+__getattr__, __dir__, __all__ = lazy_loader.attach_stub(__name__, __file__)
+
+
+del lazy_loader
diff --git a/src/kikuchipy/io/plugins/edax_binary/__init__.pyi b/src/kikuchipy/io/plugins/edax_binary/__init__.pyi
new file mode 100644
index 00000000..65a3a663
--- /dev/null
+++ b/src/kikuchipy/io/plugins/edax_binary/__init__.pyi
@@ -0,0 +1,20 @@
+# Copyright 2019-2024 The kikuchipy developers
+#
+# This file is part of kikuchipy.
+#
+# kikuchipy is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# kikuchipy is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with kikuchipy. If not, see <http://www.gnu.org/licenses/>.
+
+from ._api import file_reader
+
+__all__ = ["file_reader"]
diff --git a/src/kikuchipy/io/plugins/edax_binary.py b/src/kikuchipy/io/plugins/edax_binary/_api.py
similarity index 88%
rename from src/kikuchipy/io/plugins/edax_binary.py
rename to src/kikuchipy/io/plugins/edax_binary/_api.py
index cc3fa843..a7f5f04f 100644
--- a/src/kikuchipy/io/plugins/edax_binary.py
+++ b/src/kikuchipy/io/plugins/edax_binary/_api.py
@@ -29,24 +29,6 @@
 
 from kikuchipy.signals.util._dask import get_chunking
 
-__all__ = ["file_reader"]
-
-
-# Plugin characteristics
-# ----------------------
-format_name = "EDAX binary"
-description = (
-    "Read support for electron backscatter diffraction patterns stored "
-    "in a binary EDAX TSL's UP1/UP2 file extension '.up1' or '.up2'. "
-    "The reader is adapted from the EDAX UP1/2 reader in PyEBSDIndex."
-)
-full_support = False
-# Recognised file extension
-file_extensions = ["up1", "up2"]
-default_extension = 0
-# Writing capabilities (signal dimensions, navigation dimensions)
-writes = False
-
 
 def file_reader(
     filename: str | Path,
@@ -60,7 +42,7 @@ def file_reader(
     Parameters
     ----------
     filename
-        File path to UP1/2 file with ``"up1"`` or ``"up2"`` extension.
+        File path to UP1/2 file with .up1 or .up2 extension.
     nav_shape
         Navigation shape, as (n map rows, n map columns), of the
         returned :class:`~kikuchipy.signals.EBSD` signal, matching the
@@ -84,15 +66,15 @@ def file_reader(
     ValueError
         If file version is 2, since only version 1 or >= 3 is supported.
     ValueError
-        If ``nav_shape`` does not match the number of patterns in
-        the file.
+        If *nav_shape* does not match the number of patterns in the
+        file.
 
     Warns
     -----
     UserWarning
         If patterns were acquired in an hexagonal grid, since then the
         returned signal will have only one navigation dimension, even
-        though ``nav_shape`` is given.
+        though *nav_shape* is given.
 
     Notes
     -----
@@ -126,12 +108,7 @@ def __init__(self, file: BinaryIO) -> None:
             raise ValueError("Only files with version 1 or >= 3, not 2, can be read")
 
     def read_header(self) -> dict[str, int]:
-        """Read and return header information.
-
-        Returns
-        -------
-        dictionary
-        """
+        """Return read header information."""
         self.file.seek(4)
         sx, sy, pattern_offset = np.fromfile(self.file, "uint32", count=3)
         file_size = Path(self.file.name).stat().st_size
@@ -230,8 +207,9 @@ def read_scan(
         units = ["um"] * ndim
         scales = [header["dy"], header["dx"]] + [1, 1][:nav_dim]
         axes_names = ["y", "x"][-nav_dim:] + ["dy", "dx"]
-        axes = [
-            {
+        axes: list[dict] = []
+        for i in range(ndim):
+            axis = {
                 "size": data.shape[i],
                 "index_in_array": i,
                 "name": axes_names[i],
@@ -239,8 +217,7 @@ def read_scan(
                 "offset": 0.0,
                 "units": units[i],
             }
-            for i in range(ndim)
-        ]
+            axes.append(axis)
         fname = self.file.name
         metadata = {
             "General": {
diff --git a/src/kikuchipy/io/plugins/edax_binary/specification.yaml b/src/kikuchipy/io/plugins/edax_binary/specification.yaml
new file mode 100644
index 00000000..1ec2d9a3
--- /dev/null
+++ b/src/kikuchipy/io/plugins/edax_binary/specification.yaml
@@ -0,0 +1,10 @@
+name: edax_binary
+description: >
+  Read support for electron backscatter diffraction patterns stored in a
+  binary EDAX TSL's UP1/UP2 file extension '.up1' or '.up2'. The reader
+  is adapted from the EDAX UP1/2 reader in PyEBSDIndex.
+file_extensions: ['up1', 'up2']
+default_extension: 0
+writes: False
+manufacturer: edax
+footprints: []
diff --git a/src/kikuchipy/io/plugins/edax_h5ebsd/__init__.py b/src/kikuchipy/io/plugins/edax_h5ebsd/__init__.py
new file mode 100644
index 00000000..2f857066
--- /dev/null
+++ b/src/kikuchipy/io/plugins/edax_h5ebsd/__init__.py
@@ -0,0 +1,23 @@
+# Copyright 2019-2024 The kikuchipy developers
+#
+# This file is part of kikuchipy.
+#
+# kikuchipy is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# kikuchipy is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with kikuchipy. If not, see <http://www.gnu.org/licenses/>.
+
+import lazy_loader
+
+__getattr__, __dir__, __all__ = lazy_loader.attach_stub(__name__, __file__)
+
+
+del lazy_loader
diff --git a/src/kikuchipy/io/plugins/edax_h5ebsd/__init__.pyi b/src/kikuchipy/io/plugins/edax_h5ebsd/__init__.pyi
new file mode 100644
index 00000000..65a3a663
--- /dev/null
+++ b/src/kikuchipy/io/plugins/edax_h5ebsd/__init__.pyi
@@ -0,0 +1,20 @@
+# Copyright 2019-2024 The kikuchipy developers
+#
+# This file is part of kikuchipy.
+#
+# kikuchipy is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# kikuchipy is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with kikuchipy. If not, see <http://www.gnu.org/licenses/>.
+
+from ._api import file_reader
+
+__all__ = ["file_reader"]
diff --git a/src/kikuchipy/io/plugins/edax_h5ebsd.py b/src/kikuchipy/io/plugins/edax_h5ebsd/_api.py
similarity index 77%
rename from src/kikuchipy/io/plugins/edax_h5ebsd.py
rename to src/kikuchipy/io/plugins/edax_h5ebsd/_api.py
index 48e2dc51..50aa4bac 100644
--- a/src/kikuchipy/io/plugins/edax_h5ebsd.py
+++ b/src/kikuchipy/io/plugins/edax_h5ebsd/_api.py
@@ -25,31 +25,6 @@
 from kikuchipy.detectors.ebsd_detector import EBSDDetector
 from kikuchipy.io.plugins._h5ebsd import H5EBSDReader, _hdf5group2dict
 
-__all__ = ["file_reader"]
-
-
-# Plugin characteristics
-# ----------------------
-format_name = "edax_h5ebsd"
-description = (
-    "Read support for electron backscatter diffraction patterns stored "
-    "in an HDF5 file formatted in EDAX TSL's h5ebsd format, similar to "
-    "the format described in Jackson et al.: h5ebsd: an archival data "
-    "format for electron back-scatter diffraction data sets. "
-    "Integrating Materials and Manufacturing Innovation 2014 3:4, doi: "
-    "https://dx.doi.org/10.1186/2193-9772-3-4."
-)
-full_support = False
-# Recognised file extension
-file_extensions = ["h5", "hdf5", "h5ebsd"]
-default_extension = 0
-# Writing capabilities (signal dimensions, navigation dimensions)
-writes = False
-
-# Unique HDF5 footprint
-footprint = ["manufacturer", "version"]
-manufacturer = "edax"
-
 
 class EDAXH5EBSDReader(H5EBSDReader):
     """EDAX TSL h5ebsd file reader.
@@ -76,16 +51,15 @@ def scan2dict(self, group: h5py.Group, lazy: bool = False) -> dict:
         group
             Group with patterns.
         lazy
-            Whether to read dataset lazily (default is ``False``).
+            Whether to read dataset lazily. Default is False.
 
         Returns
         -------
         scan_dict
-            Dictionary with keys ``"axes"``, ``"data"``, ``"metadata"``,
-            ``"original_metadata"``, ``"detector"``,
-            ``"static_background"``, and ``"xmap"``. This dictionary can
-             be passed as keyword arguments to create an
-             :class:`~kikuchipy.signals.EBSD` signal.
+            Dictionary with keys "axes", "data", "metadata",
+            "original_metadata", "detector", "static_background", and
+            "xmap". This dictionary can be passed as keyword arguments
+            to create an :class:`~kikuchipy.signals.EBSD` signal.
 
         Raises
         ------
@@ -146,9 +120,9 @@ def scan2dict(self, group: h5py.Group, lazy: bool = False) -> dict:
         scan_dict["detector"] = EBSDDetector(
             shape=(sy, sx),
             px_size=px_size,
-            tilt=hd.get("Camera Elevation Angle", 0),
-            azimuthal=hd.get("Camera Azimuthal Angle", 0),
-            sample_tilt=hd.get("Sample Tilt", 70),
+            tilt=hd.get("Camera Elevation Angle", 0.0),
+            azimuthal=hd.get("Camera Azimuthal Angle", 0.0),
+            sample_tilt=hd.get("Sample Tilt", 70.0),
             pc=(
                 hd.get("Pattern Center Calibration", {}).get("x-star", 0.5),
                 hd.get("Pattern Center Calibration", {}).get("y-star", 0.5),
@@ -183,16 +157,15 @@ def file_reader(
     lazy
         Open the data lazily without actually reading the data from disk
         until required. Allows opening arbitrary sized datasets. Default
-        is ``False``.
+        is False.
     **kwargs
         Keyword arguments passed to :class:`h5py.File`.
 
     Returns
     -------
     scan_dict_list
-        List of one or more dictionaries with the keys ``"axes"``,
-        ``"data"``, ``"metadata"``, ``"original_metadata"``,
-        ``"detector"``, and ``"xmap"``. This
+        List of one or more dictionaries with the keys "axes", "data",
+        "metadata", "original_metadata", "detector", and "xmap". This
         dictionary can be passed as keyword arguments to create an
         :class:`~kikuchipy.signals.EBSD` signal.
     """
diff --git a/src/kikuchipy/io/plugins/edax_h5ebsd/specification.yaml b/src/kikuchipy/io/plugins/edax_h5ebsd/specification.yaml
new file mode 100644
index 00000000..a6b80f51
--- /dev/null
+++ b/src/kikuchipy/io/plugins/edax_h5ebsd/specification.yaml
@@ -0,0 +1,13 @@
+name: edax_h5ebsd
+description: >
+  Read support for electron backscatter diffraction patterns stored in
+  an HDF5 file formatted in EDAX TSL's h5ebsd format, similar to the
+  format described in Jackson et al.: h5ebsd: an archival data format
+  for electron back-scatter diffraction data sets. Integrating Materials
+  and Manufacturing Innovation 2014 3:4,
+  doi: https://dx.doi.org/10.1186/2193-9772-3-4.
+file_extensions: ['h5', 'hdf5', 'h5ebsd']
+default_extension: 0
+writes: False
+manufacturer: edax
+footprints: ['manufacturer', 'version']
diff --git a/src/kikuchipy/io/plugins/emsoft_ebsd/__init__.py b/src/kikuchipy/io/plugins/emsoft_ebsd/__init__.py
new file mode 100644
index 00000000..2f857066
--- /dev/null
+++ b/src/kikuchipy/io/plugins/emsoft_ebsd/__init__.py
@@ -0,0 +1,23 @@
+# Copyright 2019-2024 The kikuchipy developers
+#
+# This file is part of kikuchipy.
+#
+# kikuchipy is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# kikuchipy is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with kikuchipy. If not, see <http://www.gnu.org/licenses/>.
+
+import lazy_loader
+
+__getattr__, __dir__, __all__ = lazy_loader.attach_stub(__name__, __file__)
+
+
+del lazy_loader
diff --git a/src/kikuchipy/io/plugins/emsoft_ebsd/__init__.pyi b/src/kikuchipy/io/plugins/emsoft_ebsd/__init__.pyi
new file mode 100644
index 00000000..65a3a663
--- /dev/null
+++ b/src/kikuchipy/io/plugins/emsoft_ebsd/__init__.pyi
@@ -0,0 +1,20 @@
+# Copyright 2019-2024 The kikuchipy developers
+#
+# This file is part of kikuchipy.
+#
+# kikuchipy is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# kikuchipy is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with kikuchipy. If not, see <http://www.gnu.org/licenses/>.
+
+from ._api import file_reader
+
+__all__ = ["file_reader"]
diff --git a/src/kikuchipy/io/plugins/emsoft_ebsd.py b/src/kikuchipy/io/plugins/emsoft_ebsd/_api.py
similarity index 77%
rename from src/kikuchipy/io/plugins/emsoft_ebsd.py
rename to src/kikuchipy/io/plugins/emsoft_ebsd/_api.py
index ee123dc7..20f6cc51 100644
--- a/src/kikuchipy/io/plugins/emsoft_ebsd.py
+++ b/src/kikuchipy/io/plugins/emsoft_ebsd/_api.py
@@ -30,27 +30,6 @@
 from kikuchipy.detectors.ebsd_detector import EBSDDetector
 from kikuchipy.io.plugins._h5ebsd import _hdf5group2dict
 
-__all__ = ["file_reader"]
-
-
-# Plugin characteristics
-# ----------------------
-format_name = "emsoft_ebsd"
-description = (
-    "Read support for dynamically simulated electron backscatter "
-    "diffraction patterns stored in EMsoft's HDF5 file format produced "
-    "by their EMEBSD.f90 program."
-)
-full_support = False
-# Recognised file extension
-file_extensions = ["h5", "hdf5"]
-default_extension = 0
-# Writing capabilities
-writes = False
-
-# Unique HDF5 footprint
-footprint = ["emdata/ebsd/ebsdpatterns"]
-
 
 def file_reader(
     filename: str | Path,
@@ -72,23 +51,23 @@ def file_reader(
     lazy
         Open the data lazily without actually reading the data from disk
         until requested. Allows opening datasets larger than available
-        memory. Default is ``False``.
+        memory. Default is False.
     **kwargs
         Keyword arguments passed to :class:`h5py.File`.
 
     Returns
     -------
     signal_dict_list
-        Data, axes, metadata and original metadata.
+        Data, axes, metadata, and original metadata.
     """
     mode = kwargs.pop("mode", "r")
-    f = h5py.File(filename, mode=mode, **kwargs)
+    file = h5py.File(filename, mode=mode, **kwargs)
 
-    _check_file_format(f)
+    _check_file_format(file)
 
-    group = f["/"]
-    hd = _hdf5group2dict(group, data_dset_names=["EBSDPatterns"], recursive=True)
-    nml_dict = hd["NMLparameters"]["EBSDNameList"]
+    group = file["/"]
+    header = _hdf5group2dict(group, data_dset_names=["EBSDPatterns"], recursive=True)
+    nml_dict = header["NMLparameters"]["EBSDNameList"]
 
     # --- Metadata
     fname = os.path.basename(filename).split(".")[0]
@@ -99,10 +78,10 @@ def file_reader(
         "General": {"original_filename": fname, "title": fname},
         "Signal": {"signal_type": "EBSD", "record_by": "image"},
     }
-    scan = {"metadata": metadata, "original_metadata": hd}
+    scan = {"metadata": metadata, "original_metadata": header}
 
     # --- Data
-    dataset = f["EMData/EBSD/EBSDPatterns"]
+    dataset = file["EMData/EBSD/EBSDPatterns"]
     if lazy:
         chunks = "auto" if dataset.chunks is None else dataset.chunks
         data = da.from_array(dataset, chunks=chunks)
@@ -128,8 +107,9 @@ def file_reader(
         units = ["px"] + units
         names = ["y"] + names
         scales = np.append([1], scales)
-    scan["axes"] = [
-        {
+    axes = []
+    for i in range(data.ndim):
+        axis = {
             "size": data.shape[i],
             "index_in_array": i,
             "name": names[i],
@@ -137,15 +117,15 @@ def file_reader(
             "offset": 0,
             "units": units[i],
         }
-        for i in range(data.ndim)
-    ]
+        axes.append(axis)
+    scan["axes"] = axes
 
     # --- Crystal map
-    phase = _crystaldata2phase(_hdf5group2dict(f["CrystalData"]))
-    xtal_fname = f["EMData/EBSD/xtalname"][()][0].decode().split("/")[-1]
+    phase = _crystaldata2phase(_hdf5group2dict(file["CrystalData"]))
+    xtal_fname = file["EMData/EBSD/xtalname"][()][0].decode().split("/")[-1]
     phase.name, _ = os.path.splitext(xtal_fname)
     scan["xmap"] = CrystalMap(
-        rotations=Rotation.from_euler(f["EMData/EBSD/EulerAngles"][()]),
+        rotations=Rotation.from_euler(file["EMData/EBSD/EulerAngles"][()]),
         phase_list=PhaseList(phase),
     )
 
@@ -160,32 +140,27 @@ def file_reader(
     )
 
     if not lazy:
-        f.close()
+        file.close()
 
     return [scan]
 
 
 def _check_file_format(file: h5py.File) -> None:
-    """Return whether the HDF file is in EMsoft's format.
-
-    Parameters
-    ----------
-    file: h5py:File
-    """
+    """Return whether the HDF file is in EMsoft's format."""
     try:
         program_name = file["EMheader/EBSD/ProgramName"][:][0].decode()
         if program_name != "EMEBSD.f90":
             raise KeyError
     except KeyError:
         raise IOError(
-            f"'{file.filename}' is not in EMsoft's format returned by their EMEBSD.f90 "
+            f"{file.filename!r} is not in EMsoft's format returned by their EMEBSD.f90 "
             "program."
         )
 
 
 def _crystaldata2phase(dictionary: dict) -> Phase:
-    """Return a :class:`~orix.crystal_map.Phase` object from a
-    dictionary with EMsoft CrystalData group content.
+    """Return a phase from a dictionary with EMsoft CrystalData group
+    content.
 
     Parameters
     ----------
diff --git a/src/kikuchipy/io/plugins/emsoft_ebsd/specification.yaml b/src/kikuchipy/io/plugins/emsoft_ebsd/specification.yaml
new file mode 100644
index 00000000..15e64e5a
--- /dev/null
+++ b/src/kikuchipy/io/plugins/emsoft_ebsd/specification.yaml
@@ -0,0 +1,10 @@
+name: emsoft_ebsd
+description: >
+  Read support for dynamically simulated electron backscatter
+  diffraction patterns stored in EMsoft's HDF5 file format produced by
+  their EMEBSD.f90 program."
+file_extensions: ['h5', 'hdf5']
+default_extension: 0
+writes: False
+manufacturer: emsoft
+footprints: ['emdata/ebsd/ebsdpatterns']
diff --git a/src/kikuchipy/io/plugins/emsoft_ebsd_master_pattern/__init__.py b/src/kikuchipy/io/plugins/emsoft_ebsd_master_pattern/__init__.py
new file mode 100644
index 00000000..2f857066
--- /dev/null
+++ b/src/kikuchipy/io/plugins/emsoft_ebsd_master_pattern/__init__.py
@@ -0,0 +1,23 @@
+# Copyright 2019-2024 The kikuchipy developers
+#
+# This file is part of kikuchipy.
+#
+# kikuchipy is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# kikuchipy is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with kikuchipy. If not, see <http://www.gnu.org/licenses/>.
+
+import lazy_loader
+
+__getattr__, __dir__, __all__ = lazy_loader.attach_stub(__name__, __file__)
+
+
+del lazy_loader
diff --git a/src/kikuchipy/io/plugins/emsoft_ebsd_master_pattern/__init__.pyi b/src/kikuchipy/io/plugins/emsoft_ebsd_master_pattern/__init__.pyi
new file mode 100644
index 00000000..65a3a663
--- /dev/null
+++ b/src/kikuchipy/io/plugins/emsoft_ebsd_master_pattern/__init__.pyi
@@ -0,0 +1,20 @@
+# Copyright 2019-2024 The kikuchipy developers
+#
+# This file is part of kikuchipy.
+#
+# kikuchipy is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# kikuchipy is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with kikuchipy. If not, see <http://www.gnu.org/licenses/>.
+
+from ._api import file_reader
+
+__all__ = ["file_reader"]
diff --git a/src/kikuchipy/io/plugins/emsoft_ebsd_master_pattern.py b/src/kikuchipy/io/plugins/emsoft_ebsd_master_pattern/_api.py
similarity index 68%
rename from src/kikuchipy/io/plugins/emsoft_ebsd_master_pattern.py
rename to src/kikuchipy/io/plugins/emsoft_ebsd_master_pattern/_api.py
index a876289b..95f5f83b 100644
--- a/src/kikuchipy/io/plugins/emsoft_ebsd_master_pattern.py
+++ b/src/kikuchipy/io/plugins/emsoft_ebsd_master_pattern/_api.py
@@ -22,25 +22,16 @@
 
 from kikuchipy.io.plugins._emsoft_master_pattern import EMsoftMasterPatternReader
 
-__all__ = ["file_reader"]
-
-
-# Plugin characteristics
-# ----------------------
-format_name = "emsoft_ebsd_master_pattern"
-description = (
-    "Read support for simulated electron backscatter diffraction (EBSD)"
-    "master patterns stored in an EMsoft HDF5 file."
-)
-full_support = False
-# Recognised file extension
-file_extensions = ["h5", "hdf5"]
-default_extension = 0
-# Writing capabilities
-writes = False
-
-# Unique HDF5 footprint
-footprint = ["emdata/ebsdmaster"]
+ENERGY_ARG = """Desired beam energy or energy range. If not given (default), all
+        available energies are read.
+"""
+PROJECTION_ARG = """Projection(s) to read. Options are "stereographic" (default) or
+        "lambert".
+"""
+HEMISPHERE_ARG = """Projection hemisphere(s) to read. Options are "upper" (default),
+        "lower", or "both". If "both", these will be stacked in the
+        vertical navigation axis.
+"""
 
 
 class EMsoftEBSDMasterPatternReader(EMsoftMasterPatternReader):
@@ -50,7 +41,7 @@ def diffraction_type(self) -> str:
 
     @property
     def cl_parameters_group_name(self) -> str:
-        return "MCCL"  # Monte Carlo openCL
+        return "MCCL"  # Monte Carlo OpenCL
 
     @property
     def energy_string(self) -> str:
@@ -73,28 +64,24 @@ def file_reader(
     Parameters
     ----------
     filename
-        Full file path of the HDF file.
+        Full file path of the HDF5 file.
     energy
-        Desired beam energy or energy range. If not given (default), all
-        available energies are read.
+        %s
     projection
-        Projection(s) to read. Options are ``"stereographic"`` (default)
-        or ``"lambert"``.
+        %s
     hemisphere
-        Projection hemisphere(s) to read. Options are ``"upper"``
-        (default), ``"lower"`` or ``"both"``. If ``"both"``, these will
-        be stacked in the vertical navigation axis.
+        %s
     lazy
         Open the data lazily without actually reading the data from disk
         until requested. Allows opening datasets larger than available
-        memory. Default is ``False``.
+        memory. Default is False.
     **kwargs
         Keyword arguments passed to :class:`h5py.File`.
 
     Returns
     -------
     signal_dict_list
-        Data, axes, metadata and original metadata.
+        Data, axes, metadata, and original metadata.
     """
     reader = EMsoftEBSDMasterPatternReader(
         filename=filename,
@@ -104,3 +91,6 @@ def file_reader(
         lazy=lazy,
     )
     return reader.read(**kwargs)
+
+
+file_reader.__doc__ %= (ENERGY_ARG, PROJECTION_ARG, HEMISPHERE_ARG)
diff --git a/src/kikuchipy/io/plugins/emsoft_ebsd_master_pattern/specification.yaml b/src/kikuchipy/io/plugins/emsoft_ebsd_master_pattern/specification.yaml
new file mode 100644
index 00000000..1373c83e
--- /dev/null
+++ b/src/kikuchipy/io/plugins/emsoft_ebsd_master_pattern/specification.yaml
@@ -0,0 +1,9 @@
+name: emsoft_ebsd_master_pattern
+description: >
+  Read support for simulated electron backscatter diffraction (EBSD)
+  master patterns stored in an EMsoft HDF5 file.
+file_extensions: ['h5', 'hdf5']
+default_extension: 0
+writes: False
+manufacturer: emsoft
+footprints: ['emdata/ebsdmaster']
diff --git a/src/kikuchipy/io/plugins/emsoft_ecp_master_pattern/__init__.py b/src/kikuchipy/io/plugins/emsoft_ecp_master_pattern/__init__.py
new file mode 100644
index 00000000..2f857066
--- /dev/null
+++ b/src/kikuchipy/io/plugins/emsoft_ecp_master_pattern/__init__.py
@@ -0,0 +1,23 @@
+# Copyright 2019-2024 The kikuchipy developers
+#
+# This file is part of kikuchipy.
+#
+# kikuchipy is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# kikuchipy is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with kikuchipy. If not, see <http://www.gnu.org/licenses/>.
+
+import lazy_loader
+
+__getattr__, __dir__, __all__ = lazy_loader.attach_stub(__name__, __file__)
+
+
+del lazy_loader
diff --git a/src/kikuchipy/io/plugins/emsoft_ecp_master_pattern/__init__.pyi b/src/kikuchipy/io/plugins/emsoft_ecp_master_pattern/__init__.pyi
new file mode 100644
index 00000000..65a3a663
--- /dev/null
+++ b/src/kikuchipy/io/plugins/emsoft_ecp_master_pattern/__init__.pyi
@@ -0,0 +1,20 @@
+# Copyright 2019-2024 The kikuchipy developers
+#
+# This file is part of kikuchipy.
+#
+# kikuchipy is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# kikuchipy is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with kikuchipy. If not, see <http://www.gnu.org/licenses/>.
+
+from ._api import file_reader
+
+__all__ = ["file_reader"]
diff --git a/src/kikuchipy/io/plugins/emsoft_ecp_master_pattern.py b/src/kikuchipy/io/plugins/emsoft_ecp_master_pattern/_api.py
similarity index 70%
rename from src/kikuchipy/io/plugins/emsoft_ecp_master_pattern.py
rename to src/kikuchipy/io/plugins/emsoft_ecp_master_pattern/_api.py
index 9e002e11..8643bb83 100644
--- a/src/kikuchipy/io/plugins/emsoft_ecp_master_pattern.py
+++ b/src/kikuchipy/io/plugins/emsoft_ecp_master_pattern/_api.py
@@ -21,26 +21,11 @@
 from pathlib import Path
 
 from kikuchipy.io.plugins._emsoft_master_pattern import EMsoftMasterPatternReader
-
-__all__ = ["file_reader"]
-
-
-# Plugin characteristics
-# ----------------------
-format_name = "emsoft_ecp_master_pattern"
-description = (
-    "Read support for simulated electron channeling pattern (ECP)"
-    "master patterns stored in an EMsoft HDF5 file."
+from kikuchipy.io.plugins.emsoft_ebsd_master_pattern._api import (
+    ENERGY_ARG,
+    HEMISPHERE_ARG,
+    PROJECTION_ARG,
 )
-full_support = False
-# Recognised file extension
-file_extensions = ["h5", "hdf5"]
-default_extension = 0
-# Writing capabilities
-writes = False
-
-# Unique HDF5 footprint
-footprint = ["emdata/ecpmaster"]
 
 
 class EMsoftECPMasterPatternReader(EMsoftMasterPatternReader):
@@ -50,7 +35,7 @@ def diffraction_type(self) -> str:
 
     @property
     def cl_parameters_group_name(self) -> str:
-        return "MCCL"  # Monte Carlo openCL
+        return "MCCL"  # Monte Carlo OpenCL
 
     @property
     def energy_string(self) -> str:
@@ -75,19 +60,15 @@ def file_reader(
     filename
         Full file path of the HDF file.
     energy
-        Desired beam energy or energy range. If not given (default), all
-        available energies are read.
+        %s
     projection
-        Projection(s) to read. Options are ``"stereographic"`` (default)
-        or ``"lambert"``.
+        %s
     hemisphere
-        Projection hemisphere(s) to read. Options are ``"upper"``
-        (default), ``"lower"`` or ``"both"``. If ``"both"``, these will
-        be stacked in the vertical navigation axis.
+        %s
     lazy
         Open the data lazily without actually reading the data from disk
         until requested. Allows opening datasets larger than available
-        memory. Default is ``False``.
+        memory. Default is False.
     **kwargs
         Keyword arguments passed to :class:`h5py.File`.
 
@@ -104,3 +85,6 @@ def file_reader(
         lazy=lazy,
     )
     return reader.read(**kwargs)
+
+
+file_reader.__doc__ %= (ENERGY_ARG, PROJECTION_ARG, HEMISPHERE_ARG)
diff --git a/src/kikuchipy/io/plugins/emsoft_ecp_master_pattern/specification.yaml b/src/kikuchipy/io/plugins/emsoft_ecp_master_pattern/specification.yaml
new file mode 100644
index 00000000..96fa8617
--- /dev/null
+++ b/src/kikuchipy/io/plugins/emsoft_ecp_master_pattern/specification.yaml
@@ -0,0 +1,9 @@
+name: emsoft_ecp_master_pattern
+description: >
+  Read support for simulated electron channeling pattern (ECP) master
+  patterns stored in an EMsoft HDF5 file."
+file_extensions: ['h5', 'hdf5']
+default_extension: 0
+writes: False
+manufacturer: emsoft
+footprints: ['emdata/ecpmaster']
diff --git a/src/kikuchipy/io/plugins/emsoft_tkd_master_pattern/__init__.py b/src/kikuchipy/io/plugins/emsoft_tkd_master_pattern/__init__.py
new file mode 100644
index 00000000..2f857066
--- /dev/null
+++ b/src/kikuchipy/io/plugins/emsoft_tkd_master_pattern/__init__.py
@@ -0,0 +1,23 @@
+# Copyright 2019-2024 The kikuchipy developers
+#
+# This file is part of kikuchipy.
+#
+# kikuchipy is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# kikuchipy is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with kikuchipy. If not, see <http://www.gnu.org/licenses/>.
+
+import lazy_loader
+
+__getattr__, __dir__, __all__ = lazy_loader.attach_stub(__name__, __file__)
+
+
+del lazy_loader
diff --git a/src/kikuchipy/io/plugins/emsoft_tkd_master_pattern/__init__.pyi b/src/kikuchipy/io/plugins/emsoft_tkd_master_pattern/__init__.pyi
new file mode 100644
index 00000000..65a3a663
--- /dev/null
+++ b/src/kikuchipy/io/plugins/emsoft_tkd_master_pattern/__init__.pyi
@@ -0,0 +1,20 @@
+# Copyright 2019-2024 The kikuchipy developers
+#
+# This file is part of kikuchipy.
+#
+# kikuchipy is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# kikuchipy is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with kikuchipy. If not, see <http://www.gnu.org/licenses/>.
+
+from ._api import file_reader
+
+__all__ = ["file_reader"]
diff --git a/src/kikuchipy/io/plugins/emsoft_tkd_master_pattern.py b/src/kikuchipy/io/plugins/emsoft_tkd_master_pattern/_api.py
similarity index 70%
rename from src/kikuchipy/io/plugins/emsoft_tkd_master_pattern.py
rename to src/kikuchipy/io/plugins/emsoft_tkd_master_pattern/_api.py
index 318ab173..a64d3490 100644
--- a/src/kikuchipy/io/plugins/emsoft_tkd_master_pattern.py
+++ b/src/kikuchipy/io/plugins/emsoft_tkd_master_pattern/_api.py
@@ -21,26 +21,11 @@
 from pathlib import Path
 
 from kikuchipy.io.plugins._emsoft_master_pattern import EMsoftMasterPatternReader
-
-__all__ = ["file_reader"]
-
-
-# Plugin characteristics
-# ----------------------
-format_name = "emsoft_tkd_master_pattern"
-description = (
-    "Read support for simulated transmission kikuchi diffraction (TKD)"
-    "master patterns stored in an EMsoft HDF5 file."
+from kikuchipy.io.plugins.emsoft_ebsd_master_pattern._api import (
+    ENERGY_ARG,
+    HEMISPHERE_ARG,
+    PROJECTION_ARG,
 )
-full_support = False
-# Recognised file extension
-file_extensions = ["h5", "hdf5"]
-default_extension = 0
-# Writing capabilities
-writes = False
-
-# Unique HDF5 footprint
-footprint = ["emdata/tkdmaster"]
 
 
 class EMsoftTKDMasterPatternReader(EMsoftMasterPatternReader):
@@ -50,7 +35,7 @@ def diffraction_type(self) -> str:
 
     @property
     def cl_parameters_group_name(self) -> str:
-        return "MCCLfoil"  # Monte Carlo openCL
+        return "MCCLfoil"  # Monte Carlo OpenCL
 
     @property
     def energy_string(self) -> str:
@@ -75,19 +60,15 @@ def file_reader(
     filename
         Full file path of the HDF file.
     energy
-        Desired beam energy or energy range. If not given (default), all
-        available energies are read.
+        %s
     projection
-        Projection(s) to read. Options are ``"stereographic"`` (default)
-        or ``"lambert"``.
+        %s
     hemisphere
-        Projection hemisphere(s) to read. Options are ``"upper"``
-        (default), ``"lower"`` or ``"both"``. If ``"both"``, these will
-        be stacked in the vertical navigation axis.
+        %s
     lazy
         Open the data lazily without actually reading the data from disk
         until requested. Allows opening datasets larger than available
-        memory. Default is ``False``.
+        memory. Default is False.
     **kwargs
         Keyword arguments passed to :class:`h5py.File`.
 
@@ -104,3 +85,6 @@ def file_reader(
         lazy=lazy,
     )
     return reader.read(**kwargs)
+
+
+file_reader.__doc__ %= (ENERGY_ARG, PROJECTION_ARG, HEMISPHERE_ARG)
diff --git a/src/kikuchipy/io/plugins/emsoft_tkd_master_pattern/specification.yaml b/src/kikuchipy/io/plugins/emsoft_tkd_master_pattern/specification.yaml
new file mode 100644
index 00000000..90bdf463
--- /dev/null
+++ b/src/kikuchipy/io/plugins/emsoft_tkd_master_pattern/specification.yaml
@@ -0,0 +1,9 @@
+name: emsoft_tkd_master_pattern
+description: >
+  Read support for simulated transmission kikuchi diffraction (TKD)
+  master patterns stored in an EMsoft HDF5 file.
+file_extensions: ['h5', 'hdf5']
+default_extension: 0
+writes: False
+manufacturer: emsoft
+footprints: ['emdata/tkdmaster']
diff --git a/src/kikuchipy/io/plugins/kikuchipy_h5ebsd/__init__.py b/src/kikuchipy/io/plugins/kikuchipy_h5ebsd/__init__.py
new file mode 100644
index 00000000..2f857066
--- /dev/null
+++ b/src/kikuchipy/io/plugins/kikuchipy_h5ebsd/__init__.py
@@ -0,0 +1,23 @@
+# Copyright 2019-2024 The kikuchipy developers
+#
+# This file is part of kikuchipy.
+#
+# kikuchipy is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# kikuchipy is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with kikuchipy. If not, see <http://www.gnu.org/licenses/>.
+
+import lazy_loader
+
+__getattr__, __dir__, __all__ = lazy_loader.attach_stub(__name__, __file__)
+
+
+del lazy_loader
diff --git a/src/kikuchipy/io/plugins/kikuchipy_h5ebsd/__init__.pyi b/src/kikuchipy/io/plugins/kikuchipy_h5ebsd/__init__.pyi
new file mode 100644
index 00000000..c9131db6
--- /dev/null
+++ b/src/kikuchipy/io/plugins/kikuchipy_h5ebsd/__init__.pyi
@@ -0,0 +1,20 @@
+# Copyright 2019-2024 The kikuchipy developers
+#
+# This file is part of kikuchipy.
+#
+# kikuchipy is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# kikuchipy is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with kikuchipy. If not, see <http://www.gnu.org/licenses/>.
+
+from ._api import file_reader, file_writer
+
+__all__ = ["file_reader", "file_writer"]
diff --git a/src/kikuchipy/io/plugins/kikuchipy_h5ebsd.py b/src/kikuchipy/io/plugins/kikuchipy_h5ebsd/_api.py
similarity index 83%
rename from src/kikuchipy/io/plugins/kikuchipy_h5ebsd.py
rename to src/kikuchipy/io/plugins/kikuchipy_h5ebsd/_api.py
index 02edc417..6512be8e 100644
--- a/src/kikuchipy/io/plugins/kikuchipy_h5ebsd.py
+++ b/src/kikuchipy/io/plugins/kikuchipy_h5ebsd/_api.py
@@ -23,11 +23,11 @@
 import warnings
 
 import h5py
-from hyperspy.io_plugins.hspy import overwrite_dataset
 import numpy as np
 from orix import __version__ as orix_version
 from orix.crystal_map import CrystalMap
 from orix.io.plugins.orix_hdf5 import crystalmap2dict, dict2crystalmap
+from rsciio.hspy._api import overwrite_dataset
 
 from kikuchipy import __version__ as kikuchipy_version
 from kikuchipy.detectors.ebsd_detector import EBSDDetector
@@ -35,38 +35,14 @@
 from kikuchipy.io.plugins._h5ebsd import H5EBSDReader, _dict2hdf5group, _hdf5group2dict
 from kikuchipy.signals.util._crystal_map import _xmap_is_compatible_with_signal
 
-__all__ = ["file_reader", "file_writer"]
-
 if TYPE_CHECKING:  # pragma: no cover
-    from kikuchipy.signals.ebsd import EBSD
-
-# Plugin characteristics
-# ----------------------
-format_name = "kikuchipy_h5ebsd"
-description = (
-    "Read/write support for electron backscatter diffraction patterns "
-    "stored in an HDF5 file formatted in kikuchipy's h5ebsd format, "
-    "similar to the format described in Jackson et al.: h5ebsd: an "
-    "archival data format for electron back-scatter diffraction data "
-    "sets. Integrating Materials and Manufacturing Innovation 2014 3:4,"
-    " doi: https://dx.doi.org/10.1186/2193-9772-3-4."
-)
-full_support = True
-# Recognised file extension
-file_extensions = ["h5", "hdf5", "h5ebsd"]
-default_extension = 1
-# Writing capabilities (signal dimensions, navigation dimensions)
-writes = [(2, 2), (2, 1), (2, 0)]
-
-# Unique HDF5 footprint
-footprint = ["manufacturer", "version"]
-manufacturer = "kikuchipy"
+    from kikuchipy.signals.ebsd import EBSD, LazyEBSD
 
 
 class KikuchipyH5EBSDReader(H5EBSDReader):
     """kikuchipy h5ebsd file reader.
 
-    The file contents are ment to be used for initializing a
+    The file contents are meant to be used for initializing a
     :class:`~kikuchipy.signals.EBSD` signal.
 
     Parameters
@@ -88,25 +64,24 @@ def scan2dict(self, group: h5py.Group, lazy: bool = False) -> dict:
         group
             Group with patterns.
         lazy
-            Whether to read dataset lazily (default is ``False``).
+            Whether to read dataset lazily. Default is False.
 
         Returns
         -------
         scan_dict
-            Dictionary with keys ``"axes"``, ``"data"``, ``"metadata"``,
-            ``"original_metadata"``, ``"detector"``,
-            ``"static_background"``, and ``"xmap"``. This dictionary can
-             be passed as keyword arguments to create an
-             :class:`~kikuchipy.signals.EBSD` signal.
+            Dictionary with keys "axes", "data", "metadata",
+            "original_metadata", "detector", "static_background", and
+            "xmap". This dictionary can be passed as keyword arguments
+            to create an :class:`~kikuchipy.signals.EBSD` signal.
         """
-        hd = _hdf5group2dict(group["EBSD/Header"], recursive=True)
+        header = _hdf5group2dict(group["EBSD/Header"], recursive=True)
 
         # Note: When written, these were obtained from the
         # `axes_manager` attribute, not the `xmap` one
-        ny, nx = hd["n_rows"], hd["n_columns"]
-        sy, sx = hd["pattern_height"], hd["pattern_width"]
-        dy, dx = hd.get("step_y", 1), hd.get("step_x", 1)
-        px_size = hd.get("detector_pixel_size", 1)
+        ny, nx = header["n_rows"], header["n_columns"]
+        sy, sx = header["pattern_height"], header["pattern_width"]
+        dy, dx = header.get("step_y", 1), header.get("step_x", 1)
+        px_size = header.get("detector_pixel_size", 1)
 
         # --- Metadata
         fname, title = self.get_metadata_filename_title(group.name)
@@ -133,7 +108,7 @@ def scan2dict(self, group: h5py.Group, lazy: bool = False) -> dict:
             "manufacturer": self.manufacturer,
             "version": self.version,
         }
-        scan_dict["original_metadata"].update(hd)
+        scan_dict["original_metadata"].update(header)
 
         # --- Crystal map
         if "CrystalMap" in group["EBSD"]:
@@ -146,10 +121,12 @@ def scan2dict(self, group: h5py.Group, lazy: bool = False) -> dict:
         scan_dict["xmap"] = xmap
 
         # --- Static background
-        scan_dict["static_background"] = hd.get("static_background")
+        scan_dict["static_background"] = header.get("static_background")
 
         # --- Detector
-        pc = np.dstack((hd.get("pcx", 0.5), hd.get("pcy", 0.5), hd.get("pcz", 0.5)))
+        pc = np.dstack(
+            (header.get("pcx", 0.5), header.get("pcy", 0.5), header.get("pcz", 0.5))
+        )
         if pc.size > 3:
             try:
                 pc = pc.reshape((ny, nx, 3))
@@ -179,10 +156,10 @@ def scan2dict(self, group: h5py.Group, lazy: bool = False) -> dict:
         scan_dict["detector"] = EBSDDetector(
             shape=(sy, sx),
             px_size=px_size,
-            binning=hd.get("binning", 1),
-            tilt=hd.get("elevation_angle", 0),
-            azimuthal=hd.get("azimuth_angle", 0),
-            sample_tilt=hd.get("sample_tilt", 70),
+            binning=header.get("binning", 1),
+            tilt=header.get("elevation_angle", 0.0),
+            azimuthal=header.get("azimuth_angle", 0.0),
+            sample_tilt=header.get("sample_tilt", 70.0),
             pc=pc,
         )
 
@@ -199,9 +176,9 @@ def file_reader(
     and an EBSD detector from a kikuchipy h5ebsd file
     :cite:`jackson2014h5ebsd`.
 
-    Not ment to be used directly; use :func:`~kikuchipy.load`.
+    Not meant to be used directly; use :func:`~kikuchipy.load`.
 
-    The file is closed after reading if ``lazy=False``.
+    The file is closed after reading if *lazy* False.
 
     Parameters
     ----------
@@ -214,17 +191,17 @@ def file_reader(
     lazy
         Open the data lazily without actually reading the data from disk
         until required. Allows opening arbitrary sized datasets. Default
-        is ``False``.
+        is False.
     **kwargs
         Keyword arguments passed to :class:`h5py.File`.
 
     Returns
     -------
     scan_dict_list
-        List of one or more dictionaries with the keys ``"axes"``,
-        ``"data"``, ``"metadata"``, ``"original_metadata"``,
-        ``"detector"``, ``"static_background"``, and ``"xmap"``. This
-        dictionary can be passed as keyword arguments to create an
+        List of one or more dictionaries with the keys "axes", "data",
+        "metadata", "original_metadata", "detector",
+        "static_background", and "xmap". This dictionary can be passed
+        as keyword arguments to create an
         :class:`~kikuchipy.signals.EBSD` signal.
     """
     reader = KikuchipyH5EBSDReader(filename, **kwargs)
@@ -242,8 +219,13 @@ class KikuchipyH5EBSDWriter:
         EBSD signal.
     add_scan
         Whether to add the signal to the file if it exists and is
-        closed. If the file exists but this is not ``True``, the file
+        closed. If the file exists but this is not True, the file
         will be overwritten.
+
+    Raises
+    ------
+    OSError
+        If the file is already open.
     """
 
     def __init__(
@@ -309,7 +291,7 @@ def check_file(self) -> None:
         IOError
             If the file was not created with kikuchipy, or if there are
             no groups in the top group containing the datasets
-            ``"EBSD/Data"`` and ``"EBSD/Header"``.
+            "EBSD/Data" and "EBSD/Header".
         """
         error = None
         top_groups = _hdf5group2dict(self.file["/"])
@@ -348,7 +330,7 @@ def get_valid_scan_number(self, scan_number: int = 1) -> int:
         Returns
         -------
         valid_scan_number
-            ``scan_number`` or a new valid number if an existing scan in
+            *scan_number* or a new valid number if an existing scan in
             the file has this number.
 
         Raises
@@ -367,8 +349,8 @@ def get_valid_scan_number(self, scan_number: int = 1) -> int:
         return scan_number
 
     def get_xmap_dict(self) -> dict:
-        """Return a dictionary produced from :attr:`signal.xmap` or an
-        empty one.
+        """Return a dictionary produced from :attr:`signal`'s *xmap*
+        attribute, or an empty one.
         """
         (ny, nx, *_), (dy, dx, _) = self.data_shape_scale
         xmap = self.signal.xmap
@@ -394,8 +376,7 @@ def write(self, scan_number: int = 1, **kwargs) -> None:
         Raises
         ------
         ValueError
-            If the file exists but ``add_scan`` is ``None`` or
-            ``False``.
+            If the file exists but *scan_number* is *None* or *False*.
         """
         if self.file_exists:
             valid_scan_number = self.get_valid_scan_number(scan_number)
@@ -475,11 +456,11 @@ def write(self, scan_number: int = 1, **kwargs) -> None:
 
 def file_writer(
     filename: str,
-    signal,
+    signal: "EBSD | LazyEBSD",
     add_scan: bool | None = None,
     scan_number: int = 1,
     **kwargs,
-):
+) -> None:
     """Write an :class:`~kikuchipy.signals.EBSD` or
     :class:`~kikuchipy.signals.LazyEBSD` signal to an existing but not
     open or new h5ebsd file.
@@ -493,7 +474,7 @@ def file_writer(
     ----------
     filename
         Full path of HDF5 file.
-    signal : kikuchipy.signals.EBSD or kikuchipy.signals.LazyEBSD
+    signal
         Signal instance.
     add_scan
         Add signal to an existing, but not open, h5ebsd file. If it does
diff --git a/src/kikuchipy/io/plugins/kikuchipy_h5ebsd/specification.yaml b/src/kikuchipy/io/plugins/kikuchipy_h5ebsd/specification.yaml
new file mode 100644
index 00000000..8d3f4840
--- /dev/null
+++ b/src/kikuchipy/io/plugins/kikuchipy_h5ebsd/specification.yaml
@@ -0,0 +1,13 @@
+name: kikuchipy_h5ebsd
+description: >
+  Read/write support for electron backscatter diffraction patterns
+  stored in an HDF5 file formatted in kikuchipy's h5ebsd format, similar
+  to the format described in Jackson et al.: h5ebsd: an archival data
+  format for electron back-scatter diffraction data sets. Integrating
+  Materials and Manufacturing Innovation 2014 3:4,
+  doi: https://dx.doi.org/10.1186/2193-9772-3-4.
+file_extensions: ['h5', 'hdf5', 'h5ebsd']
+default_extension: 1
+writes: [[2, 2], [2, 1], [2, 0]]
+manufacturer: kikuchipy
+footprints: ['manufacturer', 'version']
diff --git a/src/kikuchipy/io/plugins/nordif/__init__.py b/src/kikuchipy/io/plugins/nordif/__init__.py
new file mode 100644
index 00000000..2f857066
--- /dev/null
+++ b/src/kikuchipy/io/plugins/nordif/__init__.py
@@ -0,0 +1,23 @@
+# Copyright 2019-2024 The kikuchipy developers
+#
+# This file is part of kikuchipy.
+#
+# kikuchipy is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# kikuchipy is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with kikuchipy. If not, see <http://www.gnu.org/licenses/>.
+
+import lazy_loader
+
+__getattr__, __dir__, __all__ = lazy_loader.attach_stub(__name__, __file__)
+
+
+del lazy_loader
diff --git a/src/kikuchipy/io/plugins/nordif/__init__.pyi b/src/kikuchipy/io/plugins/nordif/__init__.pyi
new file mode 100644
index 00000000..c9131db6
--- /dev/null
+++ b/src/kikuchipy/io/plugins/nordif/__init__.pyi
@@ -0,0 +1,20 @@
+# Copyright 2019-2024 The kikuchipy developers
+#
+# This file is part of kikuchipy.
+#
+# kikuchipy is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# kikuchipy is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with kikuchipy. If not, see <http://www.gnu.org/licenses/>.
+
+from ._api import file_reader, file_writer
+
+__all__ = ["file_reader", "file_writer"]
diff --git a/src/kikuchipy/io/plugins/nordif.py b/src/kikuchipy/io/plugins/nordif/_api.py
similarity index 87%
rename from src/kikuchipy/io/plugins/nordif.py
rename to src/kikuchipy/io/plugins/nordif/_api.py
index 693a3b57..7e025db0 100644
--- a/src/kikuchipy/io/plugins/nordif.py
+++ b/src/kikuchipy/io/plugins/nordif/_api.py
@@ -17,6 +17,7 @@
 
 """Reader and writer of EBSD patterns from a NORDIF binary file."""
 
+from io import TextIOWrapper
 import logging
 import os
 from pathlib import Path
@@ -28,26 +29,14 @@
 import numpy as np
 from orix.crystal_map import CrystalMap
 
+from kikuchipy.constants import VisibleDeprecationWarning
 from kikuchipy.detectors.ebsd_detector import EBSDDetector
 
 if TYPE_CHECKING:  # pragma: no cover
     from kikuchipy.signals.ebsd import EBSD, LazyEBSD
 
-__all__ = ["file_reader", "file_writer"]
-
 _logger = logging.getLogger(__name__)
 
-# Plugin characteristics
-# ----------------------
-format_name = "NORDIF"
-description = "Read/write support for NORDIF pattern and setting files"
-full_support = False
-# Recognised file extension
-file_extensions = ["dat"]
-default_extension = 0
-# Writing capabilities (signal dimensions, navigation dimensions)
-writes = [(2, 2), (2, 1), (2, 0)]
-
 
 def file_reader(
     filename: str | Path,
@@ -59,26 +48,26 @@ def file_reader(
 ) -> list[dict]:
     """Read electron backscatter patterns from a NORDIF data file.
 
-    Not meant to be used directly; use :func:`~kikuchipy.load`.
+    Not meant to be used directly; use :func:`~kikuchipy.load` instead.
 
     Parameters
     ----------
     filename
         File path to NORDIF data file.
     mmap_mode
-        Memory map mode. If not given, ``"r"`` is used unless
-        ``lazy=True``, in which case ``"c"`` is used.
+        Memory map mode. If not given, "r" is used unless *lazy* is
+        True, in which case "c" is used.
     scan_size
         Scan size in number of patterns in width and height.
     pattern_size
         Pattern size in detector pixels in width and height.
     setting_file
-        File path to NORDIF setting file (default is `Setting.txt` in
-        same directory as ``filename``).
+        File path to NORDIF setting file (default is "Setting.txt" in
+        same directory as *filename*).
     lazy
         Open the data lazily without actually reading the data from disk
         until required. Allows opening arbitrary sized datasets. Default
-        is ``False``.
+        is False.
 
     Returns
     -------
@@ -86,7 +75,10 @@ def file_reader(
         Data, axes, metadata and original metadata.
     """
     if mmap_mode is None:
-        mmap_mode = "r" if lazy else "c"
+        if lazy:
+            mmap_mode = "r"
+        else:
+            mmap_mode = "c"
 
     scan = {}
 
@@ -94,9 +86,9 @@ def file_reader(
     if "+" in mmap_mode or ("write" in mmap_mode and "copyonwrite" != mmap_mode):
         if lazy:
             raise ValueError("Lazy loading does not support in-place writing")
-        f = open(filename, mode="r+b")
+        file = open(filename, mode="r+b")
     else:
-        f = open(filename, mode="rb")
+        file = open(filename, mode="rb")
 
     # Get metadata from setting file
     folder, _ = os.path.split(filename)
@@ -115,7 +107,7 @@ def file_reader(
                 "No setting file found and no scan_size or pattern_size detected in "
                 "input arguments. These must be set if no setting file is provided"
             )
-        warnings.filterwarnings("ignore", category=np.VisibleDeprecationWarning)
+        warnings.filterwarnings("ignore", category=VisibleDeprecationWarning)
         md = {}
         omd = {}
         detector_dict = None
@@ -127,7 +119,7 @@ def file_reader(
     except FileNotFoundError:
         scan["static_background"] = None
         warnings.warn(
-            f"Could not read static background pattern '{static_bg_file}', however it "
+            f"Could not read static background pattern {static_bg_file!r}, however it "
             "can be set as 'EBSD.static_background'"
         )
 
@@ -155,10 +147,10 @@ def file_reader(
     # Read data from file
     data_size = ny * nx * sy * sx
     if not lazy:
-        f.seek(0)
-        data = np.fromfile(f, dtype="uint8", count=data_size)
+        file.seek(0)
+        data = np.fromfile(file, dtype="uint8", count=data_size)
     else:
-        data = np.memmap(f.name, mode=mmap_mode, dtype="uint8")
+        data = np.memmap(file.name, mode=mmap_mode, dtype="uint8")
 
     try:
         data = data.reshape((ny, nx, sy, sx)).squeeze()
@@ -209,7 +201,7 @@ def file_reader(
     # --- Crystal map
     scan["xmap"] = CrystalMap.empty(shape=(ny, nx), step_sizes=(dy, dx))
 
-    f.close()
+    file.close()
 
     return [scan]
 
@@ -224,8 +216,8 @@ def _get_settings_from_file(
     filename
         File path of NORDIF setting file.
     pattern_type
-        Whether to read the ``"acquisition"`` (default) or
-        ``"calibration"`` settings.
+        Whether to read the "acquisition" (default) or "calibration"
+        settings.
 
     Returns
     -------
@@ -317,7 +309,9 @@ def _get_settings_from_file(
     return md, omd, scan_size, detector
 
 
-def _get_string(content: list, expression: str, line_no: int, file) -> str:
+def _get_string(
+    content: list, expression: str, line_no: int, file: TextIOWrapper
+) -> str:
     """Get relevant part of string using regular expression.
 
     Parameters
@@ -328,7 +322,7 @@ def _get_string(content: list, expression: str, line_no: int, file) -> str:
         Regular expression.
     line_no
         Line number to search in.
-    file : file instance
+    file
         File handle of open setting file.
 
     Returns
@@ -339,8 +333,8 @@ def _get_string(content: list, expression: str, line_no: int, file) -> str:
     match = re.search(expression, content[line_no])
     if match is None:
         warnings.warn(
-            f"Failed to read line {line_no - 1} in settings file '{file.name}' using "
-            f"regular expression '{expression}'"
+            f"Failed to read line {line_no - 1} in settings file {file.name!r} using "
+            f"regular expression {expression!r}"
         )
         return ""
     else:
@@ -427,9 +421,10 @@ def scale(x, return_tuple: bool = False):
             omd["area"]["shape_scaled"] = scale(omd["area"]["shape"], True)
 
         if omd["roi"]["shape_scaled"] != (ny, nx):
+            n_samples_calculated = tuple(int(i) for i in omd["roi"]["shape_scaled"])
             _logger.debug(
                 f"Number of samples {(ny, nx)} differs from the one calculated from "
-                f"area/ROI shapes {omd['roi']['shape_scaled']}"
+                f"area/ROI shapes {n_samples_calculated}"
             )
 
     # Try to read area overview image
@@ -443,9 +438,7 @@ def scale(x, return_tuple: bool = False):
 
 
 def file_writer(filename: str, signal: "EBSD | LazyEBSD") -> None:
-    """Write an :class:`~kikuchipy.signals.EBSD` or
-    :class:`~kikuchipy.signals.LazyEBSD` instance to a NORDIF binary
-    file.
+    """Write an EBSD signal to a NORDIF binary file.
 
     Parameters
     ----------
@@ -454,10 +447,10 @@ def file_writer(filename: str, signal: "EBSD | LazyEBSD") -> None:
     signal
         Signal instance.
     """
-    with open(filename, "wb") as f:
+    with open(filename, "wb") as file:
         if signal._lazy:
-            for pattern in signal._iterate_signal():
-                np.array(pattern.flatten()).tofile(f)
+            for pattern in signal._iterate_signal(iterpath="flyback"):
+                np.array(pattern.flatten()).tofile(file)
         else:
-            for pattern in signal._iterate_signal():
-                pattern.flatten().tofile(f)
+            for pattern in signal._iterate_signal(iterpath="flyback"):
+                pattern.flatten().tofile(file)
diff --git a/src/kikuchipy/io/plugins/nordif/specification.yaml b/src/kikuchipy/io/plugins/nordif/specification.yaml
new file mode 100644
index 00000000..813feb68
--- /dev/null
+++ b/src/kikuchipy/io/plugins/nordif/specification.yaml
@@ -0,0 +1,7 @@
+name: nordif
+description: Read/write support for NORDIF's binary pattern file
+file_extensions: ['dat']
+default_extension: 0
+writes: [[2, 2], [2, 1], [2, 0]]
+manufacturer: nordif
+footprints: []
diff --git a/src/kikuchipy/io/plugins/nordif_calibration_patterns/__init__.py b/src/kikuchipy/io/plugins/nordif_calibration_patterns/__init__.py
new file mode 100644
index 00000000..2f857066
--- /dev/null
+++ b/src/kikuchipy/io/plugins/nordif_calibration_patterns/__init__.py
@@ -0,0 +1,23 @@
+# Copyright 2019-2024 The kikuchipy developers
+#
+# This file is part of kikuchipy.
+#
+# kikuchipy is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# kikuchipy is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with kikuchipy. If not, see <http://www.gnu.org/licenses/>.
+
+import lazy_loader
+
+__getattr__, __dir__, __all__ = lazy_loader.attach_stub(__name__, __file__)
+
+
+del lazy_loader
diff --git a/src/kikuchipy/io/plugins/nordif_calibration_patterns/__init__.pyi b/src/kikuchipy/io/plugins/nordif_calibration_patterns/__init__.pyi
new file mode 100644
index 00000000..65a3a663
--- /dev/null
+++ b/src/kikuchipy/io/plugins/nordif_calibration_patterns/__init__.pyi
@@ -0,0 +1,20 @@
+# Copyright 2019-2024 The kikuchipy developers
+#
+# This file is part of kikuchipy.
+#
+# kikuchipy is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# kikuchipy is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with kikuchipy. If not, see <http://www.gnu.org/licenses/>.
+
+from ._api import file_reader
+
+__all__ = ["file_reader"]
diff --git a/src/kikuchipy/io/plugins/nordif_calibration_patterns.py b/src/kikuchipy/io/plugins/nordif_calibration_patterns/_api.py
similarity index 67%
rename from src/kikuchipy/io/plugins/nordif_calibration_patterns.py
rename to src/kikuchipy/io/plugins/nordif_calibration_patterns/_api.py
index c829318d..16639ba0 100644
--- a/src/kikuchipy/io/plugins/nordif_calibration_patterns.py
+++ b/src/kikuchipy/io/plugins/nordif_calibration_patterns/_api.py
@@ -25,28 +25,14 @@
 import numpy as np
 
 from kikuchipy.detectors.ebsd_detector import EBSDDetector
-from kikuchipy.io.plugins.nordif import _get_settings_from_file
-
-__all__ = ["file_reader"]
-
-
-# Plugin characteristics
-# ----------------------
-format_name = "NORDIF calibration patterns"
-description = "Read support for NORDIF's calibration patterns"
-full_support = False
-# Recognised file extension
-file_extensions = ["txt"]
-default_extension = 0
-# Writing capabilities (signal dimensions, navigation dimensions)
-writes = False
+from kikuchipy.io.plugins.nordif._api import _get_settings_from_file
 
 
 def file_reader(filename: str | Path, lazy: bool = False) -> list[dict]:
-    """Reader electron backscatter patterns from .bmp files stored in a
-    NORDIF project directory, their filenames listed in a text file.
+    """Return NORDIF calibration electron backscatter diffraction
+    patterns in a directory with a settings text file.
 
-    Not meant to be used directly; use :func:`~kikuchipy.load`.
+    Not meant to be used directly; use :func:`~kikuchipy.load` instead.
 
     Parameters
     ----------
@@ -58,10 +44,12 @@ def file_reader(filename: str | Path, lazy: bool = False) -> list[dict]:
     Returns
     -------
     scan
-        Data, axes, metadata and original metadata.
+        Data, axes, metadata, and original metadata.
     """
     # Get metadata from setting file
-    md, omd, _, detector = _get_settings_from_file(filename, pattern_type="calibration")
+    metadata, orig_metadata, _, detector = _get_settings_from_file(
+        str(filename), pattern_type="calibration"
+    )
     dirname = os.path.dirname(filename)
 
     scan = {}
@@ -73,12 +61,12 @@ def file_reader(filename: str | Path, lazy: bool = False) -> list[dict]:
     except FileNotFoundError:
         scan["static_background"] = None
         warnings.warn(
-            f"Could not read static background pattern '{static_bg_file}', however it "
+            f"Could not read static background pattern {static_bg_file!r}, however it "
             "can be set as 'EBSD.static_background'"
         )
 
     # Set required and other parameters in metadata
-    md.update(
+    metadata.update(
         {
             "General": {
                 "original_filename": filename,
@@ -87,12 +75,12 @@ def file_reader(filename: str | Path, lazy: bool = False) -> list[dict]:
             "Signal": {"signal_type": "EBSD", "record_by": "image"},
         }
     )
-    scan["metadata"] = md
-    scan["original_metadata"] = omd
+    scan["metadata"] = metadata
+    scan["original_metadata"] = orig_metadata
 
     scan["detector"] = EBSDDetector(**detector)
 
-    yx = omd["calibration_patterns"]["indices"]
+    yx = orig_metadata["calibration_patterns"]["indices"]
 
     data = _get_patterns(dirname=dirname, coordinates=yx)
     scan["data"] = data
@@ -100,17 +88,19 @@ def file_reader(filename: str | Path, lazy: bool = False) -> list[dict]:
     units = ["um"] * 3
     names = ["x", "dy", "dx"]
     scales = np.ones(3)
-    scan["axes"] = [
-        {
-            "size": data.shape[i],
-            "index_in_array": i,
-            "name": names[i],
-            "scale": scales[i],
-            "offset": 0,
-            "units": units[i],
-        }
-        for i in range(data.ndim)
-    ]
+    axes = []
+    for i in range(data.ndim):
+        axes.append(
+            {
+                "size": data.shape[i],
+                "index_in_array": i,
+                "name": names[i],
+                "scale": scales[i],
+                "offset": 0,
+                "units": units[i],
+            }
+        )
+    scan["axes"] = axes
 
     return [scan]
 
diff --git a/src/kikuchipy/io/plugins/nordif_calibration_patterns/specification.yaml b/src/kikuchipy/io/plugins/nordif_calibration_patterns/specification.yaml
new file mode 100644
index 00000000..1bb678e0
--- /dev/null
+++ b/src/kikuchipy/io/plugins/nordif_calibration_patterns/specification.yaml
@@ -0,0 +1,7 @@
+name: nordif_calibration_patterns
+description: Read support for NORDIF's calibration patterns.
+file_extensions: ['txt']
+default_extension: 0
+writes: False
+manufacturer: nordif
+footprints: []
diff --git a/src/kikuchipy/io/plugins/oxford_binary/__init__.py b/src/kikuchipy/io/plugins/oxford_binary/__init__.py
new file mode 100644
index 00000000..2f857066
--- /dev/null
+++ b/src/kikuchipy/io/plugins/oxford_binary/__init__.py
@@ -0,0 +1,23 @@
+# Copyright 2019-2024 The kikuchipy developers
+#
+# This file is part of kikuchipy.
+#
+# kikuchipy is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# kikuchipy is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with kikuchipy. If not, see <http://www.gnu.org/licenses/>.
+
+import lazy_loader
+
+__getattr__, __dir__, __all__ = lazy_loader.attach_stub(__name__, __file__)
+
+
+del lazy_loader
diff --git a/src/kikuchipy/io/plugins/oxford_binary/__init__.pyi b/src/kikuchipy/io/plugins/oxford_binary/__init__.pyi
new file mode 100644
index 00000000..65a3a663
--- /dev/null
+++ b/src/kikuchipy/io/plugins/oxford_binary/__init__.pyi
@@ -0,0 +1,20 @@
+# Copyright 2019-2024 The kikuchipy developers
+#
+# This file is part of kikuchipy.
+#
+# kikuchipy is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# kikuchipy is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with kikuchipy. If not, see <http://www.gnu.org/licenses/>.
+
+from ._api import file_reader
+
+__all__ = ["file_reader"]
diff --git a/src/kikuchipy/io/plugins/oxford_binary.py b/src/kikuchipy/io/plugins/oxford_binary/_api.py
similarity index 97%
rename from src/kikuchipy/io/plugins/oxford_binary.py
rename to src/kikuchipy/io/plugins/oxford_binary/_api.py
index 2bf4c49b..bf79b13a 100644
--- a/src/kikuchipy/io/plugins/oxford_binary.py
+++ b/src/kikuchipy/io/plugins/oxford_binary/_api.py
@@ -34,21 +34,8 @@
 
 from kikuchipy.signals.util._dask import get_chunking
 
-__all__ = ["file_reader"]
-
 _logger = logging.getLogger(__name__)
 
-# Plugin characteristics
-# ----------------------
-format_name = "Oxford binary"
-description = "Read support for Oxford Instruments' binary .ebsp file."
-full_support = False
-# Recognised file extension
-file_extensions = ["ebsp"]
-default_extension = 0
-# Writing capabilities (signal dimensions, navigation dimensions)
-writes = False
-
 
 def file_reader(filename: str | Path, lazy: bool = False) -> list[dict]:
     """Read EBSD patterns from an Oxford Instruments' binary .ebsp file.
@@ -64,7 +51,7 @@ def file_reader(filename: str | Path, lazy: bool = False) -> list[dict]:
         File path to .ebsp file.
     lazy
         Read the data lazily without actually reading the data from disk
-        until required. Default is ``False``.
+        until required. Default is False.
 
     Returns
     -------
@@ -192,7 +179,7 @@ def first_pattern_position(self) -> int:
     def pattern_is_present(self) -> np.ndarray:
         """Boolean array indicating whether a pattern listed in the file
         header is present in the file or not. If not, its
-        `pattern_starts` entry is zero.
+        :attr:`pattern_starts` entry is zero.
         """
         return self.pattern_starts != 0
 
@@ -335,7 +322,7 @@ def get_pattern_footer_dtype(self, offset: int) -> list[tuple]:
         footer_dtype
             Format of each pattern footer as a list of tuples with a
             field name, data type and size. The format depends on the
-            :attr:`~self.version`.
+            :attr:`version`.
         """
         self.file.seek(offset + self.pattern_header_size + self.n_bytes)
         footer_dtype = ()
diff --git a/src/kikuchipy/io/plugins/oxford_binary/specification.yaml b/src/kikuchipy/io/plugins/oxford_binary/specification.yaml
new file mode 100644
index 00000000..11098250
--- /dev/null
+++ b/src/kikuchipy/io/plugins/oxford_binary/specification.yaml
@@ -0,0 +1,7 @@
+name: oxford_binary
+description: Read support for Oxford Instruments' binary .ebsp file.
+file_extensions: ['ebsp']
+default_extension: 0
+writes: False
+manufacturer: oxford
+footprints: []
diff --git a/src/kikuchipy/io/plugins/oxford_h5ebsd/__init__.py b/src/kikuchipy/io/plugins/oxford_h5ebsd/__init__.py
new file mode 100644
index 00000000..2f857066
--- /dev/null
+++ b/src/kikuchipy/io/plugins/oxford_h5ebsd/__init__.py
@@ -0,0 +1,23 @@
+# Copyright 2019-2024 The kikuchipy developers
+#
+# This file is part of kikuchipy.
+#
+# kikuchipy is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# kikuchipy is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with kikuchipy. If not, see <http://www.gnu.org/licenses/>.
+
+import lazy_loader
+
+__getattr__, __dir__, __all__ = lazy_loader.attach_stub(__name__, __file__)
+
+
+del lazy_loader
diff --git a/src/kikuchipy/io/plugins/oxford_h5ebsd/__init__.pyi b/src/kikuchipy/io/plugins/oxford_h5ebsd/__init__.pyi
new file mode 100644
index 00000000..65a3a663
--- /dev/null
+++ b/src/kikuchipy/io/plugins/oxford_h5ebsd/__init__.pyi
@@ -0,0 +1,20 @@
+# Copyright 2019-2024 The kikuchipy developers
+#
+# This file is part of kikuchipy.
+#
+# kikuchipy is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# kikuchipy is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with kikuchipy. If not, see <http://www.gnu.org/licenses/>.
+
+from ._api import file_reader
+
+__all__ = ["file_reader"]
diff --git a/src/kikuchipy/io/plugins/oxford_h5ebsd.py b/src/kikuchipy/io/plugins/oxford_h5ebsd/_api.py
similarity index 74%
rename from src/kikuchipy/io/plugins/oxford_h5ebsd.py
rename to src/kikuchipy/io/plugins/oxford_h5ebsd/_api.py
index 1a19500f..dce3463b 100644
--- a/src/kikuchipy/io/plugins/oxford_h5ebsd.py
+++ b/src/kikuchipy/io/plugins/oxford_h5ebsd/_api.py
@@ -28,35 +28,13 @@
 from kikuchipy.detectors.ebsd_detector import EBSDDetector
 from kikuchipy.io.plugins._h5ebsd import H5EBSDReader, _hdf5group2dict
 
-__all__ = ["file_reader"]
-
 _logger = logging.getLogger(__name__)
 
 
-# Plugin characteristics
-# ----------------------
-format_name = "oxford_h5ebsd"
-description = (
-    "Read support for electron backscatter diffraction patterns stored "
-    "in an HDF5 file formatted in Oxford Instruments' h5ebsd format, "
-    "named H5OINA. The format is similar to the format described in "
-    "Jackson et al.: h5ebsd: an archival data format for electron "
-    "back-scatter diffraction data sets. Integrating Materials and "
-    "Manufacturing Innovation 2014 3:4, doi: "
-    "https://dx.doi.org/10.1186/2193-9772-3-4."
-)
-full_support = False
-# Recognised file extension
-file_extensions = ["h5oina"]
-default_extension = 0
-# Writing capabilities (signal dimensions, navigation dimensions)
-writes = False
-
-
 class OxfordH5EBSDReader(H5EBSDReader):
     """Oxford Instruments h5ebsd (H5OINA) file reader.
 
-    The file contents are ment to be used for initializing a
+    The file contents are meant to be used for initializing a
     :class:`~kikuchipy.signals.EBSD` signal.
 
     Parameters
@@ -78,7 +56,7 @@ def scan2dict(self, group: h5py.Group, lazy: bool = False) -> dict:
         group
             Group with patterns.
         lazy
-            Whether to read dataset lazily (default is False).
+            Whether to read dataset lazily. Default is False.
 
         Returns
         -------
@@ -93,13 +71,15 @@ def scan2dict(self, group: h5py.Group, lazy: bool = False) -> dict:
         IOError
             If patterns are not acquired in a square grid.
         """
-        hd = _hdf5group2dict(group["EBSD/Header"], recursive=True)
-        dd = _hdf5group2dict(group["EBSD/Data"], data_dset_names=self.patterns_name)
+        header_group = _hdf5group2dict(group["EBSD/Header"], recursive=True)
+        data_group = _hdf5group2dict(
+            group["EBSD/Data"], data_dset_names=[self.patterns_name]
+        )
 
         # Get data shapes
-        ny, nx = hd["Y Cells"], hd["X Cells"]
-        sy, sx = hd["Pattern Height"], hd["Pattern Width"]
-        dy, dx = hd.get("Y Step", 1), hd.get("X Step", 1)
+        ny, nx = header_group["Y Cells"], header_group["X Cells"]
+        sy, sx = header_group["Pattern Height"], header_group["Pattern Width"]
+        dy, dx = header_group.get("Y Step", 1), header_group.get("X Step", 1)
         px_size = 1.0
 
         # --- Metadata
@@ -107,9 +87,9 @@ def scan2dict(self, group: h5py.Group, lazy: bool = False) -> dict:
         metadata = {
             "Acquisition_instrument": {
                 "SEM": {
-                    "beam_energy": hd.get("Beam Voltage"),
-                    "magnification": hd.get("Magnification"),
-                    "working_distance": hd.get("Working Distance"),
+                    "beam_energy": header_group.get("Beam Voltage"),
+                    "magnification": header_group.get("Magnification"),
+                    "working_distance": header_group.get("Working Distance"),
                 },
             },
             "General": {"original_filename": fname, "title": title},
@@ -129,7 +109,7 @@ def scan2dict(self, group: h5py.Group, lazy: bool = False) -> dict:
             "manufacturer": self.manufacturer,
             "version": self.version,
         }
-        scan_dict["original_metadata"].update(hd)
+        scan_dict["original_metadata"].update(header_group)
 
         # --- Crystal map
         # TODO: Implement reader of Oxford Instruments h5ebsd crystal
@@ -138,14 +118,14 @@ def scan2dict(self, group: h5py.Group, lazy: bool = False) -> dict:
         scan_dict["xmap"] = xmap
 
         # --- Static background
-        scan_dict["static_background"] = hd.get("Processed Static Background")
+        scan_dict["static_background"] = header_group.get("Processed Static Background")
 
         # --- Detector
         pc = np.column_stack(
             (
-                dd.get("Pattern Center X", 0.5),
-                dd.get("Pattern Center Y", 0.5),
-                dd.get("Detector Distance", 0.5),
+                data_group.get("Pattern Center X", 0.5),
+                data_group.get("Pattern Center Y", 0.5),
+                data_group.get("Detector Distance", 0.5),
             )
         )
         if pc.size > 3:
@@ -153,15 +133,15 @@ def scan2dict(self, group: h5py.Group, lazy: bool = False) -> dict:
         detector_kw = dict(
             shape=(sy, sx),
             pc=pc,
-            sample_tilt=np.rad2deg(hd.get("Tilt Angle", np.deg2rad(70))),
+            sample_tilt=np.rad2deg(header_group.get("Tilt Angle", np.deg2rad(70))),
             convention="oxford",
         )
-        detector_tilt_euler = hd.get("Detector Orientation Euler")
+        detector_tilt_euler = header_group.get("Detector Orientation Euler")
         try:
             detector_kw["tilt"] = np.rad2deg(detector_tilt_euler[1]) - 90
         except (IndexError, TypeError):  # pragma: no cover
             _logger.debug("Could not read detector tilt")
-        binning_str = hd.get("Camera Binning Mode")
+        binning_str = header_group.get("Camera Binning Mode")
         try:
             detector_kw["binning"] = int(binning_str.split("x")[0])
         except (IndexError, ValueError):  # pragma: no cover
diff --git a/src/kikuchipy/io/plugins/oxford_h5ebsd/specification.yaml b/src/kikuchipy/io/plugins/oxford_h5ebsd/specification.yaml
new file mode 100644
index 00000000..7a22a61e
--- /dev/null
+++ b/src/kikuchipy/io/plugins/oxford_h5ebsd/specification.yaml
@@ -0,0 +1,14 @@
+name: oxford_h5ebsd
+description: >
+  Read support for electron backscatter diffraction patterns stored in
+  an HDF5 file formatted in Oxford Instruments' h5ebsd format, named
+  H5OINA. The format is similar to the format described in Jackson et
+  al.: h5ebsd: an archival data format for electron back-scatter
+  diffraction data sets. Integrating Materials and Manufacturing
+  Innovation 2014 3:4,
+  doi: https://dx.doi.org/10.1186/2193-9772-3-4.
+file_extensions: ['h5oina']
+default_extension: 0
+writes: False
+manufacturer: oxford
+footprints: []
diff --git a/src/kikuchipy/signals/_kikuchipy_signal.py b/src/kikuchipy/signals/_kikuchipy_signal.py
index a8c234fd..97106717 100644
--- a/src/kikuchipy/signals/_kikuchipy_signal.py
+++ b/src/kikuchipy/signals/_kikuchipy_signal.py
@@ -25,9 +25,9 @@
 
 import dask.array as da
 from hyperspy._lazy_signals import LazySignal2D
-from hyperspy.misc.rgb_tools import rgb_dtypes
 from hyperspy.signals import Signal2D
 import numpy as np
+from rsciio.utils.rgb_tools import rgb_dtypes
 from skimage.util.dtype import dtype_range
 import yaml
 
@@ -57,7 +57,7 @@
 class KikuchipySignal2D(Signal2D):
     """General class for image signals in kikuchipy, extending
     HyperSpy's Signal2D class with some methods for carrying over custom
-    properties and some methods for intensity manipulation.
+    properties and others for manipulation of image intensities.
 
     Not meant to be used directly, see derived classes like
     :class:`~kikuchipy.signals.EBSD`.
@@ -90,60 +90,57 @@ def rescale_intensity(
     ) -> Any | None:
         """Rescale image intensities.
 
-        Output min./max. intensity is determined from ``out_range`` or
+        Output min./max. intensity is determined from *out_range* or
         the data type range of the :class:`numpy.dtype` passed to
-        ``dtype_out`` if ``out_range`` is ``None``.
-
-        This method is based on
-        :func:`skimage.exposure.rescale_intensity`.
+        *dtype_out* if *out_range* is None.
 
         Parameters
         ----------
         relative
-            Whether to keep relative intensities between images (default
-            is ``False``). If ``True``, ``in_range`` must be ``None``,
-            because ``in_range`` is in this case set to the global
-            min./max. intensity. Use with care, as this requires the
-            computation of the min./max. intensity of the signal before
-            rescaling.
+            Whether to keep relative intensities between images. Default
+            is False. If True, *in_range* must be None, because
+            *in_range* is in this case set to the global min./max.
+            intensity. Use with care, as this requires the computation
+            of the min./max. intensity of the signal before rescaling.
         in_range
             Min./max. intensity of input images. If not given,
-            ``in_range`` is set to pattern min./max intensity. Contrast
-            stretching is performed when ``in_range`` is set to a
-            narrower intensity range than the input patterns. Must be
-            ``None`` if ``relative=True`` or ``percentiles`` are passed.
+            *in_range* is set to pattern min./max intensity. Contrast
+            stretching is performed when *in_range* is set to a narrower
+            intensity range than the input patterns. Must be None if
+            *relative* is True or *percentiles* are given.
         out_range
             Min./max. intensity of output images. If not given,
-            ``out_range`` is set to ``dtype_out`` min./max according to
-            ``skimage.util.dtype.dtype_range``.
+            *out_range* is set to *dtype_out* min./max according to
+            :func:`skimage.util.dtype.dtype_range`.
         dtype_out
-            Data type of rescaled images, default is input images' data
+            Data type of rescaled images. Default is input images' data
             type.
         percentiles
             Disregard intensities outside these percentiles. Calculated
-            per image. Must be ``None`` if ``in_range`` or ``relative``
-            is passed. Default is ``None``.
+            per image. Must be None if *in_range* or *relative*
+            is passed. Default is None.
         show_progressbar
             Whether to show a progressbar. If not given, the value of
             :obj:`hyperspy.api.preferences.General.show_progressbar`
             is used.
         inplace
             Whether to operate on the current signal or return a new
-            one. Default is ``True``.
+            one. Default is True.
         lazy_output
             Whether the returned signal is lazy. If not given this
-            follows from the current signal. Can only be ``True`` if
-            ``inplace=False``.
+            follows from the current signal. Can only be True if
+            *inplace* is False.
 
         Returns
         -------
         s_out
-            Rescaled signal, returned if ``inplace=False``. Whether
-            it is lazy is determined from ``lazy_output``.
+            Rescaled signal, returned if *inplace* is False. Whether
+            it is lazy is determined from *lazy_output*.
 
         See Also
         --------
-        :func:`skimage.exposure.rescale_intensity`
+        :func:`skimage.exposure.rescale_intensity` :
+            This method is based on this function.
 
         Notes
         -----
@@ -158,8 +155,8 @@ def rescale_intensity(
 
         Image intensities are stretched to fill the available grey
         levels in the input images' data type range or any data type
-        range passed to ``dtype_out``, either keeping relative
-        intensities between images or not
+        range passed to *dtype_out*, either keeping relative intensities
+        between images or not
 
         >>> print(
         ...     s.data.dtype, s.data.min(), s.data.max(),
@@ -186,21 +183,21 @@ def rescale_intensity(
 
         Here, the darkest and brightest pixels within the 1% percentile
         are set to the ends of the data type range, e.g. 0 and 255
-        respectively for images of ``uint8`` data type.
+        respectively for images of "uint8" data type.
         """
         if lazy_output and inplace:
-            raise ValueError("`lazy_output=True` requires `inplace=False`")
+            raise ValueError("'lazy_output=True' requires 'inplace=False'")
 
         if self.data.dtype in rgb_dtypes.values():
             raise NotImplementedError(
-                "Use RGB channel normalization when creating the image instead."
+                "Use RGB channel normalization when creating the image instead"
             )
 
         # Determine min./max. intensity of input image to rescale to
         if in_range is not None and percentiles is not None:
-            raise ValueError("'percentiles' must be None if 'in_range' is not None.")
+            raise ValueError("'percentiles' must be None if 'in_range' is not None")
         elif relative is True and in_range is not None:
-            raise ValueError("'in_range' must be None if 'relative' is True.")
+            raise ValueError("'in_range' must be None if 'relative' is True")
         elif relative:  # Scale relative to min./max. intensity in images
             in_range = (self.data.min(), self.data.max())
             in_range = tuple(da.compute(in_range)[0])
@@ -213,15 +210,14 @@ def rescale_intensity(
         if out_range is None:
             out_range = dtype_range[dtype_out.type]
 
-        map_kw = dict(
-            show_progressbar=show_progressbar,
-            parallel=True,
-            output_dtype=dtype_out,
-            in_range=in_range,
-            out_range=out_range,
-            dtype_out=dtype_out,
-            percentiles=percentiles,
-        )
+        map_kw = {
+            "show_progressbar": show_progressbar,
+            "output_dtype": dtype_out,
+            "in_range": in_range,
+            "out_range": out_range,
+            "dtype_out": dtype_out,
+            "percentiles": percentiles,
+        }
         attrs = self._get_custom_attributes()
         if inplace:
             self.map(rescale_intensity, inplace=True, **map_kw)
@@ -249,10 +245,10 @@ def normalize_intensity(
         ----------
         num_std
             Number of standard deviations of the output intensities.
-            Default is ``1``.
+            Default is 1.
         divide_by_square_root
             Whether to divide output intensities by the square root of
-            the signal dimension size. Default is ``False``.
+            the signal dimension size. Default is False.
         dtype_out
             Data type of normalized images. If not given, the input
             images' data type is used.
@@ -262,24 +258,23 @@ def normalize_intensity(
             is used.
         inplace
             Whether to operate on the current signal or return a new
-            one. Default is ``True``.
+            one. Default is True.
         lazy_output
             Whether the returned signal is lazy. If not given this
-            follows from the current signal. Can only be ``True`` if
-            ``inplace=False``.
+            follows from the current signal. Can only be True if
+            *inplace* is False.
 
         Returns
         -------
         s_out
-            Normalized signal, returned if ``inplace=False``. Whether
-            it is lazy is determined from ``lazy_output``.
+            Normalized signal, returned if *inplace* is False. Whether
+            it is lazy is determined from *lazy_output*.
 
         Notes
         -----
         Data type should always be changed to floating point, e.g.
-        ``float32`` with
-        :meth:`~hyperspy.signal.BaseSignal.change_dtype`, before
-        normalizing the intensities.
+        "float32" with :meth:`~hyperspy.signal.BaseSignal.change_dtype`,
+        before normalizing the intensities.
 
         Rescaling RGB images is not possible. Use RGB channel
         normalization when creating the image instead.
@@ -296,11 +291,11 @@ def normalize_intensity(
         0.0
         """
         if lazy_output and inplace:
-            raise ValueError("`lazy_output=True` requires `inplace=False`")
+            raise ValueError("'lazy_output=True' requires 'inplace=False'")
 
         if self.data.dtype in rgb_dtypes.values():
             raise NotImplementedError(
-                "Use RGB channel normalization when creating the image instead."
+                "Use RGB channel normalization when creating the image instead"
             )
 
         if dtype_out is None:
@@ -308,14 +303,13 @@ def normalize_intensity(
         else:
             dtype_out = np.dtype(dtype_out)
 
-        map_kw = dict(
-            show_progressbar=show_progressbar,
-            parallel=True,
-            output_dtype=dtype_out,
-            num_std=num_std,
-            divide_by_square_root=divide_by_square_root,
-            dtype_out=dtype_out,
-        )
+        map_kw = {
+            "show_progressbar": show_progressbar,
+            "output_dtype": dtype_out,
+            "num_std": num_std,
+            "divide_by_square_root": divide_by_square_root,
+            "dtype_out": dtype_out,
+        }
         attrs = self._get_custom_attributes()
         if inplace:
             self.map(normalize_intensity, inplace=True, **map_kw)
@@ -349,27 +343,27 @@ def adaptive_histogram_equalization(
             image width.
         clip_limit
             Clipping limit, normalized between 0 and 1 (higher values
-            give more contrast). Default is ``0``.
+            give more contrast). Default is 0.
         nbins
             Number of gray bins for histogram ("data range"), default is
-            ``128``.
+            128.
         show_progressbar
             Whether to show a progressbar. If not given, the value of
             :obj:`hyperspy.api.preferences.General.show_progressbar`
             is used.
         inplace
             Whether to operate on the current signal or return a new
-            one. Default is ``True``.
+            one. Default is True.
         lazy_output
             Whether the returned signal is lazy. If not given this
-            follows from the current signal. Can only be ``True`` if
-            ``inplace=False``.
+            follows from the current signal. Can only be True if
+            *inplace* is False.
 
         Returns
         -------
         s_out
-            Equalized signal, returned if ``inplace=False``. Whether it
-            is lazy is determined from ``lazy_output``.
+            Equalized signal, returned if *inplace* is False. Whether it
+            is lazy is determined from *lazy_output*.
 
         See Also
         --------
@@ -413,7 +407,7 @@ def adaptive_histogram_equalization(
         >>> _ = ax3.plot(hist2)
         """
         if lazy_output and inplace:
-            raise ValueError("`lazy_output=True` requires `inplace=False`")
+            raise ValueError("'lazy_output=True' requires 'inplace=False'")
 
         dtype_out = self.data.dtype
         if np.issubdtype(dtype_out, np.floating):
@@ -444,14 +438,13 @@ def adaptive_histogram_equalization(
             raise ValueError(f"Incorrect value of `shape`: {kernel_size}")
         kernel_size = [int(k) for k in kernel_size]
 
-        map_kw = dict(
-            show_progressbar=show_progressbar,
-            parallel=True,
-            output_dtype=dtype_out,
-            kernel_size=kernel_size,
-            clip_limit=clip_limit,
-            nbins=nbins,
-        )
+        map_kw = {
+            "show_progressbar": show_progressbar,
+            "output_dtype": dtype_out,
+            "kernel_size": kernel_size,
+            "clip_limit": clip_limit,
+            "nbins": nbins,
+        }
         attrs = self._get_custom_attributes()
         if inplace:
             self.map(_adaptive_histogram_equalization, inplace=True, **map_kw)
@@ -478,7 +471,7 @@ def _get_custom_attributes(self, make_deepcopy: bool = False) -> dict:
         ----------
         make_deepcopy
             Whether the returned dictionary should contain deep copies
-            of each attribute. Default is ``False``.
+            of each attribute. Default is False.
 
         Returns
         -------
@@ -518,16 +511,16 @@ def _set_custom_attributes(
             Dictionary of custom attributes.
         make_deepcopy
             Whether to make a deepcopy of all attributes before setting
-            them in this instance. Default is ``False``.
+            them in this instance. Default is False.
         make_lazy
             Whether to cast attributes which are
             :class:`~numpy.ndarray` to :class:`~dask.array.Array` before
-            setting them. Default is ``False``.
+            setting them. Default is False.
         unmake_lazy
             Whether to cast attributes which are
             :class:`~dask.array.Array` to :class:`~numpy.ndarray` before
-            setting them. Default is ``False``. Ignored if both this and
-            ``make_lazy`` are ``True``.
+            setting them. Default is False. Ignored if both this and
+            *make_lazy* are True.
         """
         for name, value in attributes.items():
             if name in self._custom_attributes:
diff --git a/src/kikuchipy/signals/ebsd.py b/src/kikuchipy/signals/ebsd.py
index b59387dc..395f6e49 100644
--- a/src/kikuchipy/signals/ebsd.py
+++ b/src/kikuchipy/signals/ebsd.py
@@ -22,6 +22,7 @@
 import gc
 import logging
 import os
+from pathlib import Path
 from typing import TYPE_CHECKING, Iterable
 import warnings
 
@@ -505,7 +506,7 @@ def remove_static_background(
         array.
         """
         if lazy_output and inplace:
-            raise ValueError("`lazy_output=True` requires `inplace=False`")
+            raise ValueError("'lazy_output=True' requires 'inplace=False'")
 
         dtype = np.float32  # During processing
         dtype_out = self.data.dtype.type
@@ -541,16 +542,15 @@ def remove_static_background(
         else:
             operation_func = _remove_static_background_divide
 
-        map_kw = dict(
-            show_progressbar=show_progressbar,
-            parallel=True,
-            output_dtype=dtype_out,
-            static_bg=static_bg,
-            dtype_out=dtype_out,
-            omin=omin,
-            omax=omax,
-            scale_bg=scale_bg,
-        )
+        map_kw = {
+            "show_progressbar": show_progressbar,
+            "output_dtype": dtype_out,
+            "static_bg": static_bg,
+            "dtype_out": dtype_out,
+            "omin": omin,
+            "omax": omax,
+            "scale_bg": scale_bg,
+        }
         attrs = self._get_custom_attributes()
         if inplace:
             self.map(operation_func, inplace=True, **map_kw)
@@ -633,7 +633,7 @@ def remove_dynamic_background(
         >>> s.remove_dynamic_background(operation="divide", std=5)
         """
         if lazy_output and inplace:
-            raise ValueError("`lazy_output=True` requires `inplace=False`")
+            raise ValueError("'lazy_output=True' requires 'inplace=False'")
 
         if std is None:
             std = self.axes_manager.signal_shape[0] / 8
@@ -666,17 +666,16 @@ def remove_dynamic_background(
         dtype_out = self.data.dtype.type
         omin, omax = dtype_range[dtype_out]
 
-        map_kw = dict(
-            show_progressbar=show_progressbar,
-            parallel=True,
-            output_dtype=dtype_out,
-            filter_func=filter_func,
-            operation=operation,
-            dtype_out=dtype_out,
-            omin=omin,
-            omax=omax,
+        map_kw = {
+            "show_progressbar": show_progressbar,
+            "output_dtype": dtype_out,
+            "filter_func": filter_func,
+            "operation": operation,
+            "dtype_out": dtype_out,
+            "omin": omin,
+            "omax": omax,
             **kwargs,
-        )
+        }
         attrs = self._get_custom_attributes()
         if inplace:
             self.map(map_func, inplace=True, **map_kw)
@@ -869,7 +868,7 @@ def fft_filter(
         ... )
         """
         if lazy_output and inplace:
-            raise ValueError("`lazy_output=True` requires `inplace=False`")
+            raise ValueError("'lazy_output=True' requires 'inplace=False'")
 
         dtype_out = self.data.dtype.type
         dtype = np.float32
@@ -996,7 +995,7 @@ def average_neighbour_patterns(
         scipy.ndimage.correlate
         """
         if lazy_output and inplace:
-            raise ValueError("`lazy_output=True` requires `inplace=False`")
+            raise ValueError("'lazy_output=True' requires 'inplace=False'")
 
         if isinstance(window, Window) and window.is_valid:
             averaging_window = copy.copy(window)
@@ -1153,7 +1152,7 @@ def downsample(
         rescaling is undesirable, use :meth:`rebin` instead.
         """
         if lazy_output and inplace:
-            raise ValueError("`lazy_output=True` requires `inplace=False`")
+            raise ValueError("'lazy_output=True' requires 'inplace=False'")
 
         if not isinstance(factor, int) or factor <= 1:
             raise ValueError(f"Binning `factor` {factor} must be an integer > 1")
@@ -1191,7 +1190,6 @@ def downsample(
 
         map_kw = {
             "show_progressbar": show_progressbar,
-            "parallel": True,
             "output_dtype": dtype_out,
             "factor": factor,
             "omin": omin,
@@ -1355,7 +1353,6 @@ def get_image_quality(
         image_quality_map = self.map(
             _get_image_quality,
             show_progressbar=show_progressbar,
-            parallel=True,
             inplace=False,
             output_dtype=np.float32,
             normalize=normalize,
@@ -2592,63 +2589,72 @@ def refine_orientation_projection_center(
 
     def save(
         self,
-        filename: str | None = None,
+        filename: Path | str | None = None,
         overwrite: bool | None = None,
         extension: str | None = None,
         **kwargs,
     ) -> None:
         """Write the signal to file in the specified format.
 
-        The function gets the format from the extension: ``h5``,
-        ``hdf5`` or ``h5ebsd`` for kikuchipy's specification of the
-        h5ebsd format, ``dat`` for the NORDIF binary format or ``hspy``
-        for HyperSpy's HDF5 specification. If no extension is provided
-        the signal is written to a file in kikuchipy's h5ebsd format.
-        Each format accepts a different set of parameters.
-
-        This method is a modified version of HyperSpy's function
-        :meth:`hyperspy.signal.BaseSignal.save`.
+        If no extension is given, the signal is written to a file in
+        kikuchipy's h5ebsd format.
 
         Parameters
         ----------
         filename
-            If not given and ``tmp_parameters.filename`` and
-            ``tmp_parameters.folder`` in signal metadata are defined,
-            the filename and path will be taken from there. A valid
-            extension can be provided e.g. ``"data.h5"``, see
-            ``extension``.
+            If not given and 'tmp_parameters.filename' and
+            'tmp_parameters.folder' are defined in signal metadata, the
+            filename and path will be taken from there.
         overwrite
             If not given and the file exists, it will query the user. If
-            ``True`` (``False``) it (does not) overwrite the file if it
-            exists.
+            True (False) it (does not) overwrite the file if it exists.
         extension
             Extension of the file that defines the file format. Options
-            are ``"h5"``, ``"hdf5"``, ``"h5ebsd"``, ``"dat"``,
-            ``"hspy"``. ``"h5"``, ``"hdf5"``, and ``"h5ebsd"`` are
-            equivalent. If not given, the extension is determined from
-            the following list in this order: i) the filename, ii)
-            ``tmp_parameters.extension`` or iii) ``"h5"`` (kikuchipy's
-            h5ebsd format).
+            are:
+
+            * h5, hdf5, or h5ebsd: kikuchipy's specification of the
+              h5ebsd format
+            * dat: NORDIF's binary format
+            * hspy: HyperSpy's HDF5 format
+            * zspy: HyperSpy's zarr format
+
+            Each format accepts different parameters.
+
+            If not given, the extension is determined from the following
+            list in this order: i) the filename, ii)
+            'tmp_parameters.extension' or iii) 'h5' (kikuchipy's h5ebsd
+            format).
         **kwargs
-            Keyword arguments passed to the writer.
+            Keyword arguments passed to the corresponding writer.
 
         See Also
         --------
         kikuchipy.io.plugins
+
+        Notes
+        -----
+        This method is a modified version of HyperSpy's function
+        :meth:`hyperspy.signal.BaseSignal.save`.
         """
-        if filename is None:
+        if filename is not None:
+            fname = Path(filename)
+        else:
             tmp_params = self.tmp_parameters
             if tmp_params.has_item("filename") and tmp_params.has_item("folder"):
-                filename = os.path.join(tmp_params.folder, tmp_params.filename)
+                fname = Path(tmp_params.folder) / tmp_params.filename
                 extension = tmp_params.extension if not extension else extension
             elif self.metadata.has_item("General.original_filename"):
-                filename = self.metadata.General.original_filename
+                fname = Path(self.metadata.General.original_filename)
             else:
-                raise ValueError("Filename not defined")
+                raise ValueError("filename not given")
         if extension is not None:
-            basename, _ = os.path.splitext(filename)
-            filename = basename + "." + extension
-        _save(filename, self, overwrite=overwrite, **kwargs)
+            ext = extension
+        else:
+            ext = fname.suffix[1:]
+        if ext.lower() in ["hspy", "zspy"]:
+            super().save(filename, overwrite=overwrite, **kwargs)
+            return
+        _save(fname, self, overwrite=overwrite, **kwargs)
 
     def get_decomposition_model(
         self,
@@ -2712,7 +2718,7 @@ def get_decomposition_model(
 
     @insert_doc_disclaimer(cls=Signal2D, meth=Signal2D.crop)
     def crop(self, *args, **kwargs):
-        # This method is called by crop_image(), so attributes are
+        # This method is called by crop_signal(), so attributes are
         # handled correctly by that method as well
 
         old_shape = self.data.shape
diff --git a/src/kikuchipy/signals/util/_crystal_map.py b/src/kikuchipy/signals/util/_crystal_map.py
index 92cd307d..b15845eb 100644
--- a/src/kikuchipy/signals/util/_crystal_map.py
+++ b/src/kikuchipy/signals/util/_crystal_map.py
@@ -40,21 +40,22 @@ def _xmap_is_compatible_with_signal(
         warnings.warn(
             "Signal navigation axes must be named 'x' and/or 'y' in order to compare "
             "the signal navigation scales to the crystal map step sizes 'dx' and 'dy' "
-            "(see `EBSD.axes_manager`)"
+            "(see EBSD.axes_manager)"
         )
         xmap_scale = list(xmap._step_sizes.values())[-len(navigation_axes) :]
 
     compatible = xmap.shape == nav_shape
     if compatible and not np.allclose(xmap_scale, nav_scale, atol=1e-6):
+        xmap_scale2 = list(map(float, xmap_scale))
         warnings.warn(
-            f"Crystal map step size(s) {xmap_scale} and signal's step size(s) "
-            f"{nav_scale} must be the same (see `EBSD.axes_manager`)"
+            f"Crystal map step size(s) {xmap_scale2} and signal's step size(s) "
+            f"{nav_scale} must be the same (see EBSD.axes_manager)"
         )
 
     if not compatible and raise_if_not:
         raise ValueError(
             f"Crystal map shape {xmap.shape} and signal's navigation shape {nav_shape} "
-            "must be the same (see `EBSD.axes_manager`)"
+            "must be the same (see EBSD.axes_manager)"
         )
     else:
         return compatible
diff --git a/src/kikuchipy/simulations/_kikuchi_pattern_simulation.py b/src/kikuchipy/simulations/_kikuchi_pattern_simulation.py
index b40e2b56..9bd27c0a 100644
--- a/src/kikuchipy/simulations/_kikuchi_pattern_simulation.py
+++ b/src/kikuchipy/simulations/_kikuchi_pattern_simulation.py
@@ -19,8 +19,7 @@
 import re
 
 from diffsims.crystallography import ReciprocalLatticeVector
-from hyperspy.drawing.marker import MarkerBase
-from hyperspy.utils.markers import line_segment, point, text
+import hyperspy.api as hs
 import matplotlib.collections as mcollections
 import matplotlib.figure as mfigure
 import matplotlib.path as mpath
@@ -199,7 +198,7 @@ def as_markers(
         zone_axes_kwargs: dict | None = None,
         zone_axes_labels_kwargs: dict | None = None,
         pc_kwargs: dict | None = None,
-    ) -> list[MarkerBase]:
+    ) -> list:
         """Return a list of simulation markers.
 
         Parameters
@@ -469,7 +468,7 @@ def _lines_as_collection(
         kw.update(kwargs)
         return mcollections.LineCollection(segments=list(coords), **kw)
 
-    def _lines_as_markers(self, **kwargs) -> list[line_segment]:
+    def _lines_as_markers(self, **kwargs) -> list[hs.plot.markers.Lines]:
         """Get Kikuchi lines as a list of HyperSpy markers.
 
         Parameters
@@ -496,7 +495,7 @@ def _lines_as_markers(self, **kwargs) -> list[line_segment]:
                 y1 = line[..., 1].squeeze()
                 x2 = line[..., 2].squeeze()
                 y2 = line[..., 3].squeeze()
-                marker = line_segment(x1=x1, y1=y1, x2=x2, y2=y2, **kw)
+                marker = hs.plot.markers.Lines([[[x1, y1], [x2, y2]]], **kw)
                 lines_list.append(marker)
 
         return lines_list


Test patch:
diff --git a/conftest.py b/conftest.py
index 4bb2f9ba..38b5fc36 100644
--- a/conftest.py
+++ b/conftest.py
@@ -298,6 +298,11 @@ def save_path_hdf5(request, tmpdir) -> Generator[Path, None, None]:
     yield Path(tmpdir / f"patterns.{ext}")
 
 
+@pytest.fixture()
+def save_path_nordif(tmpdir) -> Generator[Path, None, None]:
+    yield Path(tmpdir / "nordif/save_temp.dat")
+
+
 @pytest.fixture
 def ni_small_axes_manager() -> Generator[dict, None, None]:
     """Axes manager for :func:`kikuchipy.data.nickel_ebsd_small`."""
diff --git a/tests/test_data/test_data.py b/tests/test_data/test_data.py
index 768ef7ca..d7cb0482 100644
--- a/tests/test_data/test_data.py
+++ b/tests/test_data/test_data.py
@@ -15,7 +15,6 @@
 # You should have received a copy of the GNU General Public License
 # along with kikuchipy. If not, see <http://www.gnu.org/licenses/>.
 
-import os
 from pathlib import Path
 
 from dask.array import Array
diff --git a/tests/test_draw/test_markers.py b/tests/test_draw/test_markers.py
deleted file mode 100644
index 2bef4f65..00000000
--- a/tests/test_draw/test_markers.py
+++ /dev/null
@@ -1,286 +0,0 @@
-# Copyright 2019-2024 The kikuchipy developers
-#
-# This file is part of kikuchipy.
-#
-# kikuchipy is free software: you can redistribute it and/or modify
-# it under the terms of the GNU General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-#
-# kikuchipy is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-# GNU General Public License for more details.
-#
-# You should have received a copy of the GNU General Public License
-# along with kikuchipy. If not, see <http://www.gnu.org/licenses/>.
-
-from hyperspy.utils.markers import line_segment, point, text
-import numpy as np
-import pytest
-
-from kikuchipy.draw.markers import get_line_segment_list, get_point_list, get_text_list
-
-
-class TestMarkers:
-    @pytest.mark.parametrize("n_lines", [1, 2, 3])
-    def test_get_line_segment_list0d(self, n_lines):
-        """Lines in 0D () navigation space."""
-        nav_shape = ()
-        size = int(np.prod(nav_shape) * n_lines * 4)
-        lines = np.random.random(size=size).reshape(nav_shape + (n_lines, 4))
-        kwargs = dict(linewidth=1, color="red", alpha=1, zorder=1)
-        line_markers = get_line_segment_list(list(lines), **kwargs)
-
-        # Number of markers
-        assert isinstance(line_markers, list)
-        assert len(line_markers) == n_lines
-        assert isinstance(line_markers[0], line_segment)
-
-        # Coordinates, data shape and marker properties
-        for line, marker in zip(lines, line_markers):
-            assert np.allclose(
-                [
-                    marker.get_data_position("x1"),
-                    marker.get_data_position("y1"),
-                    marker.get_data_position("x2"),
-                    marker.get_data_position("y2"),
-                ],
-                line,
-            )
-            assert marker._get_data_shape() == nav_shape
-            for k, v in kwargs.items():
-                assert marker.marker_properties[k] == v
-
-    def test_get_line_segment_0d_2(self):
-        """A line in 0d but no (1,) navigation shape."""
-        lines = np.random.random(size=4).reshape((4,))
-        line_markers = get_line_segment_list(lines)
-
-        assert len(line_markers) == 1
-        assert np.allclose(
-            [
-                line_markers[0].get_data_position("x1"),
-                line_markers[0].get_data_position("y1"),
-                line_markers[0].get_data_position("x2"),
-                line_markers[0].get_data_position("y2"),
-            ],
-            lines,
-        )
-
-    def test_get_line_segment_list1d(self):
-        """Lines in 1D (2,) navigation space."""
-        nav_shape = (2,)
-        n_lines = 2
-        size = int(np.prod(nav_shape) * n_lines * 4)
-        lines = np.random.random(size=size).reshape(nav_shape + (n_lines, 4))
-        line_markers = get_line_segment_list(lines)
-
-        assert len(line_markers) == n_lines
-
-        # Iterate over lines
-        for i in range(n_lines):
-            assert line_markers[i]._get_data_shape() == nav_shape  # 1d
-            assert np.allclose(
-                np.dstack(line_markers[i].data.tolist()[:4]),
-                lines[:, i],
-            )
-
-    def test_get_line_segment_list2d(self):
-        """Lines in 2D (2, 3) navigation space."""
-        nav_shape = (2, 3)
-        n_lines = 3
-        size = int(np.prod(nav_shape) * n_lines * 4)
-        lines = np.random.random(size=size).reshape(nav_shape + (n_lines, 4))
-        line_markers = get_line_segment_list(lines)
-
-        assert len(line_markers) == n_lines
-
-        # Iterate over lines
-        for i in range(n_lines):
-            assert line_markers[i]._get_data_shape() == nav_shape
-            assert np.allclose(
-                np.dstack(line_markers[i].data.tolist()[:4]), lines[:, :, i]
-            )
-
-    def test_get_line_segment_list_nans(self):
-        lines = np.ones((2, 3, 4)) * np.nan
-        assert len(get_line_segment_list(lines)) == 0
-
-    @pytest.mark.parametrize("n_points", [1, 2, 3])
-    def test_get_point_list0d(self, n_points):
-        """Points in 0D () navigation space."""
-        nav_shape = ()
-        size = int(np.prod(nav_shape) * n_points * 2)
-        points = np.random.random(size=size).reshape(nav_shape + (n_points, 2))
-        kwargs = dict(s=40, marker="o", facecolor="w", edgecolor="k", zorder=5, alpha=1)
-        point_markers = get_point_list(list(points), **kwargs)
-
-        # Number of markers
-        assert isinstance(point_markers, list)
-        assert len(point_markers) == n_points
-        assert isinstance(point_markers[0], point)
-
-        # Coordinates, data shape and marker properties
-        for i, marker in zip(points, point_markers):
-            assert np.allclose(
-                [
-                    marker.get_data_position("x1"),
-                    marker.get_data_position("y1"),
-                ],
-                i,
-            )
-            assert marker._get_data_shape() == nav_shape
-            for k, v in kwargs.items():
-                assert marker.marker_properties[k] == v
-
-    def test_get_point_list0d_2(self):
-        """One point in 0d but no (1,) navigation shape."""
-        points = np.random.random(size=2).reshape((2,))
-        point_marker = get_point_list(points)
-
-        assert len(point_marker) == 1
-        assert np.allclose(
-            [
-                point_marker[0].get_data_position("x1"),
-                point_marker[0].get_data_position("y1"),
-            ],
-            points,
-        )
-
-    def test_get_point_list1d(self):
-        """Points in 1D (2,) navigation space."""
-        nav_shape = (2,)
-        n_points = 2
-        size = int(np.prod(nav_shape) * n_points * 2)
-        points = np.random.random(size=size).reshape(nav_shape + (n_points, 2))
-        point_markers = get_point_list(points)
-
-        assert len(point_markers) == n_points
-
-        # Iterate over points
-        for i in range(n_points):
-            assert point_markers[i]._get_data_shape() == nav_shape  # 1d
-            assert np.allclose(
-                np.dstack(point_markers[i].data.tolist()[:2]), points[:, i]
-            )
-
-    def test_get_point_list2d(self):
-        """Points in 2D (2, 3) navigation space."""
-        nav_shape = (2, 3)
-        n_points = 3
-        size = int(np.prod(nav_shape) * n_points * 2)
-        points = np.random.random(size=size).reshape(nav_shape + (n_points, 2))
-        point_markers = get_point_list(points)
-
-        assert len(point_markers) == n_points
-
-        # Iterate over points
-        for i in range(n_points):
-            assert point_markers[i]._get_data_shape() == nav_shape
-            assert np.allclose(
-                np.dstack(point_markers[i].data.tolist()[:2]), points[:, :, i]
-            )
-
-    def test_get_point_list_nans(self):
-        points = np.ones((2, 3, 2)) * np.nan
-        assert len(get_point_list(points)) == 0
-
-    @pytest.mark.parametrize("n_labels", [1, 2, 3])
-    def test_get_text_list0d(self, n_labels):
-        """Text labels in 0D () navigation space."""
-        nav_shape = ()
-        size = int(np.prod(nav_shape) * n_labels * 2)
-        texts = ["111", "220", "-220"][:n_labels]
-        text_coords = np.random.random(size=size).reshape(nav_shape + (n_labels, 2))
-        kwargs = dict(
-            color="k",
-            zorder=5,
-            ha="center",
-            bbox=dict(
-                facecolor="w",
-                edgecolor="k",
-                boxstyle="round, rounding_size=0.2",
-                pad=0.1,
-                alpha=0.1,
-            ),
-        )
-        text_markers = get_text_list(
-            texts=texts, coordinates=list(text_coords), **kwargs
-        )
-
-        # Number of markers
-        assert isinstance(text_markers, list)
-        assert len(text_markers) == n_labels
-        assert isinstance(text_markers[0], text)
-
-        # Coordinates, data shape and marker properties
-        for i, (t, marker) in enumerate(zip(text_coords, text_markers)):
-            assert np.allclose(
-                [marker.get_data_position("x1"), marker.get_data_position("y1")], t
-            )
-            assert marker.get_data_position("text") == texts[i]
-            assert marker._get_data_shape() == nav_shape
-            for k, v in kwargs.items():
-                assert marker.marker_properties[k] == v
-
-    def test_get_text_list0d_2(self):
-        """One text label in 0d but no (1,) navigation shape."""
-        text_coords = np.random.random(size=2).reshape((2,))
-        texts = ["123"]
-        text_markers = get_text_list(texts=texts, coordinates=text_coords)
-
-        assert len(text_markers) == 1
-        assert np.allclose(
-            [
-                text_markers[0].get_data_position("x1"),
-                text_markers[0].get_data_position("y1"),
-            ],
-            text_coords,
-        )
-        assert text_markers[0].get_data_position("text") == texts[0]
-
-    def test_get_text_list1d(self):
-        """Text labels in 1D (2,) navigation space."""
-        nav_shape = (2,)
-        n_labels = 2
-        size = int(np.prod(nav_shape) * n_labels * 2)
-        texts = ["111", "-220"]
-        text_coords = np.random.random(size=size).reshape(nav_shape + (n_labels, 2))
-        text_markers = get_text_list(texts=texts, coordinates=text_coords)
-
-        assert len(text_markers) == n_labels
-
-        # Iterate over text labels
-        for i in range(n_labels):
-            assert text_markers[i]._get_data_shape() == nav_shape
-            assert np.allclose(
-                np.dstack(text_markers[i].data.tolist()[:2]), text_coords[:, i]
-            )
-            assert text_markers[i].data["text"] == texts[i]
-
-    def test_get_text_list2d(self):
-        """Text labels in 2D (2, 3) navigation space."""
-        nav_shape = (2, 3)
-        n_labels = 3
-        size = int(np.prod(nav_shape) * n_labels * 2)
-        texts = ["111", "-2-20", "311"]
-        text_coords = np.random.random(size=size).reshape(nav_shape + (n_labels, 2))
-        text_markers = get_text_list(texts=texts, coordinates=text_coords)
-
-        assert len(text_markers) == n_labels
-
-        # Iterate over text labels
-        for i in range(n_labels):
-            assert text_markers[i]._get_data_shape() == nav_shape
-            assert np.allclose(
-                np.dstack(text_markers[i].data.tolist()[:2]), text_coords[:, :, i]
-            )
-            assert text_markers[i].data["text"] == texts[i]
-
-    def test_get_text_list_nans(self):
-        text_coords = np.ones((2, 3, 2)) * np.nan
-        assert (
-            len(get_text_list(texts=["111", "200", "220"], coordinates=text_coords))
-            == 0
-        )
diff --git a/tests/test_draw/test_navigators.py b/tests/test_draw/test_navigators.py
index 3503535d..4547fd03 100644
--- a/tests/test_draw/test_navigators.py
+++ b/tests/test_draw/test_navigators.py
@@ -15,9 +15,9 @@
 # You should have received a copy of the GNU General Public License
 # along with kikuchipy. If not, see <http://www.gnu.org/licenses/>.
 
-from hyperspy.misc.rgb_tools import rgb8, rgb16
 import matplotlib.pyplot as plt
 import numpy as np
+from rsciio.utils.rgb_tools import rgb8, rgb16
 
 import kikuchipy as kp
 
diff --git a/tests/test_imaging/test_virtual_bse_imager.py b/tests/test_imaging/test_virtual_bse_imager.py
index 38226779..10d22ff9 100644
--- a/tests/test_imaging/test_virtual_bse_imager.py
+++ b/tests/test_imaging/test_virtual_bse_imager.py
@@ -28,7 +28,7 @@ def test_init(self, dummy_signal):
         vbse_imager = kp.imaging.VirtualBSEImager(dummy_signal)
 
         assert isinstance(vbse_imager.signal, kp.signals.EBSD)
-        assert vbse_imager.grid_shape == (5, 5)
+        assert vbse_imager.grid_shape == (3, 3)
 
     def test_init_lazy(self, dummy_signal):
         lazy_signal = dummy_signal.as_lazy()
@@ -137,7 +137,7 @@ def test_axes_manager_transfer(self, kikuchipy_h5ebsd_path):
 
     def test_get_images_lazy(self, dummy_signal):
         vbse_imager = kp.imaging.VirtualBSEImager(dummy_signal.as_lazy())
-        vbse_img = vbse_imager.get_images_from_grid()
+        _ = vbse_imager.get_images_from_grid()
 
 
 class TestGetRGBImage:
diff --git a/tests/test_io/test_edax_h5ebsd.py b/tests/test_io/test_edax_h5ebsd.py
index 5fb8b671..9d353cdc 100644
--- a/tests/test_io/test_edax_h5ebsd.py
+++ b/tests/test_io/test_edax_h5ebsd.py
@@ -17,7 +17,7 @@
 
 import shutil
 
-from h5py import File
+import h5py
 import pytest
 
 import kikuchipy as kp
@@ -36,13 +36,13 @@ def test_load(
         # No 'SEM-PRIAS' group available
         tmp_file = tmp_path / file.name
         shutil.copy(file, tmp_file)
-        with File(tmp_file, mode="r+") as f:
+        with h5py.File(tmp_file, mode="r+") as f:
             f["Scan 1"].move("SEM-PRIAS Images", "SSEM-PRIAS Images")
         s2 = kp.load(tmp_file)
         assert s2.metadata.Acquisition_instrument.SEM.magnification != mag
 
         # Not a square grid
-        with File(tmp_file, mode="r+") as f:
+        with h5py.File(tmp_file, mode="r+") as f:
             grid = f["Scan 1/EBSD/Header/Grid Type"]
             grid[()] = "HexGrid".encode()
         with pytest.raises(IOError, match="Only square grids are"):
@@ -51,5 +51,5 @@ def test_load(
     def test_save_error(self, edax_h5ebsd_path):
         file = edax_h5ebsd_path / "patterns.h5"
         s = kp.load(file)
-        with pytest.raises(OSError, match="(.*) is not a supported kikuchipy"):
+        with pytest.raises(OSError, match="(.*) is not a supported kikuchipy h5ebsd "):
             s.save(file, add_scan=True)
diff --git a/tests/test_io/test_emsoft_ebsd.py b/tests/test_io/test_emsoft_ebsd.py
index 13390a8e..41a7efc2 100644
--- a/tests/test_io/test_emsoft_ebsd.py
+++ b/tests/test_io/test_emsoft_ebsd.py
@@ -16,14 +16,14 @@
 # along with kikuchipy. If not, see <http://www.gnu.org/licenses/>.
 
 import dask.array as da
-from h5py import File
+import h5py
 import numpy as np
 from orix.crystal_map import CrystalMap
 import pytest
 
 from kikuchipy.io._io import load
 from kikuchipy.io.plugins._h5ebsd import _hdf5group2dict
-from kikuchipy.io.plugins.emsoft_ebsd import _check_file_format, _crystaldata2phase
+from kikuchipy.io.plugins.emsoft_ebsd._api import _check_file_format, _crystaldata2phase
 
 
 class TestEMsoftEBSDReader:
@@ -57,7 +57,7 @@ def test_read_lazy(self, emsoft_ebsd_path):
 
     def test_check_file_format(self, save_path_hdf5):
         """Wrong file format raises an error."""
-        with File(save_path_hdf5, mode="w") as f:
+        with h5py.File(save_path_hdf5, mode="w") as f:
             g1 = f.create_group("EMheader")
             g2 = g1.create_group("EBSD")
             g2.create_dataset(
@@ -68,7 +68,7 @@ def test_check_file_format(self, save_path_hdf5):
 
     def test_crystaldata2phase(self, emsoft_ebsd_path):
         """A Phase object is correctly returned."""
-        with File(emsoft_ebsd_path / "simulated_ebsd.h5") as f:
+        with h5py.File(emsoft_ebsd_path / "simulated_ebsd.h5") as f:
             xtal_dict = _hdf5group2dict(f["CrystalData"])
         phase = _crystaldata2phase(xtal_dict)
 
@@ -85,15 +85,13 @@ def test_crystaldata2phase(self, emsoft_ebsd_path):
         )
         assert np.allclose(structure.occupancy, [1, 1])
         assert np.allclose(structure.Bisoequiv, [0.5] * 2)
-        assert np.compare_chararrays(
-            structure.element, np.array(["13", "29"], dtype="|S2"), "==", rstrip=False
-        ).all()
+        assert structure.element.tolist() == [b"13", b"29"]
 
     def test_crystaldata2phase_single_atom(self, emsoft_ebsd_path):
         """A Phase object is correctly returned when there is only one
         atom present.
         """
-        with File(emsoft_ebsd_path / "simulated_ebsd.h5") as f:
+        with h5py.File(emsoft_ebsd_path / "simulated_ebsd.h5") as f:
             xtal_dict = _hdf5group2dict(f["CrystalData"])
         xtal_dict["Natomtypes"] = 1
         xtal_dict["AtomData"] = xtal_dict["AtomData"][:, 0][..., np.newaxis]
diff --git a/tests/test_io/test_emsoft_master_pattern.py b/tests/test_io/test_emsoft_master_pattern.py
index b6be84a1..d70c2e9b 100644
--- a/tests/test_io/test_emsoft_master_pattern.py
+++ b/tests/test_io/test_emsoft_master_pattern.py
@@ -121,8 +121,8 @@ def test_get_datasets(
     @pytest.mark.parametrize(
         "projection, hemisphere, error_msg",
         [
-            ("stereographicl", "upper", "'projection' value stereographicl "),
-            ("lambert", "east", "'hemisphere' value east "),
+            ("stereographicl", "upper", "'projection' value 'stereographicl' "),
+            ("lambert", "east", "'hemisphere' value 'east' "),
         ],
     )
     def test_get_datasets_raises(
diff --git a/tests/test_io/test_io.py b/tests/test_io/test_io.py
index d9da91e0..96bdb0d4 100644
--- a/tests/test_io/test_io.py
+++ b/tests/test_io/test_io.py
@@ -17,32 +17,33 @@
 
 import os
 
+import hyperspy.api as hs
 import numpy as np
 import pytest
 
 import kikuchipy as kp
 from kikuchipy.io._io import _assign_signal_subclass, _dict2signal
+from kikuchipy.io.plugins.kikuchipy_h5ebsd._api import (
+    file_reader as kp_h5ebsd_file_reader,
+)
 
 
 class TestIO:
-    @pytest.mark.parametrize("filename", ("im_not_here.h5", "unsupported.h4"))
-    def test_load(self, kikuchipy_h5ebsd_path, tmpdir, filename):
-        if filename == "im_not_here.h5":
-            with pytest.raises(IOError, match="No filename matches"):
-                _ = kp.load(filename)
-        else:
-            s = kp.load(kikuchipy_h5ebsd_path / "patterns.h5")
-            file_path = tmpdir / "supported.h5"
-            s.save(file_path)
-            new_file_path = tmpdir / filename
-            file_path.rename(new_file_path)
-            with pytest.raises(IOError, match="Could not read"):
-                _ = kp.load(new_file_path)
+    def test_load_unsupported_extension(self, kikuchipy_h5ebsd_path, tmpdir):
+        s = kp.load(kikuchipy_h5ebsd_path / "patterns.h5")
+        file_path = tmpdir / "supported.h5"
+        s.save(file_path)
+        new_file_path = tmpdir / "unsupported.h4"
+        file_path.rename(new_file_path)
+        with pytest.raises(IOError, match="Could not read"):
+            kp.load(new_file_path)
+
+    def test_load_missing_file(self, kikuchipy_h5ebsd_path, tmpdir):
+        with pytest.raises(IOError, match="No filename matches"):
+            kp.load("im_not_here.h5")
 
     def test_dict2signal(self, kikuchipy_h5ebsd_path):
-        scan_dict = kp.io.plugins.kikuchipy_h5ebsd.file_reader(
-            kikuchipy_h5ebsd_path / "patterns.h5"
-        )[0]
+        scan_dict, *_ = kp_h5ebsd_file_reader(kikuchipy_h5ebsd_path / "patterns.h5")
         scan_dict["metadata"]["Signal"]["record_by"] = "not-image"
         with pytest.raises(ValueError, match="kikuchipy only supports"):
             _ = _dict2signal(scan_dict)
@@ -105,12 +106,10 @@ def test_save_extensions(self, kikuchipy_h5ebsd_path, extension, tmpdir):
             with pytest.raises(ValueError, match="'h4' does not"):
                 s.save(file_path)
 
-    @pytest.mark.filterwarnings("ignore:Using `set_signal_dimension`")
-    def test_save_data_dimensions(self, kikuchipy_h5ebsd_path):
-        s = kp.load(kikuchipy_h5ebsd_path / "patterns.h5")
-        s.axes_manager.set_signal_dimension(3)
-        with pytest.raises(ValueError, match="This file format cannot write"):
-            s.save()
+    def test_save_data_dimensions(self, tmpdir):
+        s = kp.signals.EBSD(np.zeros((2, 3, 4, 5, 6)))
+        with pytest.raises(ValueError, match="Chosen IO plugin 'kikuchipy_h5ebsd' "):
+            s.save(tmpdir / "test.h5")
 
     def test_save_to_existing_file(self, save_path_hdf5, kikuchipy_h5ebsd_path):
         s = kp.load(kikuchipy_h5ebsd_path / "patterns.h5")
@@ -122,3 +121,12 @@ def test_save_to_existing_file(self, save_path_hdf5, kikuchipy_h5ebsd_path):
         s.save(save_path_hdf5, scan_number=2, overwrite=False, add_scan=False)
         with pytest.raises(OSError, match="Scan 'Scan 2' is not among the"):
             _ = kp.load(save_path_hdf5, scan_group_names="Scan 2")
+
+
+class TestHSPYWrite:
+    def test_hspy_write(self, tmpdir, dummy_signal):
+        file_path = str(tmpdir / "test.hspy")
+        s = dummy_signal.as_lazy()
+        s.save(file_path)
+        s2 = hs.load(file_path, signal_type="EBSD")
+        assert np.allclose(dummy_signal.data, s2.data)
diff --git a/tests/test_io/test_kikuchipy_h5ebsd.py b/tests/test_io/test_kikuchipy_h5ebsd.py
index fd5f66bb..ad898a41 100644
--- a/tests/test_io/test_kikuchipy_h5ebsd.py
+++ b/tests/test_io/test_kikuchipy_h5ebsd.py
@@ -18,15 +18,15 @@
 import os
 
 import dask.array as da
-from h5py import Dataset, File
-from hyperspy.api import load as hs_load
+import h5py
+import hyperspy.api as hs
 import numpy as np
 from orix.quaternion import Rotation
 import pytest
 
 import kikuchipy as kp
 from kikuchipy.io.plugins._h5ebsd import _dict2hdf5group
-from kikuchipy.io.plugins.kikuchipy_h5ebsd import (
+from kikuchipy.io.plugins.kikuchipy_h5ebsd._api import (
     KikuchipyH5EBSDReader,
     KikuchipyH5EBSDWriter,
 )
@@ -42,21 +42,21 @@ def test_repr(self, kikuchipy_h5ebsd_path):
         assert repr_str_list[2][-11:] == "patterns.h5"
 
     def test_check_file_invalid_version(self, save_path_hdf5):
-        f = File(save_path_hdf5, mode="w")
+        f = h5py.File(save_path_hdf5, mode="w")
         _dict2hdf5group({"manufacturer": "kikuchipy", "versionn": "0.1"}, f["/"])
         f.close()
-        with pytest.raises(IOError, match="(.*) as manufacturer"):
+        with pytest.raises(IOError, match="Could not find 'version' key in '(.*)'"):
             _ = KikuchipyH5EBSDReader(save_path_hdf5)
 
     def test_check_file_no_scan_groups(self, save_path_hdf5):
-        f = File(save_path_hdf5, mode="w")
+        f = h5py.File(save_path_hdf5, mode="w")
         _dict2hdf5group({"manufacturer": "kikuchipy", "version": "0.1"}, f["/"])
         f.close()
         with pytest.raises(IOError, match="(.*) as no top groups"):
             _ = KikuchipyH5EBSDReader(save_path_hdf5)
 
     def test_dict2hdf5roup(self, save_path_hdf5):
-        with File(save_path_hdf5, mode="w") as f:
+        with h5py.File(save_path_hdf5, mode="w") as f:
             with pytest.warns(UserWarning, match="(c, set())"):
                 _dict2hdf5group({"a": [np.array(24.5)], "c": set()}, f["/"])
 
@@ -97,20 +97,20 @@ def test_load_manufacturer(self, save_path_hdf5):
         s.save(save_path_hdf5)
 
         # Change manufacturer
-        with File(save_path_hdf5, mode="r+") as f:
+        with h5py.File(save_path_hdf5, mode="r+") as f:
             manufacturer = f["manufacturer"]
             manufacturer[()] = "Nope".encode()
 
         with pytest.raises(
             OSError,
-            match="(.*) is not a supported h5ebsd file, as 'nope' is not among ",
+            match="Could not read (.*). If the file format is supported, ",
         ):
             _ = kp.load(save_path_hdf5)
 
     def test_read_patterns(self, save_path_hdf5):
         s = kp.signals.EBSD((255 * np.random.rand(10, 3, 5, 5)).astype(np.uint8))
         s.save(save_path_hdf5)
-        with File(save_path_hdf5, mode="r+") as f:
+        with h5py.File(save_path_hdf5, mode="r+") as f:
             del f["Scan 1/EBSD/Data/patterns"]
             with pytest.raises(KeyError, match="Could not find patterns"):
                 _ = kp.load(save_path_hdf5)
@@ -128,7 +128,7 @@ def test_load_with_padding(
         s.save(save_path_hdf5)
 
         new_n_columns = 4
-        with File(save_path_hdf5, mode="r+") as f:
+        with h5py.File(save_path_hdf5, mode="r+") as f:
             f["Scan 1/EBSD/Header/n_columns"][()] = new_n_columns
 
         with pytest.warns(UserWarning) as warninfo:
@@ -174,8 +174,7 @@ def test_load_save_hyperspy_cycle(self, tmp_path, kikuchipy_h5ebsd_path):
         s.save(file)
 
         # Reload data and use HyperSpy's set_signal_type function
-        s_reload = hs_load(file)
-        s_reload.set_signal_type("EBSD")
+        s_reload = hs.load(file, signal_type="EBSD")
 
         # Check signal type, patterns and learning results
         assert isinstance(s_reload, kp.signals.EBSD)
@@ -242,7 +241,7 @@ def test_load_readonly(self, kikuchipy_h5ebsd_path):
             )
         )
         mm = s.data.dask[k]
-        assert isinstance(mm, Dataset)
+        assert isinstance(mm, h5py.Dataset)
 
     def test_save_fresh(self, save_path_hdf5, tmp_path):
         scan_size = (10, 3)
@@ -255,7 +254,7 @@ def test_save_fresh(self, save_path_hdf5, tmp_path):
 
         # Test writing of signal to file when no file name is passed to save()
         del s.tmp_parameters.filename
-        with pytest.raises(ValueError, match="Filename not defined"):
+        with pytest.raises(ValueError, match="filename not given"):
             s.save(overwrite=True)
 
         s.metadata.General.original_filename = "an_original_filename"
@@ -280,13 +279,13 @@ def test_save_multiple(self, kikuchipy_h5ebsd_path, save_path_hdf5, scan_number)
 
     def test_read_lazily_no_chunks(self, kikuchipy_h5ebsd_path):
         fname = kikuchipy_h5ebsd_path / "patterns_nochunks.h5"
-        # First, make sure the data image dataset is not actually chunked
-        f = File(fname)
-        data_dset = f["Scan 1/EBSD/Data/patterns"]
+        # Make sure the data image dataset is not actually chunked
+        file = h5py.File(fname)
+        data_dset = file["Scan 1/EBSD/Data/patterns"]
         assert data_dset.chunks is None
-        f.close()
+        file.close()
 
-        # Then, make sure it can be read correctly
+        # Make sure it can be read correctly
         s = kp.load(fname, lazy=True)
         assert s.data.chunks == ((60,), (60,))
 
@@ -320,7 +319,7 @@ def test_save_load_1d_nav(self, save_path_hdf5):
 
         # Maintain axis name
         s_y_only2.axes_manager["y"].name = "x"
-        with pytest.warns(UserWarning, match=r"Crystal map step size\(s\) \[0\] and "):
+        with pytest.warns(UserWarning, match=r"Crystal map step size\(s\) \[0.0\] and"):
             s_y_only2.save(save_path_hdf5, overwrite=True)
         s_x_only3 = kp.load(save_path_hdf5)
         assert s_x_only3.data.shape == desired_shape
@@ -354,7 +353,7 @@ def test_load_with_detector_multiple_pc(self):
 
     def test_writer_check_file(self, save_path_hdf5):
         s = kp.data.nickel_ebsd_small(lazy=True)
-        f = File(save_path_hdf5, mode="w")
+        f = h5py.File(save_path_hdf5, mode="w")
         _dict2hdf5group({"manufacturer": "kikuchipy", "version": "0.1"}, f["/"])
         f.close()
         with pytest.raises(IOError, match="(.*) as no top groups"):
diff --git a/tests/test_io/test_nordif.py b/tests/test_io/test_nordif.py
index 20af57af..1d101d17 100644
--- a/tests/test_io/test_nordif.py
+++ b/tests/test_io/test_nordif.py
@@ -20,9 +20,6 @@
 # blob/RELEASE_next_minor/hyperspy/tests/io/test_blockfile.py
 
 import datetime
-import gc
-import os
-import tempfile
 import time
 
 import dask.array as da
@@ -32,7 +29,7 @@
 import pytest
 
 import kikuchipy as kp
-from kikuchipy.io.plugins.nordif import _get_settings_from_file, _get_string
+from kikuchipy.io.plugins.nordif._api import _get_settings_from_file, _get_string
 
 # Settings content
 METADATA = {
@@ -184,14 +181,6 @@
 }
 
 
-@pytest.fixture()
-def save_path_nordif():
-    with tempfile.TemporaryDirectory() as tmp:
-        file_path = os.path.join(tmp, "nordif", "save_temp.dat")
-        yield file_path
-        gc.collect()
-
-
 class TestNORDIF:
     def test_get_settings_from_file(self, nordif_path):
         settings = _get_settings_from_file(nordif_path / "Setting.txt")
@@ -270,12 +259,11 @@ def test_load_save_cycle(self, nordif_path, save_path_nordif):
         )
         assert s.metadata.General.title == "Pattern"
 
-        s.save(save_path_nordif, overwrite=True)
+        s.save(save_path_nordif)
         with pytest.warns(UserWarning):  # No background pattern in directory
             s_reload = kp.load(
                 save_path_nordif, setting_file=nordif_path / "Setting.txt"
             )
-
         assert np.allclose(s.data, s_reload.data)
 
         # Add static background and change filename to make metadata equal
diff --git a/tests/test_io/test_oxford_binary.py b/tests/test_io/test_oxford_binary.py
index 8681b254..7e74b88e 100644
--- a/tests/test_io/test_oxford_binary.py
+++ b/tests/test_io/test_oxford_binary.py
@@ -20,6 +20,7 @@
 import pytest
 
 import kikuchipy as kp
+from kikuchipy.io.plugins.oxford_binary._api import OxfordBinaryFileReader
 
 
 class TestOxfordBinaryReader:
@@ -110,5 +111,5 @@ def test_guess_number_of_patterns(self, oxford_binary_file, n_patterns):
         the file works.
         """
         with open(oxford_binary_file.name, mode="rb") as f:
-            fox = kp.io.plugins.oxford_binary.OxfordBinaryFileReader(f)
+            fox = OxfordBinaryFileReader(f)
             assert fox.n_patterns == n_patterns
diff --git a/tests/test_io/test_util.py b/tests/test_io/test_util.py
index 71b5c099..de586330 100644
--- a/tests/test_io/test_util.py
+++ b/tests/test_io/test_util.py
@@ -41,11 +41,11 @@ def test_get_input_bool(self, answer, should_return):
         if answer == "m":
             with replace_stdin(io.StringIO(answer)):
                 with pytest.raises(EOFError):
-                    _ = _get_input_bool(question)
+                    _get_input_bool(question, "")
             return
         else:
             with replace_stdin(io.StringIO(answer)):
-                returns = _get_input_bool(question)
+                returns = _get_input_bool(question, "")
         assert returns == should_return
 
     @pytest.mark.parametrize("var_type", (int, 1))
diff --git a/tests/test_signals/test_ebsd.py b/tests/test_signals/test_ebsd.py
index c592d671..d6a4312b 100644
--- a/tests/test_signals/test_ebsd.py
+++ b/tests/test_signals/test_ebsd.py
@@ -366,7 +366,7 @@ def test_inplace(self, dummy_signal):
 
     def test_lazy_output(self, dummy_signal):
         with pytest.raises(
-            ValueError, match="`lazy_output=True` requires `inplace=False`"
+            ValueError, match="'lazy_output=True' requires 'inplace=False'"
         ):
             _ = dummy_signal.remove_static_background(lazy_output=True)
 
@@ -576,7 +576,7 @@ def test_inplace(self, dummy_signal):
     @pytest.mark.filterwarnings("ignore:invalid value")
     def test_lazy_output(self, dummy_signal):
         with pytest.raises(
-            ValueError, match="`lazy_output=True` requires `inplace=False`"
+            ValueError, match="'lazy_output=True' requires 'inplace=False'"
         ):
             _ = dummy_signal.remove_dynamic_background(lazy_output=True)
 
@@ -673,7 +673,7 @@ def test_rescale_intensity_percentiles(self, dummy_signal, percentiles, answer):
         dummy_signal.rescale_intensity(percentiles=percentiles, dtype_out=dtype_out)
 
         assert dummy_signal.data.dtype == dtype_out
-        assert np.allclose(dummy_signal.inav[0, 0].data, answer)
+        assert np.allclose(dummy_signal.inav[0, 0].data, answer, atol=2)
 
     def test_rescale_intensity_in_range(self, dummy_signal):
         dummy_data = dummy_signal.deepcopy().data
@@ -716,7 +716,7 @@ def test_inplace(self, dummy_signal):
 
     def test_lazy_output(self, dummy_signal):
         with pytest.raises(
-            ValueError, match="`lazy_output=True` requires `inplace=False`"
+            ValueError, match="'lazy_output=True' requires 'inplace=False'"
         ):
             _ = dummy_signal.rescale_intensity(lazy_output=True)
 
@@ -785,7 +785,7 @@ def test_inplace(self):
     def test_lazy_output(self):
         s = kp.data.nickel_ebsd_small()
         with pytest.raises(
-            ValueError, match="`lazy_output=True` requires `inplace=False`"
+            ValueError, match="'lazy_output=True' requires 'inplace=False'"
         ):
             _ = s.adaptive_histogram_equalization(lazy_output=True)
 
@@ -1035,7 +1035,7 @@ def test_inplace(self, dummy_signal):
 
     def test_lazy_output(self, dummy_signal):
         with pytest.raises(
-            ValueError, match="`lazy_output=True` requires `inplace=False`"
+            ValueError, match="'lazy_output=True' requires 'inplace=False'"
         ):
             _ = dummy_signal.average_neighbour_patterns(lazy_output=True)
 
@@ -1419,7 +1419,7 @@ def test_lazy_output(self, dummy_signal):
             function_domain="spatial",
         )
         with pytest.raises(
-            ValueError, match="`lazy_output=True` requires `inplace=False`"
+            ValueError, match="'lazy_output=True' requires 'inplace=False'"
         ):
             _ = dummy_signal.fft_filter(lazy_output=True, **filter_kw)
 
@@ -1532,7 +1532,7 @@ def test_inplace(self, dummy_signal):
 
     def test_lazy_output(self, dummy_signal):
         with pytest.raises(
-            ValueError, match="`lazy_output=True` requires `inplace=False`"
+            ValueError, match="'lazy_output=True' requires 'inplace=False'"
         ):
             _ = dummy_signal.normalize_intensity(lazy_output=True)
 
@@ -2080,13 +2080,13 @@ def test_crop_real_data(self):
             ),
         ],
     )
-    def test_crop_image(
+    def test_crop_signal(
         self, dummy_signal, top_bottom_left_right, sig_slices, sig_shape
     ):
         """Custom properties are cropped correctly."""
         static_bg_old = dummy_signal.static_background.copy()
 
-        dummy_signal.crop_image(*top_bottom_left_right)
+        dummy_signal.crop_signal(*top_bottom_left_right)
 
         assert np.allclose(dummy_signal.static_background, static_bg_old[sig_slices])
         assert dummy_signal.detector.shape == sig_shape
@@ -2374,7 +2374,7 @@ def test_inplace(self):
     def test_lazy_output(self):
         s = kp.data.nickel_ebsd_small()
         with pytest.raises(
-            ValueError, match="`lazy_output=True` requires `inplace=False`"
+            ValueError, match="'lazy_output=True' requires 'inplace=False'"
         ):
             _ = s.downsample(2, lazy_output=True)
 
diff --git a/tests/test_signals/test_ebsd_hough_indexing.py b/tests/test_signals/test_ebsd_hough_indexing.py
index 5c533894..4fcf01a8 100644
--- a/tests/test_signals/test_ebsd_hough_indexing.py
+++ b/tests/test_signals/test_ebsd_hough_indexing.py
@@ -146,7 +146,7 @@ def test_indexer_is_compatible_with_signal(self):
         # Vendor
         indexer.vendor = "EDAX"
         assert not _indexer_is_compatible_with_kikuchipy(indexer, (60, 60), 9)
-        with pytest.raises(ValueError, match="`indexer.vendor` must be 'kikuchipy', "):
+        with pytest.raises(ValueError, match="'indexer.vendor' must be 'kikuchipy', "):
             _indexer_is_compatible_with_kikuchipy(indexer, (60, 60), raise_if_not=True)
         indexer.vendor = "kikuchipy"
 
diff --git a/tests/test_signals/test_ebsd_master_pattern.py b/tests/test_signals/test_ebsd_master_pattern.py
index 1f60c3fa..37de0b25 100644
--- a/tests/test_signals/test_ebsd_master_pattern.py
+++ b/tests/test_signals/test_ebsd_master_pattern.py
@@ -663,7 +663,7 @@ def test_rescale_intensity_inplace(self):
     def test_rescale_intensity_lazy_output(self):
         mp = kp.data.nickel_ebsd_master_pattern_small()
         with pytest.raises(
-            ValueError, match="`lazy_output=True` requires `inplace=False`"
+            ValueError, match="'lazy_output=True' requires 'inplace=False'"
         ):
             _ = mp.normalize_intensity(lazy_output=True)
 
@@ -697,7 +697,7 @@ def test_normalize_intensity_inplace(self):
     def test_normalize_intensity_lazy_output(self):
         mp = kp.data.nickel_ebsd_master_pattern_small()
         with pytest.raises(
-            ValueError, match="`lazy_output=True` requires `inplace=False`"
+            ValueError, match="'lazy_output=True' requires 'inplace=False'"
         ):
             _ = mp.normalize_intensity(lazy_output=True)
 
diff --git a/tests/test_signals/test_ecp_master_pattern.py b/tests/test_signals/test_ecp_master_pattern.py
index 09f6b048..5ca3292f 100644
--- a/tests/test_signals/test_ecp_master_pattern.py
+++ b/tests/test_signals/test_ecp_master_pattern.py
@@ -125,7 +125,7 @@ def test_rescale_intensity_inplace(self, emsoft_ecp_master_pattern_file):
     def test_rescale_intensity_lazy_output(self, emsoft_ecp_master_pattern_file):
         mp = kp.load(emsoft_ecp_master_pattern_file)
         with pytest.raises(
-            ValueError, match="`lazy_output=True` requires `inplace=False`"
+            ValueError, match="'lazy_output=True' requires 'inplace=False'"
         ):
             mp.normalize_intensity(lazy_output=True)
 
@@ -159,7 +159,7 @@ def test_normalize_intensity_inplace(self, emsoft_ecp_master_pattern_file):
     def test_normalize_intensity_lazy_output(self, emsoft_ecp_master_pattern_file):
         mp = kp.load(emsoft_ecp_master_pattern_file)
         with pytest.raises(
-            ValueError, match="`lazy_output=True` requires `inplace=False`"
+            ValueError, match="'lazy_output=True' requires 'inplace=False'"
         ):
             mp.normalize_intensity(lazy_output=True)
 
diff --git a/tests/test_signals/test_virtual_bse_image.py b/tests/test_signals/test_virtual_bse_image.py
index cd6af767..6068d5c6 100644
--- a/tests/test_signals/test_virtual_bse_image.py
+++ b/tests/test_signals/test_virtual_bse_image.py
@@ -65,7 +65,7 @@ def test_rescale_intensity_lazy_output(self, dummy_signal):
         vbse_img = vbse_gen.get_images_from_grid()
 
         with pytest.raises(
-            ValueError, match="`lazy_output=True` requires `inplace=False`"
+            ValueError, match="'lazy_output=True' requires 'inplace=False'"
         ):
             vbse_img.normalize_intensity(lazy_output=True)
 
@@ -103,7 +103,7 @@ def test_normalize_intensity_lazy_output(self, dummy_signal):
         vbse_gen.grid_shape = (3, 3)
         vbse_img = vbse_gen.get_images_from_grid()
         with pytest.raises(
-            ValueError, match="`lazy_output=True` requires `inplace=False`"
+            ValueError, match="'lazy_output=True' requires 'inplace=False'"
         ):
             vbse_img.normalize_intensity(lazy_output=True)
 
diff --git a/tests/test_util/test_deprecated.py b/tests/test_util/test_deprecated.py
index 80a18ded..0cc86bbf 100644
--- a/tests/test_util/test_deprecated.py
+++ b/tests/test_util/test_deprecated.py
@@ -17,10 +17,10 @@
 
 import warnings
 
-import numpy as np
 import pytest
 
 from kikuchipy._util import deprecated, deprecated_argument
+from kikuchipy.constants import VisibleDeprecationWarning
 
 
 class TestDeprecationWarning:
@@ -35,7 +35,7 @@ def foo(n):
             """Some docstring."""
             return n + 1
 
-        with pytest.warns(np.VisibleDeprecationWarning) as record:
+        with pytest.warns(VisibleDeprecationWarning) as record:
             assert foo(4) == 5
         desired_msg = (
             "Function `foo()` is deprecated and will be removed in version 0.8. Use "
@@ -59,7 +59,7 @@ def foo2(n):
             """
             return n + 2
 
-        with pytest.warns(np.VisibleDeprecationWarning) as record2:
+        with pytest.warns(VisibleDeprecationWarning) as record2:
             assert foo2(4) == 6
         desired_msg2 = "Function `foo2()` is deprecated."
         assert str(record2[0].message) == desired_msg2
@@ -76,7 +76,7 @@ def test_deprecation_no_old_doc(self):
         def foo(n):
             return n + 1
 
-        with pytest.warns(np.VisibleDeprecationWarning) as record:
+        with pytest.warns(VisibleDeprecationWarning) as record:
             assert foo(4) == 5
         desired_msg = (
             "Function `foo()` is deprecated and will be removed in version 0.8. Use "
@@ -101,7 +101,7 @@ def b(self):
                 return 1
 
         foo1 = Foo(1)
-        with pytest.warns(np.VisibleDeprecationWarning) as record:
+        with pytest.warns(VisibleDeprecationWarning) as record:
             assert foo1.b == 1
         desired_msg = "Attribute `b` is deprecated. Use `c` instead."
         assert str(record[0].message) == desired_msg
@@ -137,7 +137,7 @@ def bar_arg_alt(self, **kwargs):
             assert my_foo.bar_arg(b=1) == {"b": 1}
 
         # Warns
-        with pytest.warns(np.VisibleDeprecationWarning) as record2:
+        with pytest.warns(VisibleDeprecationWarning) as record2:
             assert my_foo.bar_arg(a=2) == {"a": 2}
         assert str(record2[0].message) == (
             r"Parameter `a` is deprecated and will be removed in version 1.4. "
@@ -146,7 +146,7 @@ def bar_arg_alt(self, **kwargs):
         )
 
         # Warns with alternative
-        with pytest.warns(np.VisibleDeprecationWarning) as record3:
+        with pytest.warns(VisibleDeprecationWarning) as record3:
             assert my_foo.bar_arg_alt(a=3) == {"a": 3}
         assert str(record3[0].message) == (
             r"Parameter `a` is deprecated and will be removed in version 1.4. "