Coverage for src/jquantstats/_plots/_backend.py: 100%
34 statements
« prev ^ index » next coverage.py v7.16.1, created at 2026-09-23 04:11 +0000
« prev ^ index » next coverage.py v7.16.1, created at 2026-09-23 04:11 +0000
1"""Selection of the rendering backend used by the plotting facades.
3jquantstats renders every chart with `Plotly <https://plotly.com/python/>`_ by
4default. A second, optional `matplotlib <https://matplotlib.org/>`_ backend
5produces static figures instead, which is markedly cheaper when a script builds
6many charts at once.
8Three levels of selection compose, most specific first:
101. a per-call ``backend=`` argument on any plot method,
112. a `plot_backend` context manager, scoped to the current thread or task,
123. `set_plot_backend`, the process-wide default (``"plotly"`` when never set).
14Examples:
15 >>> from jquantstats import get_plot_backend, plot_backend, set_plot_backend
16 >>> get_plot_backend()
17 'plotly'
18 >>> set_plot_backend("matplotlib")
19 >>> get_plot_backend()
20 'matplotlib'
21 >>> with plot_backend("plotly"):
22 ... get_plot_backend()
23 'plotly'
24 >>> get_plot_backend()
25 'matplotlib'
26 >>> set_plot_backend("plotly")
27"""
29from __future__ import annotations
31import contextvars
32from collections.abc import Iterator
33from contextlib import contextmanager
34from importlib.util import find_spec
35from typing import Literal
37from jquantstats.exceptions import MissingBackendError, UnknownPlotBackendError
39__all__ = [
40 "Backend",
41 "get_plot_backend",
42 "plot_backend",
43 "require_backend",
44 "resolve",
45 "set_plot_backend",
46]
48Backend = Literal["matplotlib", "plotly"]
50#: Accepted backend names, in the order error messages list them.
51SUPPORTED: tuple[Backend, ...] = ("matplotlib", "plotly")
53# The packaging extra that installs each *optional* backend's rendering library.
54# Plotly is deliberately absent: it is a core dependency, so it is always importable
55# and there is no extra to point anyone at. (The `plot` extra is kaleido — static image
56# export *for* the plotly figures — which is a different thing entirely.)
57_OPTIONAL_EXTRAS: dict[Backend, str] = {"matplotlib": "mpl"}
59# The process-wide default. A plain module global on purpose: "last writer wins,
60# visible from every thread" is the semantic a global setter should have, and a
61# single reference store is atomic under both the GIL and free-threaded CPython.
62_default: Backend = "plotly"
64# The scoped override. A ContextVar rather than threading.local because it is the
65# only primitive that scopes correctly across both threads and asyncio tasks.
66_override: contextvars.ContextVar[Backend | None] = contextvars.ContextVar(
67 "jquantstats_plot_backend",
68 default=None,
69)
71# Bound at module scope on purpose: the tests simulate an absent library by
72# replacing *this* name, so they never poison ``sys.modules``. A ``None`` sentinel
73# left in ``sys.modules`` survives a failed test and leaks across the many tests
74# an xdist worker runs in one interpreter; rebinding an attribute cannot.
75_find_spec = find_spec
78def _validate(backend: str) -> Backend:
79 """Narrow *backend* to `Backend`, rejecting anything unsupported.
81 Args:
82 backend: The candidate backend name.
84 Returns:
85 Backend: The same name, narrowed for the type checker.
87 Raises:
88 UnknownPlotBackendError: If *backend* is not a supported name.
90 """
91 if backend not in SUPPORTED:
92 raise UnknownPlotBackendError(backend, list(SUPPORTED))
93 return backend
96def set_plot_backend(backend: Backend) -> None:
97 """Set the process-wide default plotting backend.
99 Args:
100 backend: Either ``"plotly"`` (the default) or ``"matplotlib"``.
102 Raises:
103 UnknownPlotBackendError: If *backend* is not a supported name.
105 Examples:
106 >>> from jquantstats import get_plot_backend, set_plot_backend
107 >>> set_plot_backend("matplotlib")
108 >>> get_plot_backend()
109 'matplotlib'
110 >>> set_plot_backend("plotly")
112 """
113 global _default
114 _default = _validate(backend)
117def get_plot_backend() -> Backend:
118 """Return the backend currently in effect.
120 Returns:
121 Backend: The scoped override if one is active, else the process-wide
122 default.
124 Examples:
125 >>> from jquantstats import get_plot_backend
126 >>> get_plot_backend()
127 'plotly'
129 """
130 return _override.get() or _default
133@contextmanager
134def plot_backend(backend: Backend) -> Iterator[None]:
135 """Select *backend* for the duration of the ``with`` block.
137 Scoped to the current thread or asyncio task, and restored even if the body
138 raises. This is the only exception-safe way to use both backends in one
139 process; pairing `set_plot_backend` calls by hand leaks the override when
140 the code between them raises.
142 Args:
143 backend: Either ``"plotly"`` or ``"matplotlib"``.
145 Yields:
146 None: Control returns to the ``with`` body.
148 Raises:
149 UnknownPlotBackendError: If *backend* is not a supported name.
151 Examples:
152 >>> from jquantstats import get_plot_backend, plot_backend
153 >>> with plot_backend("matplotlib"):
154 ... get_plot_backend()
155 'matplotlib'
156 >>> get_plot_backend()
157 'plotly'
159 """
160 token = _override.set(_validate(backend))
161 try:
162 yield
163 finally:
164 _override.reset(token)
167def resolve(backend: Backend | None) -> Backend:
168 """Resolve an explicit per-call *backend* against the ambient selection.
170 Args:
171 backend: An explicit choice, or ``None`` to defer to the context
172 manager and then the process-wide default.
174 Returns:
175 Backend: The backend to render with.
177 Raises:
178 UnknownPlotBackendError: If *backend* is not a supported name.
180 Examples:
181 >>> from jquantstats._plots._backend import resolve
182 >>> resolve("matplotlib")
183 'matplotlib'
184 >>> resolve(None)
185 'plotly'
187 """
188 return _validate(backend) if backend is not None else get_plot_backend()
191def require_backend(backend: Backend) -> None:
192 """Raise unless *backend*'s rendering library is importable.
194 Only the optional backends are checked; plotly is a core dependency, so its
195 absence means a broken install rather than a missing extra and there is no
196 useful hint to give.
198 Availability is probed with `importlib.util.find_spec` rather than a
199 ``try``/``except ImportError`` around the import itself: a spec lookup is an
200 ordinary condition whose arms are both reachable under the branch-coverage
201 gate, whereas forcing a real ``ImportError`` would mean poisoning
202 ``sys.modules`` — which leaks past a failed test and is a no-op once the
203 module has already been imported.
205 Args:
206 backend: The backend about to be used.
208 Raises:
209 MissingBackendError: If *backend* is optional and not installed.
211 Examples:
212 >>> from jquantstats._plots._backend import require_backend
213 >>> require_backend("plotly")
214 >>> require_backend("matplotlib")
216 """
217 extra = _OPTIONAL_EXTRAS.get(backend)
218 if extra is not None and _find_spec(backend) is None:
219 raise MissingBackendError(backend, extra)