Repository: cta-observatory/ctapipe
Pull request #2641 (2024-11-08 10:18:53)

Make some dependencies optional
- [X] matplotlib
- [X] Bokeh
- [x] eventio
- [x] iminuit


Patch:
diff --git a/.github/workflows/changelog.yml b/.github/workflows/changelog.yml
index 31131d61..d9e14aee 100644
--- a/.github/workflows/changelog.yml
+++ b/.github/workflows/changelog.yml
@@ -6,19 +6,19 @@ on:
     types: [opened, reopened, labeled, unlabeled, synchronize]
 
 env:
-  FRAGMENT_NAME_PREFIX: "docs/changes/${{ github.event.number }}"
+  FRAGMENT_NAME: "docs/changes/${{ github.event.number }}.{feature,bugfix,api,datamodel,optimization,maintenance}.rst"
 
 jobs:
   changelog:
     runs-on: ubuntu-latest
     steps:
-      - name: Checkout code
-        uses: actions/checkout@v4
+      - uses: actions/checkout@v4
         with:
           fetch-depth: 0
 
-      - name: Check for at least one changelog fragment
-        if: ${{ !contains(github.event.pull_request.labels.*.name, 'no-changelog-needed') }}
-        run: |
-          find docs/changes -type f | \
-          grep -P "${FRAGMENT_NAME_PREFIX}\.(feature|bugfix|api|datamodel|optimization|maintenance)\.rst"
+      - name: Check for news fragment
+        if: ${{ ! contains( github.event.pull_request.labels.*.name, 'no-changelog-needed')}}
+        uses: andstor/file-existence-action@v3
+        with:
+          files: ${{ env.FRAGMENT_NAME }}
+          fail: true
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index f47935c7..bf759c5f 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -39,36 +39,50 @@ jobs:
     strategy:
       matrix:
         include:
-          - os: ubuntu-latest
+          - name: Linux (3.10, mamba)
+            os: ubuntu-latest
             python-version: "3.10"
             install-method: mamba
+            extras: tests,all
 
-          - os: ubuntu-latest
+          - name: Linux (3.10, pip)
+            os: ubuntu-latest
             python-version: "3.10"
             install-method: pip
+            extras: tests,all
 
-          - os: ubuntu-latest
+          - name: Linux (3.11, pip, minimal-dependencies)
+            os: ubuntu-latest
             python-version: "3.11"
-            install-method: mamba
+            install-method: pip
+            extras: tests
 
-          - os: ubuntu-latest
+          - name: Linux (3.12, mamba, coverage)
+            os: ubuntu-latest
             python-version: "3.12"
             install-method: mamba
             extra-args: ["codecov"]
+            extras: tests,all
 
-          - os: ubuntu-latest
+          - name: Linux (3.12, pip)
+            os: ubuntu-latest
             python-version: "3.12"
             install-method: pip
+            extras: tests,all
 
           # macos 13 image is x86-based
-          - os: macos-13
+          - name: macos (3.10, x86_64, pip)
+            os: macos-13
             python-version: "3.10"
             install-method: pip
+            extras: tests,all
 
           # macos 14 image is arm64 based
-          - os: macos-14
+          - name: macos (3.12, arm64, mamba)
+            os: macos-14
             python-version: "3.12"
             install-method: mamba
+            extras: tests,all
 
     defaults:
       run:
@@ -122,9 +136,10 @@ jobs:
         if: contains(github.event.pull_request.labels.*.name, 'documentation-only') == false
         env:
           PYTHON_VERSION: ${{ matrix.python-version }}
+          EXTRAS: ${{ matrix.extras }}
         run: |
           python --version | grep "Python ${PYTHON_VERSION}"
-          pip install -e .[tests]
+          pip install -e ".[${EXTRAS}]"
           pip install ./test_plugin
           pip freeze
           pip list
diff --git a/docs/api-reference/visualization/bokeh.rst b/docs/api-reference/visualization/bokeh.rst
new file mode 100644
index 00000000..b652233f
--- /dev/null
+++ b/docs/api-reference/visualization/bokeh.rst
@@ -0,0 +1,14 @@
+.. _visualization_bokeh:
+
+**********************************************************
+Bokeh-based visualization (`~ctapipe.visualization.bokeh`)
+**********************************************************
+
+.. currentmodule:: ctapipe.visualization.bokeh
+
+
+Reference/API
+=============
+
+.. automodapi:: ctapipe.visualization.bokeh
+    :no-inheritance-diagram:
diff --git a/docs/api-reference/visualization/index.rst b/docs/api-reference/visualization/index.rst
index c5c5ced1..18ce7c6e 100644
--- a/docs/api-reference/visualization/index.rst
+++ b/docs/api-reference/visualization/index.rst
@@ -12,13 +12,19 @@ Introduction
 
 This module provides methods to display various information related to
 events and reconstruction, like Cherenkov Camera images, image
-parameterizations, etc. The default implementation uses MatPlotLib
-to render displays, but in the future more rendering methods may be supported.
+parameterizations, etc.
 
-The main classes are:
+For the classes defined at this level, ``matplotlib`` is required as optional
+dependency. There are also bokeh-based versions of these classes defined in :ref:`visualization_bokeh`.
 
-* `CameraDisplay`
-* `ArrayDisplay`
+
+Submodules
+==========
+
+.. toctree::
+  :maxdepth: 1
+
+  bokeh
 
 
 Reference/API
diff --git a/docs/changes/2641.api.rst b/docs/changes/2641.api.rst
new file mode 100644
index 00000000..f48a8c2b
--- /dev/null
+++ b/docs/changes/2641.api.rst
@@ -0,0 +1,16 @@
+The following dependencies are now optional:
+
+* eventio, used for ``ctapipe.io.SimTelEventSource``.
+* matplotlib, used ``ctapipe.visualiation.CameraDisplay``, ``ctapipe.visualiation.ArrayDisplay``,
+  and most default visualiation tasks, e.g. ``.peek()`` methods.
+* iminuit, used for the ``ctapipe.image.muon`` and ``ctapipe.reco.impact`` fitting routines.
+* bokeh, for ``ctapipe.visualiation.bokeh``
+
+Code that needs these dependencies will now raise ``ctapipe.exceptions.OptionalDependencyMissing``
+in case such functionality is used and the dependency in question is not installed.
+
+These packages will now longer be installed by default when using e.g. ``pip install ctapipe``.
+
+If you want to install ctapipe with all optional dependencies included, do ``pip install "ctapipe[all]"``.
+
+The ``conda`` package will also no longer directly depend on these packages.
diff --git a/docs/developer-guide/getting-started.rst b/docs/developer-guide/getting-started.rst
index 2838160e..ecf98245 100644
--- a/docs/developer-guide/getting-started.rst
+++ b/docs/developer-guide/getting-started.rst
@@ -100,7 +100,7 @@ usable from anywhere, given you have activated the ``cta-dev`` conda environment
 
 .. code-block:: console
 
-    $ pip install -e .
+    $ pip install -e '.[dev]'
 
 Using the editable installation means you won't have to rerun the installation for
 simple code changes to take effect.
diff --git a/docs/user-guide/getting-started.rst b/docs/user-guide/getting-started.rst
index f228d921..08318f61 100644
--- a/docs/user-guide/getting-started.rst
+++ b/docs/user-guide/getting-started.rst
@@ -41,6 +41,22 @@ or with pip:
    $ pip install ctapipe
 
 
+``ctapipe`` has a number of optional dependencies that are not automatically installed
+when just installing ``ctapipe``.
+These are:
+
+- `matplotlib <https://matplotlib.org/>`_ for visualization, used in `~ctapipe.visualization` and several other places.
+- `bokeh <https://bokeh.org/>`_ for web-based, interactive visualizations, see `~ctapipe.visualization.bokeh`.
+- `iminuit <https://scikit-hep.org/iminuit/>`_ for fitting, used in `~ctapipe.reco.impact` and `~ctapipe.image.muon`.
+- `eventio <https://github.com/cta-observatory/pyeventio>`_ used for reading ``sim_telarray`` files in `~ctapipe.io.SimTelEventSource`.
+
+You can install them individually, or if you just want to get ``ctapipe`` with all optional dependencies, use:
+
+.. code-block:: console
+
+   $ pip install 'ctapipe[all]'
+
+
 How To Get a Specific Version
 -----------------------------
 
diff --git a/environment.yml b/environment.yml
index f92fcfa6..e640533a 100644
--- a/environment.yml
+++ b/environment.yml
@@ -48,6 +48,5 @@ dependencies:
   - wheel
   - xz
   - zlib
-  - zstandard
   - eventio>=1.9.1
   - ffmpeg # for making movies in the documentation
diff --git a/pyproject.toml b/pyproject.toml
index 9f31b876..aeb8ad2d 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -31,14 +31,11 @@ requires-python = ">=3.10"
 
 dependencies = [
     "astropy >=5.3,<7.0.0a0",
-    "bokeh ~=3.0",
     "docutils",
-    "eventio >=1.9.1, <2.0.0a0",
-    "iminuit >=2",
     "joblib",
-    "matplotlib ~=3.0",
     "numba >=0.56",
     "numpy >=1.23,<3.0.0a0",
+    "packaging",
     "psutil",
     "pyyaml >=5.1",
     "requests",
@@ -47,12 +44,23 @@ dependencies = [
     "tables ~=3.4",
     "tqdm >=4.32",
     "traitlets ~=5.6",
-    "zstandard",
-    "packaging",
 ]
 
 [project.optional-dependencies]
+
+# all is with all optional *runtime* dependencies
+# use `dev` to get really all dependencies
+all = [
+    "bokeh ~=3.0",
+    "eventio >=1.9.1,<2.0.0a0",
+    "iminuit >=2",
+    "matplotlib ~=3.0",
+]
+
 tests = [
+    # at the moment, essentially all tests rely on test data from simtel
+    # it doesn't make sense to skip all of these.
+    "eventio >=1.9.1,<2.0.0a0",
     "h5py",
     "pandas",
     "pytest >= 7.0",
@@ -64,33 +72,31 @@ tests = [
 ]
 
 docs = [
-    "sphinx",
-    "pydata_sphinx_theme",
-    "sphinx_automodapi",
+    "ctapipe[all]",
+    "ffmpeg-python",
+    "graphviz",
+    "ipython",
+    "jupyter",
     "nbsphinx",
+    "notebook",
     "numpydoc",
+    "pandas",
+    "pydata_sphinx_theme",
+    "pypandoc",
+    "sphinx",
     "sphinx-design",
     "sphinx-gallery >= 0.16.0",
+    "sphinx_automodapi",
     "sphinxcontrib-bibtex",
-    "jupyter",
-    "notebook",
-    "graphviz",
-    "pandas",
-    "ipython",
-    "ffmpeg-python",
-    "pypandoc",
     "tomli; python_version < '3.11'",
 ]
 
 dev = [
+    "ctapipe[all,docs,tests]",
     "pre-commit",
     "setuptools_scm[toml]",
 ]
 
-all = [
-    # self-reference with all extras to simplify
-    "ctapipe[tests,docs,dev]",
-]
 
 [project.scripts]
 ctapipe-info = "ctapipe.tools.info:main"
diff --git a/src/ctapipe/atmosphere.py b/src/ctapipe/atmosphere.py
index 62f25bc0..8808cfb6 100644
--- a/src/ctapipe/atmosphere.py
+++ b/src/ctapipe/atmosphere.py
@@ -19,6 +19,8 @@ from astropy import units as u
 from astropy.table import QTable, Table
 from scipy.interpolate import interp1d
 
+from ctapipe.exceptions import OptionalDependencyMissing
+
 from .compat import COPY_IF_NEEDED
 
 __all__ = [
@@ -152,7 +154,10 @@ class AtmosphereDensityProfile(abc.ABC):
         Draw quick plot of profile
         """
         # pylint: disable=import-outside-toplevel
-        import matplotlib.pyplot as plt
+        try:
+            import matplotlib.pyplot as plt
+        except ModuleNotFoundError:
+            raise OptionalDependencyMissing("matplotlib") from None
 
         fig, axis = plt.subplots(1, 3, constrained_layout=True, figsize=(10, 3))
 
diff --git a/src/ctapipe/core/provenance.py b/src/ctapipe/core/provenance.py
index a8f602e1..e827637f 100644
--- a/src/ctapipe/core/provenance.py
+++ b/src/ctapipe/core/provenance.py
@@ -14,10 +14,12 @@ import uuid
 import warnings
 from collections import UserList
 from contextlib import contextmanager
+from functools import cache
 from importlib import import_module
-from importlib.metadata import distributions, version
+from importlib.metadata import Distribution, distributions
 from os.path import abspath
 from pathlib import Path
+from types import ModuleType
 
 import psutil
 from astropy.time import Time
@@ -45,17 +47,59 @@ _interesting_env_vars = [
 ]
 
 
+@cache
+def modules_of_distribution(distribution: Distribution):
+    modules = distribution.read_text("top_level.txt")
+    if modules is None:
+        return None
+    return set(modules.splitlines())
+
+
+@cache
+def get_distribution_of_module(module: ModuleType | str):
+    """Get the package distribution for an imported module"""
+    if isinstance(module, str):
+        name = module
+        module = import_module(module)
+    else:
+        name = module.__name__
+
+    path = Path(module.__file__).absolute()
+
+    for dist in distributions():
+        modules = modules_of_distribution(dist)
+        if modules is None:
+            base = dist.locate_file("")
+            if dist.files is not None and any(path == base / f for f in dist.files):
+                return dist
+        elif name in modules:
+            return dist
+
+    raise ValueError(f"Could not find a distribution for module: {module}")
+
+
 def get_module_version(name):
+    """
+    Get the version of a python *module*, something you can import.
+
+    If the module does not expose a ``__version__`` attribute, this function
+    will try to determine the *distribution* of the module and return its
+    version.
+    """
+    # we try first with module.__version__
+    # to support editable installs
     try:
         module = import_module(name)
+    except ModuleNotFoundError:
+        return "not installed"
+
+    try:
         return module.__version__
     except AttributeError:
         try:
-            return version(name)
+            return get_distribution_of_module(module).version
         except Exception:
             return "unknown"
-    except ImportError:
-        return "not installed"
 
 
 class MissingReferenceMetadata(UserWarning):
@@ -351,15 +395,13 @@ def _get_python_packages():
 
 
 def _get_system_provenance():
-    """return JSON string containing provenance for all things that are
+    """return a dict containing provenance for all things that are
     fixed during the runtime"""
 
     bits, linkage = platform.architecture()
 
     return dict(
         ctapipe_version=__version__,
-        ctapipe_resources_version=get_module_version("ctapipe_resources"),
-        eventio_version=get_module_version("eventio"),
         ctapipe_svc_path=os.getenv("CTAPIPE_SVC_PATH"),
         executable=sys.executable,
         platform=dict(
diff --git a/src/ctapipe/exceptions.py b/src/ctapipe/exceptions.py
index c75f2318..9a78de3d 100644
--- a/src/ctapipe/exceptions.py
+++ b/src/ctapipe/exceptions.py
@@ -4,3 +4,12 @@ class CTAPipeException(Exception):
 
 class TooFewEvents(CTAPipeException):
     """Raised if something that needs a minimum number of event gets fewer"""
+
+
+class OptionalDependencyMissing(ModuleNotFoundError):
+    """Raised if an optional dependency required for a feature is not installed"""
+
+    def __init__(self, module):
+        self.module = module
+        msg = f"'{module}' is required for this functionality of ctapipe"
+        super().__init__(msg)
diff --git a/src/ctapipe/image/muon/fitting.py b/src/ctapipe/image/muon/fitting.py
index 2fce59f3..64d8e4f8 100644
--- a/src/ctapipe/image/muon/fitting.py
+++ b/src/ctapipe/image/muon/fitting.py
@@ -1,11 +1,16 @@
 import numpy as np
 from astropy.units import Quantity
-from iminuit import Minuit
 
-from ctapipe.utils.quantities import all_to_value
+from ...exceptions import OptionalDependencyMissing
+from ...utils.quantities import all_to_value
 
 __all__ = ["kundu_chaudhuri_circle_fit", "taubin_circle_fit"]
 
+try:
+    from iminuit import Minuit
+except ModuleNotFoundError:
+    Minuit = None
+
 
 def kundu_chaudhuri_circle_fit(x, y, weights):
     """
@@ -61,6 +66,9 @@ def taubin_circle_fit(x, y, mask):
     mask: array-like boolean
         true for pixels surviving the cleaning
     """
+    if Minuit is None:
+        raise OptionalDependencyMissing("iminuit")
+
     original_unit = x.unit
     x, y = all_to_value(x, y, unit=original_unit)
 
diff --git a/src/ctapipe/image/muon/intensity_fitter.py b/src/ctapipe/image/muon/intensity_fitter.py
index f150ca47..2063f237 100644
--- a/src/ctapipe/image/muon/intensity_fitter.py
+++ b/src/ctapipe/image/muon/intensity_fitter.py
@@ -12,20 +12,25 @@ from math import erf
 
 import numpy as np
 from astropy import units as u
-from iminuit import Minuit
 from numba import double, vectorize
 from scipy.constants import alpha
 from scipy.ndimage import correlate1d
 
-from ctapipe.image.pixel_likelihood import neg_log_likelihood_approx
-
 from ...containers import MuonEfficiencyContainer
 from ...coordinates import TelescopeFrame
 from ...core import TelescopeComponent
 from ...core.traits import FloatTelescopeParameter, IntTelescopeParameter
+from ...exceptions import OptionalDependencyMissing
+from ..pixel_likelihood import neg_log_likelihood_approx
 
-__all__ = ["MuonIntensityFitter"]
+try:
+    from iminuit import Minuit
+except ModuleNotFoundError:
+    Minuit = None
 
+__all__ = [
+    "MuonIntensityFitter",
+]
 
 # ratio of the areas of the unit circle and a square of side lengths 2
 CIRCLE_SQUARE_AREA_RATIO = np.pi / 4
@@ -466,8 +471,10 @@ class MuonIntensityFitter(TelescopeComponent):
     ).tag(config=True)
 
     def __init__(self, subarray, **kwargs):
-        super().__init__(subarray=subarray, **kwargs)
+        if Minuit is None:
+            raise OptionalDependencyMissing("iminuit") from None
 
+        super().__init__(subarray=subarray, **kwargs)
         self._geometries_tel_frame = {
             tel_id: tel.camera.geometry.transform_to(TelescopeFrame())
             for tel_id, tel in subarray.tel.items()
diff --git a/src/ctapipe/io/eventsource.py b/src/ctapipe/io/eventsource.py
index b6edb985..d4343f30 100644
--- a/src/ctapipe/io/eventsource.py
+++ b/src/ctapipe/io/eventsource.py
@@ -338,16 +338,19 @@ class EventSource(Component):
         if input_url == "" or input_url in {None, Undefined}:
             raise ToolConfigurationError("EventSource: No input_url was specified")
 
-        # validate input url with the traitel validate method
+        # validate input url with the traitlet validate method
         # to make sure it's compatible and to raise the correct error
         input_url = EventSource.input_url.validate(obj=None, value=input_url)
 
         available_classes = cls.non_abstract_subclasses()
 
+        missing_deps = {}
         for name, subcls in available_classes.items():
             try:
                 if subcls.is_compatible(input_url):
                     return subcls
+            except ModuleNotFoundError as e:
+                missing_deps[subcls] = e.module
             except Exception as e:
                 warnings.warn(f"{name}.is_compatible raised exception: {e}")
 
@@ -355,15 +358,23 @@ class EventSource(Component):
         if not input_url.exists():
             raise TraitError(
                 f"input_url {input_url} is not an existing file "
-                " and no EventSource implementation claimed compatibility"
+                " and no EventSource implementation claimed compatibility."
             )
 
-        raise ValueError(
-            "Cannot find compatible EventSource for \n"
-            "\turl:{}\n"
-            "in available EventSources:\n"
-            "\t{}".format(input_url, [c for c in available_classes])
+        available_sources = [
+            name for name, cls in available_classes.items() if cls not in missing_deps
+        ]
+
+        msg = (
+            f"Could not find compatible EventSource for input_url: {input_url!r}\n"
+            f"in available EventSources: {available_sources}\n"
+            "EventSources that are installed but could not be used due to missing dependencies:\n\t"
+            + "\n\t".join(
+                f"{source.__name__}: {missing}"
+                for source, missing in missing_deps.items()
+            )
         )
+        raise ValueError(msg)
 
     @classmethod
     def from_url(cls, input_url, **kwargs):
diff --git a/src/ctapipe/io/simteleventsource.py b/src/ctapipe/io/simteleventsource.py
index 175914fb..6d4acd5e 100644
--- a/src/ctapipe/io/simteleventsource.py
+++ b/src/ctapipe/io/simteleventsource.py
@@ -11,8 +11,6 @@ from astropy import units as u
 from astropy.coordinates import Angle, EarthLocation
 from astropy.table import Table
 from astropy.time import Time
-from eventio.file_types import is_eventio
-from eventio.simtel.simtelfile import SimTelFile
 
 from ..atmosphere import (
     AtmosphereDensityProfile,
@@ -50,6 +48,7 @@ from ..coordinates import CameraFrame, shower_impact_distance
 from ..core import Map
 from ..core.provenance import Provenance
 from ..core.traits import Bool, ComponentName, Float, Integer, Undefined, UseEnum
+from ..exceptions import OptionalDependencyMissing
 from ..instrument import (
     CameraDescription,
     CameraGeometry,
@@ -70,6 +69,12 @@ from ..instrument.guess import (
 from .datalevels import DataLevel
 from .eventsource import EventSource
 
+try:
+    from eventio import SimTelFile
+except ModuleNotFoundError:
+    SimTelFile = None
+
+
 __all__ = [
     "SimTelEventSource",
     "read_atmosphere_profile_from_simtel",
@@ -529,6 +534,8 @@ class SimTelEventSource(EventSource):
         kwargs
         """
         super().__init__(input_url=input_url, config=config, parent=parent, **kwargs)
+        if SimTelFile is None:
+            raise OptionalDependencyMissing("eventio")
 
         self.file_ = SimTelFile(
             self.input_url.expanduser(),
@@ -736,6 +743,11 @@ class SimTelEventSource(EventSource):
 
     @staticmethod
     def is_compatible(file_path):
+        try:
+            from eventio.file_types import is_eventio
+        except ModuleNotFoundError:
+            raise OptionalDependencyMissing("eventio") from None
+
         path = Path(file_path).expanduser()
         if not path.is_file():
             return False
diff --git a/src/ctapipe/reco/impact.py b/src/ctapipe/reco/impact.py
index e837e8a8..969eac84 100644
--- a/src/ctapipe/reco/impact.py
+++ b/src/ctapipe/reco/impact.py
@@ -9,10 +9,10 @@ import numpy as np
 import numpy.ma as ma
 from astropy import units as u
 from astropy.coordinates import AltAz, SkyCoord
-from iminuit import Minuit
 from scipy.stats import norm
 
 from ctapipe.core import traits
+from ctapipe.exceptions import OptionalDependencyMissing
 
 from ..compat import COPY_IF_NEEDED
 from ..containers import ReconstructedEnergyContainer, ReconstructedGeometryContainer
@@ -49,6 +49,11 @@ from .reconstructor import (
     TooFewTelescopesException,
 )
 
+try:
+    from iminuit import Minuit
+except ModuleNotFoundError:
+    Minuit = None
+
 PROV = Provenance()
 
 INVALID_GEOMETRY = ReconstructedGeometryContainer(
@@ -134,6 +139,9 @@ class ImPACTReconstructor(HillasGeometryReconstructor):
     def __init__(
         self, subarray, atmosphere_profile, dummy_reconstructor=False, **kwargs
     ):
+        if Minuit is None:
+            raise OptionalDependencyMissing("iminuit") from None
+
         if atmosphere_profile is None:
             raise TypeError(
                 "Argument 'atmosphere_profile' can not be 'None' for ImPACTReconstructor"
diff --git a/src/ctapipe/tools/display_dl1.py b/src/ctapipe/tools/display_dl1.py
index 7a27305b..3392e6c9 100644
--- a/src/ctapipe/tools/display_dl1.py
+++ b/src/ctapipe/tools/display_dl1.py
@@ -4,13 +4,11 @@ Calibrate dl0 data to dl1, and plot the photoelectron images.
 from contextlib import ExitStack
 from copy import copy
 
-from matplotlib import pyplot as plt
-from matplotlib.backends.backend_pdf import PdfPages
-
 from ..calib import CameraCalibrator
 from ..core import Component, QualityQuery, Tool
 from ..core.tool import ToolConfigurationError
 from ..core.traits import Bool, Int, Path, classes_with_traits, flag
+from ..exceptions import OptionalDependencyMissing
 from ..image.extractor import ImageExtractor
 from ..io import EventSource
 from ..io.datalevels import DataLevel
@@ -64,6 +62,12 @@ class ImagePlotter(Component):
         self._init_figure()
 
     def _init_figure(self):
+        try:
+            from matplotlib import pyplot as plt
+            from matplotlib.backends.backend_pdf import PdfPages
+        except ModuleNotFoundError:
+            raise OptionalDependencyMissing("matplotlib") from None
+
         self.fig = plt.figure(figsize=(16, 7))
         self.ax_intensity = self.fig.add_subplot(1, 2, 1)
         self.ax_peak_time = self.fig.add_subplot(1, 2, 2)
@@ -72,6 +76,8 @@ class ImagePlotter(Component):
             self.pdf = self._exit_stack.enter_context(PdfPages(self.output_path))
 
     def plot(self, event, tel_id):
+        from matplotlib import pyplot as plt
+
         image = event.dl1.tel[tel_id].image
         peak_time = event.dl1.tel[tel_id].peak_time
 
diff --git a/src/ctapipe/tools/info.py b/src/ctapipe/tools/info.py
index f100b24e..70aee352 100644
--- a/src/ctapipe/tools/info.py
+++ b/src/ctapipe/tools/info.py
@@ -3,8 +3,12 @@
 import logging
 import os
 import sys
+from importlib.metadata import PackageNotFoundError, requires, version
 from importlib.resources import files
 
+from packaging.markers import default_environment
+from packaging.requirements import Requirement
+
 from ..core import Provenance, get_module_version
 from ..core.plugins import detect_and_import_plugins
 from ..utils import datasets
@@ -12,27 +16,6 @@ from .utils import get_parser
 
 __all__ = ["info"]
 
-# TODO: this list should be global (or generated at install time)
-_dependencies = sorted(
-    [
-        "astropy",
-        "matplotlib",
-        "numpy",
-        "traitlets",
-        "sklearn",
-        "scipy",
-        "numba",
-        "pytest",
-        "iminuit",
-        "tables",
-        "eventio",
-    ]
-)
-
-_optional_dependencies = sorted(
-    ["ctapipe_resources", "pytest", "graphviz", "matplotlib"]
-)
-
 
 def main(args=None):
     parser = get_parser(info)
@@ -162,15 +145,28 @@ def _info_dependencies():
     """Print info about dependencies."""
     print("\n*** ctapipe core dependencies ***\n")
 
-    for name in _dependencies:
-        version = get_module_version(name)
-        print(f"{name:>20s} -- {version}")
+    env = default_environment()
+    requirements = [Requirement(r) for r in requires("ctapipe")]
+    dependencies = [
+        r.name for r in requirements if r.marker is None or r.marker.evaluate(env)
+    ]
+
+    env["extra"] = "all"
+    optional_dependencies = [
+        r.name for r in requirements if r.marker is not None and r.marker.evaluate(env)
+    ]
+
+    for name in dependencies:
+        print(f"{name:>20s} -- {version(name)}")
 
     print("\n*** ctapipe optional dependencies ***\n")
 
-    for name in _optional_dependencies:
-        version = get_module_version(name)
-        print(f"{name:>20s} -- {version}")
+    for name in optional_dependencies:
+        try:
+            v = version(name)
+        except PackageNotFoundError:
+            v = "not installed"
+        print(f"{name:>20s} -- {v}")
 
 
 def _info_resources():
diff --git a/src/ctapipe/tools/process.py b/src/ctapipe/tools/process.py
index ef272346..3a01c781 100644
--- a/src/ctapipe/tools/process.py
+++ b/src/ctapipe/tools/process.py
@@ -191,7 +191,10 @@ class ProcessorTool(Tool):
         self.write = self.enter_context(
             DataWriter(event_source=self.event_source, parent=self)
         )
-        self.process_muons = MuonProcessor(subarray=subarray, parent=self)
+
+        self.process_muons = None
+        if self.should_compute_muon_parameters:
+            self.process_muons = MuonProcessor(subarray=subarray, parent=self)
 
         self.event_type_filter = EventTypeFilter(parent=self)
 
diff --git a/src/ctapipe/utils/rgbtohex.py b/src/ctapipe/utils/rgbtohex.py
index 6172b1c5..d5083a9c 100644
--- a/src/ctapipe/utils/rgbtohex.py
+++ b/src/ctapipe/utils/rgbtohex.py
@@ -1,9 +1,8 @@
 import codecs
 
 import numpy as np
-from matplotlib import colormaps
 
-viridis = colormaps["viridis"]
+from ..exceptions import OptionalDependencyMissing
 
 
 def intensity_to_rgb(array, minval=None, maxval=None):
@@ -25,6 +24,12 @@ def intensity_to_rgb(array, minval=None, maxval=None):
         rgb tuple representing the intensity as a color
 
     """
+    try:
+        from matplotlib import colormaps
+    except ModuleNotFoundError:
+        raise OptionalDependencyMissing("matplotlib") from None
+
+    viridis = colormaps["viridis"]
     if minval is None:
         minval = np.nanmin(array)
     if maxval is None:
diff --git a/src/ctapipe/visualization/__init__.py b/src/ctapipe/visualization/__init__.py
index a1b300e2..f83c3715 100644
--- a/src/ctapipe/visualization/__init__.py
+++ b/src/ctapipe/visualization/__init__.py
@@ -2,12 +2,7 @@
 """
 Visualization: Methods for displaying data
 """
-
-try:
-    from .mpl_array import ArrayDisplay
-    from .mpl_camera import CameraDisplay
-except ImportError:
-    pass
-
+from .mpl_array import ArrayDisplay
+from .mpl_camera import CameraDisplay
 
 __all__ = ["CameraDisplay", "ArrayDisplay"]
diff --git a/src/ctapipe/visualization/bokeh.py b/src/ctapipe/visualization/bokeh.py
index a6300e8f..6bf4b856 100644
--- a/src/ctapipe/visualization/bokeh.py
+++ b/src/ctapipe/visualization/bokeh.py
@@ -3,33 +3,43 @@ from abc import ABCMeta
 from tempfile import NamedTemporaryFile
 
 import astropy.units as u
-import matplotlib.pyplot as plt
 import numpy as np
-from bokeh.io import output_file, output_notebook, push_notebook, show
-from bokeh.models import (
-    BoxZoomTool,
-    CategoricalColorMapper,
-    ColorBar,
-    ColumnDataSource,
-    ContinuousColorMapper,
-    Ellipse,
-    HoverTool,
-    Label,
-    LinearColorMapper,
-    LogColorMapper,
-    TapTool,
-)
-from bokeh.palettes import Greys256, Inferno256, Magma256, Viridis256, d3
-from bokeh.plotting import figure
-from bokeh.transform import transform
-from matplotlib.colors import to_hex
 
+from ..exceptions import OptionalDependencyMissing
 from ..instrument import CameraGeometry, PixelShape
 from .utils import build_hillas_overlay
 
-PLOTARGS = dict(tools="", toolbar_location=None, outline_line_color="#595959")
+try:
+    from bokeh.io import output_file, output_notebook, push_notebook, show
+    from bokeh.models import (
+        BoxZoomTool,
+        CategoricalColorMapper,
+        ColorBar,
+        ColumnDataSource,
+        ContinuousColorMapper,
+        Ellipse,
+        HoverTool,
+        Label,
+        LinearColorMapper,
+        LogColorMapper,
+        TapTool,
+    )
+    from bokeh.palettes import Greys256, Inferno256, Magma256, Viridis256, d3
+    from bokeh.plotting import figure
+    from bokeh.transform import transform
+except ModuleNotFoundError:
+    raise OptionalDependencyMissing("bokeh") from None
+
+
+__all__ = [
+    "BokehPlot",
+    "CameraDisplay",
+    "ArrayDisplay",
+]
 
 
+PLOTARGS = dict(tools="", toolbar_location=None, outline_line_color="#595959")
+
 # mapper to mpl names
 CMAPS = {
     "viridis": Viridis256,
@@ -45,6 +55,12 @@ def palette_from_mpl_name(name):
     if name in CMAPS:
         return CMAPS[name]
 
+    try:
+        import matplotlib.pyplot as plt
+        from matplotlib.colors import to_hex
+    except ModuleNotFoundError:
+        raise OptionalDependencyMissing("matplotlib") from None
+
     rgba = plt.get_cmap(name)(np.linspace(0, 1, 256))
     palette = [to_hex(color) for color in rgba]
     return palette
@@ -369,7 +385,7 @@ class CameraDisplay(BokehPlot):
             )
 
     def enable_pixel_picker(self, callback):
-        """Call `callback`` when a pixel is clicked"""
+        """Call ``callback`` when a pixel is clicked"""
         if self._tap_tool is None:
             self.figure.add_tools(TapTool())
         self.datasource.selected.on_change("indices", callback)
@@ -473,7 +489,7 @@ class CameraDisplay(BokehPlot):
     def overlay_moments(
         self, hillas_parameters, with_label=True, keep_old=False, n_sigma=1, **kwargs
     ):
-        """helper to overlay ellipse from a `HillasParametersContainer` structure
+        """helper to overlay ellipse from a `~ctapipe.containers.HillasParametersContainer` structure
 
         Parameters
         ----------
@@ -527,13 +543,13 @@ class ArrayDisplay(BokehPlot):
     telescopes in the subarray, colored by telescope type, however you can
     also color the telescopes by a value (like trigger pattern, or some other
     scalar per-telescope parameter). To set the color value, simply set the
-    `value` attribute, and the fill color will be updated with the value. You
+    ``value`` attribute, and the fill color will be updated with the value. You
     might want to set the border color to zero to avoid confusion between the
     telescope type color and the value color (
-    `array_disp.telescope.set_linewidth(0)`)
+    ``array_disp.telescope.set_linewidth(0)``)
 
     To display a vector field over the telescope positions, e.g. for
-    reconstruction, call `set_uv()` to set cartesian vectors, or `set_r_phi()`
+    reconstruction, call ``set_uv()`` to set cartesian vectors, or ``set_r_phi()``
     to set polar coordinate vectors.  These both take an array of length
     N_tels, or a single value.
 
diff --git a/src/ctapipe/visualization/mpl_array.py b/src/ctapipe/visualization/mpl_array.py
index 3bc0064f..4f9f7772 100644
--- a/src/ctapipe/visualization/mpl_array.py
+++ b/src/ctapipe/visualization/mpl_array.py
@@ -3,13 +3,10 @@ from itertools import cycle
 import numpy as np
 from astropy import units as u
 from astropy.coordinates import Angle, SkyCoord
-from matplotlib import pyplot as plt
-from matplotlib.collections import PatchCollection
-from matplotlib.lines import Line2D
-from matplotlib.patches import Circle
 
-from ctapipe.coordinates import GroundFrame
-from ctapipe.visualization.mpl_camera import polar_to_cart
+from ..coordinates import GroundFrame
+from ..exceptions import OptionalDependencyMissing
+from .mpl_camera import polar_to_cart
 
 
 class ArrayDisplay:
@@ -59,6 +56,14 @@ class ArrayDisplay:
         radius=None,
         frame=GroundFrame(),
     ):
+        try:
+            import matplotlib.pyplot as plt
+            from matplotlib.collections import PatchCollection
+            from matplotlib.lines import Line2D
+            from matplotlib.patches import Circle
+        except ModuleNotFoundError:
+            raise OptionalDependencyMissing("matplotlib") from None
+
         self.frame = frame
         self.subarray = subarray
         self.axes = axes or plt.gca()
@@ -163,6 +168,8 @@ class ArrayDisplay:
             spacing between rings
 
         """
+        from matplotlib.collections import PatchCollection
+        from matplotlib.patches import Circle
 
         n_circles = np.round(
             (np.sqrt(self.subarray.footprint / np.pi) / spacing).to_value(""),
@@ -373,6 +380,8 @@ class ArrayDisplay:
 
     def _update(self):
         """signal a redraw if necessary"""
+        import matplotlib.pyplot as plt
+
         if self.autoupdate:
             plt.draw()
 
diff --git a/src/ctapipe/visualization/mpl_camera.py b/src/ctapipe/visualization/mpl_camera.py
index 09410373..c4c88faf 100644
--- a/src/ctapipe/visualization/mpl_camera.py
+++ b/src/ctapipe/visualization/mpl_camera.py
@@ -7,12 +7,9 @@ import logging
 
 import numpy as np
 from astropy import units as u
-from matplotlib import pyplot as plt
-from matplotlib.collections import PatchCollection
-from matplotlib.colors import LogNorm, Normalize, SymLogNorm
-from matplotlib.patches import Circle, Ellipse, RegularPolygon
 
 from ..coordinates import get_representation_component_names
+from ..exceptions import OptionalDependencyMissing
 from ..instrument import PixelShape
 from .utils import build_hillas_overlay
 
@@ -100,6 +97,12 @@ class CameraDisplay:
         show_frame=True,
         ax=None,
     ):
+        try:
+            import matplotlib.pyplot as plt
+            from matplotlib.collections import PatchCollection
+        except ModuleNotFoundError:
+            raise OptionalDependencyMissing("matplotlib") from None
+
         self.axes = ax if ax is not None else plt.gca()
         self.pixels = None
         self.colorbar = None
@@ -202,6 +205,8 @@ class CameraDisplay:
 
     @staticmethod
     def _create_hex_patches(pix_x, pix_y, pix_width, pix_rotation):
+        from matplotlib.patches import RegularPolygon
+
         orientation = pix_rotation.to_value(u.rad)
         return [
             RegularPolygon(
@@ -217,6 +222,8 @@ class CameraDisplay:
 
     @staticmethod
     def _create_circle_patches(pix_x, pix_y, pix_width):
+        from matplotlib.patches import Circle
+
         return [
             Circle((x, y), radius=w / 2, fill=True)
             for x, y, w in zip(pix_x, pix_y, pix_width)
@@ -224,6 +231,8 @@ class CameraDisplay:
 
     @staticmethod
     def _create_square_patches(pix_x, pix_y, pix_width, pix_rotation):
+        from matplotlib.patches import RegularPolygon
+
         orientation = (pix_rotation + 45 * u.deg).to_value(u.rad)
         return [
             RegularPolygon(
@@ -275,6 +284,8 @@ class CameraDisplay:
 
     def set_limits_percent(self, percent=95):
         """auto-scale the color range to percent of maximum"""
+        from matplotlib.colors import LogNorm
+
         zmin = np.nanmin(self.pixels.get_array())
         zmax = np.nanmax(self.pixels.get_array())
         if isinstance(self.pixels.norm, LogNorm):
@@ -301,6 +312,8 @@ class CameraDisplay:
 
     @norm.setter
     def norm(self, norm):
+        from matplotlib.colors import LogNorm, Normalize, SymLogNorm
+
         vmin, vmax = self.pixels.norm.vmin, self.pixels.norm.vmax
 
         if norm == "lin":
@@ -414,6 +427,8 @@ class CameraDisplay:
             any MatPlotLib style arguments to pass to the Ellipse patch
 
         """
+        from matplotlib.patches import Ellipse
+
         ellipse = Ellipse(
             xy=centroid,
             width=length,


Test patch:
diff --git a/docs/conftest.py b/docs/conftest.py
index 4c7e9da0..e78f8b38 100644
--- a/docs/conftest.py
+++ b/docs/conftest.py
@@ -2,4 +2,6 @@ collect_ignore = [
     "conf.py",
     "_build",
     "auto_examples",
+    "api-reference/instrument/camerageometry_example.py",
+    "api-reference/coordinates/plot_camera_frames.py",
 ]
diff --git a/src/ctapipe/conftest.py b/src/ctapipe/conftest.py
index c6599dfe..df0bae9e 100644
--- a/src/ctapipe/conftest.py
+++ b/src/ctapipe/conftest.py
@@ -486,6 +486,7 @@ def dl1_muon_output_file(dl1_tmp_path, dl1_muon_file):
     from ctapipe.tools.process import ProcessorTool
 
     output = dl1_tmp_path / "muon_output.dl1.h5"
+    pytest.importorskip("iminuit")
 
     # prevent running process multiple times in case of parallel tests
     with FileLock(output.with_suffix(output.suffix + ".lock")):
diff --git a/src/ctapipe/core/tests/test_provenance.py b/src/ctapipe/core/tests/test_provenance.py
index 465e11ba..2ef0c7cb 100644
--- a/src/ctapipe/core/tests/test_provenance.py
+++ b/src/ctapipe/core/tests/test_provenance.py
@@ -76,3 +76,11 @@ def test_provenance_input_reference_meta(provenance: Provenance, dl1_file):
     assert "reference_meta" in input_meta
     assert "CTA PRODUCT ID" in input_meta["reference_meta"]
     Reference.from_dict(input_meta["reference_meta"])
+
+
+def test_get_distribution_of_module():
+    from ctapipe.core.provenance import get_distribution_of_module
+
+    assert get_distribution_of_module("yaml").name == "PyYAML"
+    assert get_distribution_of_module("sklearn").name == "scikit-learn"
+    assert get_distribution_of_module("ctapipe_test_plugin").version == "0.1.0"
diff --git a/src/ctapipe/image/muon/tests/test_intensity_fit.py b/src/ctapipe/image/muon/tests/test_intensity_fit.py
index 3f255ee3..8972aa08 100644
--- a/src/ctapipe/image/muon/tests/test_intensity_fit.py
+++ b/src/ctapipe/image/muon/tests/test_intensity_fit.py
@@ -27,6 +27,8 @@ def test_muon_efficiency_fit(prod5_lst, reference_location):
     )
     from ctapipe.instrument import SubarrayDescription
 
+    pytest.importorskip("iminuit")
+
     tel_id = 1
     telescope = prod5_lst
     subarray = SubarrayDescription(
@@ -88,6 +90,8 @@ def test_scts(prod5_sst, reference_location):
     from ctapipe.image.muon.intensity_fitter import MuonIntensityFitter
     from ctapipe.instrument import SubarrayDescription
 
+    pytest.importorskip("iminuit")
+
     telescope = prod5_sst
     subarray = SubarrayDescription(
         name="ssts",
diff --git a/src/ctapipe/image/muon/tests/test_processor.py b/src/ctapipe/image/muon/tests/test_processor.py
index e2a7211e..58c1aae2 100644
--- a/src/ctapipe/image/muon/tests/test_processor.py
+++ b/src/ctapipe/image/muon/tests/test_processor.py
@@ -2,11 +2,14 @@
 Tests for MuonProcessor functionality
 """
 import numpy as np
+import pytest
 
 from ctapipe.image import ImageProcessor
 from ctapipe.image.muon import MuonProcessor
 from ctapipe.io import EventSource
 
+pytest.importorskip("iminuit")
+
 
 def test_processor(dl1_muon_file):
     """Test that the MuonProcessor component analyses events or
diff --git a/src/ctapipe/image/muon/tests/test_ring_fitter.py b/src/ctapipe/image/muon/tests/test_ring_fitter.py
index 6886be6e..7d5b3b25 100644
--- a/src/ctapipe/image/muon/tests/test_ring_fitter.py
+++ b/src/ctapipe/image/muon/tests/test_ring_fitter.py
@@ -14,6 +14,8 @@ def test_MuonRingFitter_has_methods():
 @pytest.mark.parametrize("method", MuonRingFitter.fit_method.values)
 def test_MuonRingFitter(method, prod5_mst_flashcam):
     """test MuonRingFitter"""
+    pytest.importorskip("iminuit")
+
     # flashCam example
     center_xs = 0.3 * u.m
     center_ys = 0.6 * u.m
diff --git a/src/ctapipe/instrument/tests/test_subarray.py b/src/ctapipe/instrument/tests/test_subarray.py
index 488cf6fc..3fdc2eab 100644
--- a/src/ctapipe/instrument/tests/test_subarray.py
+++ b/src/ctapipe/instrument/tests/test_subarray.py
@@ -41,7 +41,6 @@ def test_subarray_description(prod5_mst_nectarcam):
     """Test SubarrayDescription functionality"""
     n_tels = 10
     sub = create_subarray(prod5_mst_nectarcam, n_tels)
-    sub.peek()
 
     assert len(sub.telescope_types) == 1
 
@@ -75,6 +74,12 @@ def test_subarray_description(prod5_mst_nectarcam):
     assert sub.telescope_types[0] == sub.tel[1]
 
 
+def test_subarray_peek(prod5_mst_nectarcam):
+    pytest.importorskip("matplotlib")
+    sub = create_subarray(prod5_mst_nectarcam, 10)
+    sub.peek()
+
+
 def test_to_table(example_subarray):
     """Check that we can generate astropy Tables from the SubarrayDescription"""
     sub = example_subarray
diff --git a/src/ctapipe/reco/tests/test_ImPACT.py b/src/ctapipe/reco/tests/test_ImPACT.py
index 35e0fc7c..3aa6f89d 100644
--- a/src/ctapipe/reco/tests/test_ImPACT.py
+++ b/src/ctapipe/reco/tests/test_ImPACT.py
@@ -18,8 +18,7 @@ from ctapipe.reco.impact_utilities import (
 )
 from ctapipe.utils import get_dataset_path
 
-#    CameraHillasParametersContainer,
-#    ReconstructedEnergyContainer,
+pytest.importorskip("iminuit")
 
 SIMTEL_PATH = get_dataset_path(
     "gamma_20deg_0deg_run2___cta-prod5-paranal_desert"
diff --git a/src/ctapipe/reco/tests/test_reconstruction_methods.py b/src/ctapipe/reco/tests/test_reconstruction_methods.py
index 4088ed94..ba80f17b 100644
--- a/src/ctapipe/reco/tests/test_reconstruction_methods.py
+++ b/src/ctapipe/reco/tests/test_reconstruction_methods.py
@@ -8,13 +8,11 @@ from ctapipe.reco import HillasIntersection, HillasReconstructor
 from ctapipe.reco.impact import ImPACTReconstructor
 from ctapipe.utils import get_dataset_path
 
+reconstructors = [HillasIntersection, HillasReconstructor, ImPACTReconstructor]
 
-@pytest.fixture
-def reconstructors():
-    return [HillasIntersection, HillasReconstructor, ImPACTReconstructor]
 
-
-def test_reconstructors(reconstructors):
+@pytest.mark.parametrize("cls", reconstructors)
+def test_reconstructors(cls):
     """
     a test of the complete fit procedure on one event including:
     • tailcut cleaning
@@ -37,17 +35,20 @@ def test_reconstructors(reconstructors):
         calib(event)
         image_processor(event)
 
-        for ReconstructorType in reconstructors:
-            reconstructor = ReconstructorType(
-                subarray, atmosphere_profile=source.atmosphere_density_profile
-            )
-            if ReconstructorType is ImPACTReconstructor:
-                reconstructor.root_dir = str(template_file.parents[0])
-            reconstructor(event)
-
-            name = ReconstructorType.__name__
-            # test the container is actually there and not only created by Map
-            assert name in event.dl2.stereo.geometry
-            assert event.dl2.stereo.geometry[name].alt.unit.is_equivalent(u.deg)
-            assert event.dl2.stereo.geometry[name].az.unit.is_equivalent(u.deg)
-            assert event.dl2.stereo.geometry[name].core_x.unit.is_equivalent(u.m)
+        if cls is ImPACTReconstructor:
+            pytest.importorskip("iminuit")
+
+        reconstructor = cls(
+            subarray, atmosphere_profile=source.atmosphere_density_profile
+        )
+        if cls is ImPACTReconstructor:
+            reconstructor.root_dir = str(template_file.parents[0])
+
+        reconstructor(event)
+
+        name = cls.__name__
+        # test the container is actually there and not only created by Map
+        assert name in event.dl2.stereo.geometry
+        assert event.dl2.stereo.geometry[name].alt.unit.is_equivalent(u.deg)
+        assert event.dl2.stereo.geometry[name].az.unit.is_equivalent(u.deg)
+        assert event.dl2.stereo.geometry[name].core_x.unit.is_equivalent(u.m)
diff --git a/src/ctapipe/tests/test_atmosphere.py b/src/ctapipe/tests/test_atmosphere.py
index 64013336..fd2278c5 100644
--- a/src/ctapipe/tests/test_atmosphere.py
+++ b/src/ctapipe/tests/test_atmosphere.py
@@ -88,6 +88,7 @@ def test_models(density_model):
         density_model.integral(1 * u.km), density_model.integral(1000 * u.m)
     )
 
+    pytest.importorskip("matplotlib")
     with patch("matplotlib.pyplot.show"):
         density_model.peek()
 
diff --git a/src/ctapipe/tests/test_traitlets_configurable.py b/src/ctapipe/tests/test_traitlets_configurable.py
index 74b59b19..696e3dac 100644
--- a/src/ctapipe/tests/test_traitlets_configurable.py
+++ b/src/ctapipe/tests/test_traitlets_configurable.py
@@ -4,6 +4,7 @@ from collections import defaultdict
 
 import ctapipe
 from ctapipe.core import Component, Tool
+from ctapipe.exceptions import OptionalDependencyMissing
 
 ignore_traits = {
     "config",
@@ -44,7 +45,12 @@ def test_all_traitlets_configurable():
             if submodule.startswith("test_") or submodule in skip_modules:
                 continue
 
-            submodule = importlib.import_module(module_name + "." + submodule_info.name)
+            try:
+                submodule = importlib.import_module(
+                    module_name + "." + submodule_info.name
+                )
+            except OptionalDependencyMissing:
+                continue
 
             if submodule_info.ispkg:
                 find_all_traitlets(submodule, missing_config=missing_config)
diff --git a/src/ctapipe/tools/tests/test_process.py b/src/ctapipe/tools/tests/test_process.py
index 17ba5ae8..c9633574 100644
--- a/src/ctapipe/tools/tests/test_process.py
+++ b/src/ctapipe/tools/tests/test_process.py
@@ -412,6 +412,8 @@ def test_read_from_simtel_and_dl1(prod5_proton_simtel_path, tmp_path):
 
 def test_muon_reconstruction_simtel(tmp_path):
     """ensure processor tool generates expected output when used to analyze muons"""
+    pytest.importorskip("iminuit")
+
     muon_simtel_output_file = tmp_path / "muon_reco_on_simtel.h5"
     run_tool(
         ProcessorTool(),
diff --git a/src/ctapipe/tools/tests/test_tools.py b/src/ctapipe/tools/tests/test_tools.py
index 770bd120..acfa823f 100644
--- a/src/ctapipe/tools/tests/test_tools.py
+++ b/src/ctapipe/tools/tests/test_tools.py
@@ -4,10 +4,6 @@ Test individual tool functionality
 import subprocess
 import sys
 
-import matplotlib as mpl
-import matplotlib.pyplot as plt
-
-# pylint: disable=C0103,C0116,C0415
 import pytest
 
 from ctapipe.core import run_tool
@@ -23,6 +19,9 @@ PROD5B_PATH = get_dataset_path(
 def test_display_dl1(tmp_path, dl1_image_file, dl1_parameters_file):
     from ctapipe.tools.display_dl1 import DisplayDL1Calib
 
+    mpl = pytest.importorskip("matplotlib")
+    plt = pytest.importorskip("matplotlib.pyplot")
+
     # close all figures before switching mpl backends
     # fixes a deprecation warning / pending error in newer mpl versions
     plt.close("all")
diff --git a/src/ctapipe/utils/tests/test_rgbtohex.py b/src/ctapipe/utils/tests/test_rgbtohex.py
index 9d7eb1ab..09657bcc 100644
--- a/src/ctapipe/utils/tests/test_rgbtohex.py
+++ b/src/ctapipe/utils/tests/test_rgbtohex.py
@@ -1,7 +1,10 @@
 import numpy as np
+import pytest
 
 from ctapipe.utils.rgbtohex import intensity_to_hex, intensity_to_rgb
 
+pytest.importorskip("matplotlib")
+
 
 def test_rgb():
     input_ = np.array([4])
diff --git a/src/ctapipe/visualization/conftest.py b/src/ctapipe/visualization/conftest.py
new file mode 100644
index 00000000..986314ad
--- /dev/null
+++ b/src/ctapipe/visualization/conftest.py
@@ -0,0 +1,4 @@
+import importlib
+
+if importlib.util.find_spec("bokeh") is None:
+    collect_ignore = ["bokeh.py"]
diff --git a/src/ctapipe/visualization/tests/test_bokeh.py b/src/ctapipe/visualization/tests/test_bokeh.py
index a6ee6906..2279fe06 100644
--- a/src/ctapipe/visualization/tests/test_bokeh.py
+++ b/src/ctapipe/visualization/tests/test_bokeh.py
@@ -1,9 +1,14 @@
 """Tests for the bokeh visualization"""
 import numpy as np
-from bokeh.io import output_file, save
+import pytest
 
 from ctapipe.coordinates import TelescopeFrame
 
+bokeh = pytest.importorskip("bokeh")
+bokeh_io = pytest.importorskip("bokeh.io")
+output_file = bokeh_io.output_file
+save = bokeh_io.save
+
 
 def test_create_display_without_geometry(example_event, example_subarray):
     """Test we can create a display without giving the geometry to init"""
diff --git a/src/ctapipe/visualization/tests/test_mpl.py b/src/ctapipe/visualization/tests/test_mpl.py
index 70776678..99520252 100644
--- a/src/ctapipe/visualization/tests/test_mpl.py
+++ b/src/ctapipe/visualization/tests/test_mpl.py
@@ -8,7 +8,6 @@ import numpy as np
 import pytest
 from astropy import units as u
 from astropy.coordinates import AltAz, SkyCoord
-from matplotlib import __version__ as mpl_version
 
 from ctapipe.calib.camera.calibrator import CameraCalibrator
 from ctapipe.containers import (
@@ -18,6 +17,7 @@ from ctapipe.containers import (
 from ctapipe.coordinates.telescope_frame import TelescopeFrame
 from ctapipe.instrument import PixelShape, SubarrayDescription
 
+mpl = pytest.importorskip("matplotlib")
 plt = pytest.importorskip("matplotlib.pyplot")
 
 
@@ -27,7 +27,7 @@ def prod5_lst_cam(prod5_lst):
 
 
 @pytest.mark.skipif(
-    mpl_version != "3.6.3",
+    mpl.__version__ != "3.6.3",
     reason="See test below (test_camera_display_single)",
 )
 def test_norm_after_colorbar(prod5_lst_cam, tmp_path):
@@ -52,7 +52,7 @@ def test_norm_after_colorbar(prod5_lst_cam, tmp_path):
 
 
 @pytest.mark.skipif(
-    mpl_version == "3.6.3",
+    mpl.__version__ == "3.6.3",
     reason=(
         "There is a problem in changing the norm after adding a colorbar. "
         + "This issue came up in #2207 and "