diff --git a/extensions/positron-python/.gitignore b/extensions/positron-python/.gitignore index cf75d9cbe29c..bd4f92b77031 100644 --- a/extensions/positron-python/.gitignore +++ b/extensions/positron-python/.gitignore @@ -51,7 +51,9 @@ dist/** l10n/ tags # --- Start Positron --- +# The old location, kept so clones that ran the pre-split suite stay clean. python_files/posit/positron/tests/images +python_files/posit/positron/tests/test_matplotlib_backend/images python_files/posit/positron/_vendor/** scripts/*.js resources/pet/** diff --git a/extensions/positron-python/python_files/posit/positron/execute_request.py b/extensions/positron-python/python_files/posit/positron/execute_request.py index cdbf6fb93e2d..d458fda931ae 100644 --- a/extensions/positron-python/python_files/posit/positron/execute_request.py +++ b/extensions/positron-python/python_files/posit/positron/execute_request.py @@ -4,7 +4,7 @@ # import logging -from typing import Optional, Union +from typing import Optional, Union, cast from ._vendor.pydantic import ( BaseModel, @@ -58,13 +58,27 @@ class PositronExecuteRequest(BaseModel): None, description="Output area device pixel ratio, e.g. 1.0 or 2.0" ) + @property + def figure_size(self) -> Optional[tuple[Optional[float], Optional[float]]]: + """The figure size in inches, if specified. + + Either dimension may be set alone; a missing or non-positive dimension is + None, for the caller to fill from its own default. None when neither + dimension is specified. + """ + w = self.fig_width if self.fig_width is not None and self.fig_width > 0 else None + h = self.fig_height if self.fig_height is not None and self.fig_height > 0 else None + if w is None and h is None: + return None + return (w, h) + @classmethod def from_message(cls, message: dict) -> "PositronExecuteRequest": """Parse from a Jupyter shell message.""" content = message.get("content", {}) positron = content.get("positron", {}) if isinstance(content, dict) else {} if not isinstance(positron, dict): - return cls() + return cls.parse_obj({}) try: return cls.parse_obj(positron) @@ -79,4 +93,17 @@ def from_message(cls, message: dict) -> "PositronExecuteRequest": return cls.parse_obj(cleaned) except ValidationError: logger.debug("Failed to parse positron execute request", exc_info=True) - return cls() + return cls.parse_obj({}) + + +def current_execute_request() -> PositronExecuteRequest: + """The Positron `execute_request` currently being handled, if any.""" + # No contextvar of our own needed here: ipykernel's `get_parent("shell")` is + # already backed by a `ContextVar` (see `_shell_parent` in kernelbase.py), so + # this is async/thread-safe as-is. + # Imported lazily to avoid a circular import. + from .positron_ipkernel import PositronIPyKernel + + kernel = cast("PositronIPyKernel", PositronIPyKernel.instance()) + execute_request_message = kernel.get_parent("shell") + return PositronExecuteRequest.from_message(execute_request_message) diff --git a/extensions/positron-python/python_files/posit/positron/matplotlib_backend/__init__.py b/extensions/positron-python/python_files/posit/positron/matplotlib_backend/__init__.py new file mode 100644 index 000000000000..6068b00166e4 --- /dev/null +++ b/extensions/positron-python/python_files/posit/positron/matplotlib_backend/__init__.py @@ -0,0 +1,19 @@ +# +# Copyright (C) 2026 Posit Software, PBC. All rights reserved. +# Licensed under the Elastic License 2.0. See LICENSE.txt for license information. +# +""" +Positron's matplotlib backends. + +Positron ships one backend per session mode, each registered under its own short name +(`positron-console`, `positron-notebook`) via the `matplotlib.backend` entry point in +`python_files/posit/positron_matplotlib-.dist-info`. `backend.py` holds the +names they share, `registry.py` the switch-time lifecycle called by +`PositronShell.enable_matplotlib`, and `compat.py` the shims for matplotlib and IPython +versions without backend-registry support. + +Keep `backend.py`, `compat.py` and `registry.py` free of matplotlib imports, and this +module free of code entirely. The kernel imports them, and must work in environments +without matplotlib installed; `console.py`, `notebook.py` and `formats.py` are imported +lazily, and only once they're actually needed. +""" diff --git a/extensions/positron-python/python_files/posit/positron/matplotlib_backend/backend.py b/extensions/positron-python/python_files/posit/positron/matplotlib_backend/backend.py new file mode 100644 index 000000000000..a633e544b71e --- /dev/null +++ b/extensions/positron-python/python_files/posit/positron/matplotlib_backend/backend.py @@ -0,0 +1,123 @@ +# +# Copyright (C) 2026 Posit Software, PBC. All rights reserved. +# Licensed under the Elastic License 2.0. See LICENSE.txt for license information. +# +""" +The names of Positron's matplotlib backends, and the interface their modules implement. + +Keep this module free of matplotlib imports; see the package docstring. +""" + +from __future__ import annotations + +import importlib +from enum import Enum +from typing import TYPE_CHECKING, Protocol, cast + +from ..session_mode import SessionMode +from .compat import get_backend_registry + +if TYPE_CHECKING: + from IPython.core.interactiveshell import InteractiveShell + +_MODULE_PREFIX = "module://" + + +class Backend(Enum): + """ + One of Positron's matplotlib backends. + + Both names select the same backend: matplotlib resolves the short name through its + backend registry (matplotlib >= 3.9), while the `module://` spelling works on any + version. + + Attributes: + module_name: The importable path of the backend's module. + short_name: The name the backend is registered under via the `matplotlib.backend` + entry point. + """ + + CONSOLE = ("positron.matplotlib_backend.console", "positron-console") + NOTEBOOK = ("positron.matplotlib_backend.notebook", "positron-notebook") + + def __init__(self, module_name: str, short_name: str) -> None: + self.module_name = module_name + self.short_name = short_name + + @classmethod + def for_session_mode(cls, session_mode: SessionMode) -> Backend: + """The backend for a session started in `session_mode`.""" + # BACKGROUND sessions get the console backend: they have no notebook to render + # figures into, and the console backend routes them to the plots pane. + return cls.NOTEBOOK if session_mode == SessionMode.NOTEBOOK else cls.CONSOLE + + @classmethod + def from_name(cls, backend: str) -> Backend | None: + """ + The Positron backend that `backend` selects, or None if `backend` isn't ours. + + Matches either spelling (short name or `module://` name). That matters at a + backend module's self-activation time (see the trailer at the bottom of + `console.py`/`notebook.py`): when matplotlib switches to a short name, + `matplotlib.get_backend()` at module-import time can still report the short + name rather than the `module://` spelling it eventually settles into. + """ + # Short names are case-insensitive in matplotlib's registry; `module://` names + # aren't, since everything after the prefix is an importable module path. + normalized = backend if backend.startswith(_MODULE_PREFIX) else backend.lower() + for candidate in cls: + if normalized in (candidate.full_name, candidate.short_name): + return candidate + return None + + @property + def full_name(self) -> str: + """Matplotlib's `module://` spelling of `module_name`; works on any matplotlib version.""" + return _MODULE_PREFIX + self.module_name + + @property + def preferred_name(self) -> str: + """ + The name to select this backend with. + + Prefers the short name (`positron-console` or `positron-notebook`): it's what + `%matplotlib` prints, and users can round-trip it into + `%matplotlib positron-console` or `matplotlib.use("positron-console")`. Falls + back to `full_name` when the entry point isn't discoverable, which is always + the case before matplotlib 3.9 since it has no backend registry. + """ + registry = get_backend_registry() + # `list_all` also loads the entry points, which `switch_backend` relies on having + # been loaded to resolve the short name to a module. Loading them doesn't import + # any backend module: matplotlib only reads each entry point's name and value + # strings (see `backends/registry.py::_read_entry_points`), so this stays cheap + # and can't trigger a premature self-activation. + if registry and self.short_name in registry.list_all(): + return self.short_name + return self.full_name + + def import_module(self) -> BackendModule: + """Import this Positron matplotlib backend module.""" + return cast("BackendModule", importlib.import_module(self.module_name)) + + +class BackendModule(Protocol): + """Interface for Positron matplotlib backend modules.""" + + def install(self, shell: InteractiveShell) -> None: + """ + Install this backend's shell hooks. + + Never called twice without an intervening `uninstall`, so implementations hold + no activation state of their own and needn't be idempotent. + """ + ... + + def uninstall(self, shell: InteractiveShell) -> None: + """ + Remove the shell hooks installed by `install`. + + Only ever called after a matching `install`, and before another backend's + `install`. + """ + ... diff --git a/extensions/positron-python/python_files/posit/positron/matplotlib_backend/compat.py b/extensions/positron-python/python_files/posit/positron/matplotlib_backend/compat.py new file mode 100644 index 000000000000..785fef42f11a --- /dev/null +++ b/extensions/positron-python/python_files/posit/positron/matplotlib_backend/compat.py @@ -0,0 +1,47 @@ +# +# Copyright (C) 2026 Posit Software, PBC. All rights reserved. +# Licensed under the Elastic License 2.0. See LICENSE.txt for license information. +# +""" +Shims for the matplotlib and IPython versions that predate backend-registry support. + +Keep this module free of matplotlib imports; see the package docstring. +""" + +from __future__ import annotations + + +def get_backend_registry(): + """The matplotlib backend registry, or None if matplotlib < 3.9.""" + try: + from matplotlib.backends.registry import backend_registry + + return backend_registry + except ImportError: + # No backend registry before matplotlib 3.9. + return None + + +def register_with_legacy_ipython() -> None: + """ + Make Positron's short names visible to IPython versions without registry support. + + IPython 8.24 started resolving `%matplotlib ` and building `%matplotlib -l` + from matplotlib's backend registry (matplotlib >= 3.9), which discovers Positron's + backends through their entry points. Earlier IPython reads the static + `pylabtools.backends` table for both, so add the short names there. Uses the + `module://` spelling as the value so the entries resolve on any matplotlib version. + """ + from IPython.core import pylabtools as pt + + # Imported here rather than at module scope: `backend.py` imports this module for + # `get_backend_registry`, so a module-level import would be circular. + from .backend import Backend + + # The registry-based lister arrived in the same release that stopped reading the + # static table, so its presence tells the two resolution schemes apart. Touching + # `pt.backends` on newer IPython would also trip its deprecation warning. + if hasattr(pt, "_list_matplotlib_backends_and_gui_loops"): + return + for backend in Backend: + pt.backends.setdefault(backend.short_name, backend.full_name) diff --git a/extensions/positron-python/python_files/posit/positron/matplotlib_backend.py b/extensions/positron-python/python_files/posit/positron/matplotlib_backend/console.py similarity index 75% rename from extensions/positron-python/python_files/posit/positron/matplotlib_backend.py rename to extensions/positron-python/python_files/posit/positron/matplotlib_backend/console.py index 3a11a7c33a31..57773db740b2 100644 --- a/extensions/positron-python/python_files/posit/positron/matplotlib_backend.py +++ b/extensions/positron-python/python_files/posit/positron/matplotlib_backend/console.py @@ -1,9 +1,9 @@ # -# Copyright (C) 2024 Posit Software, PBC. All rights reserved. +# Copyright (C) 2024-2026 Posit Software, PBC. All rights reserved. # Licensed under the Elastic License 2.0. See LICENSE.txt for license information. # """ -The matplotlib backend for Positron. +Positron's matplotlib backend for console sessions. NOTE: DO NOT DIRECTLY IMPORT THIS MODULE! @@ -16,6 +16,7 @@ from __future__ import annotations +import contextlib import hashlib import inspect import io @@ -27,21 +28,19 @@ from matplotlib.backend_bases import FigureManagerBase from matplotlib.backends.backend_agg import FigureCanvasAgg -from .execute_request import PositronExecuteRequest +from ..execute_request import PositronExecuteRequest +from .backend import Backend +from .registry import registry if TYPE_CHECKING: + from IPython.core.interactiveshell import InteractiveShell from matplotlib.figure import Figure - from .plot_comm import PlotSize + from ..plot_comm import PlotSize logger = logging.getLogger(__name__) -# Enable interactive mode (i.e. redraw after every plotting command). -# This is expected to run when the backend is selected. See the note at the top of the file. -matplotlib.interactive(True) # noqa: FBT003 - - # High-level libraries that build on matplotlib. A figure produced by one of these is # attributed to that library (used for the plot's display name and, for seaborn, # detached after each cell; see `detach_library_figures`). @@ -80,7 +79,7 @@ def _detect_plotting_library() -> str: return "matplotlib" -def detach_library_figures() -> None: +def _detach_library_figures() -> None: """ Detach high-level library figures (e.g. seaborn) from matplotlib's global registry. @@ -95,17 +94,20 @@ def detach_library_figures() -> None: Plain matplotlib figures are intentionally left in the registry to preserve Positron's cross-cell figure persistence (updating or re-showing a plot across cells). """ - from matplotlib._pylab_helpers import Gcf + try: + from matplotlib._pylab_helpers import Gcf - for manager in list(Gcf.get_all_fig_managers()): - if ( - isinstance(manager, FigureManagerPositron) - and manager.plotting_library in _DETACH_AFTER_CELL_KINDS - ): - # Removes the manager from the registry and invokes our no-op `destroy`. - # Pass the manager rather than its number: numbers freed here can be reused - # by matplotlib, so destroying by number could hit a different figure. - Gcf.destroy(manager) + for manager in list(Gcf.get_all_fig_managers()): + if ( + isinstance(manager, FigureManagerPositron) + and manager.plotting_library in _DETACH_AFTER_CELL_KINDS + ): + # Removes the manager from the registry and invokes our no-op `destroy`. + # Pass the manager rather than its number: numbers freed here can be reused + # by matplotlib, so destroying by number could hit a different figure. + Gcf.destroy(manager) + except Exception: + logger.exception("Error detaching high-level library figures") class FigureManagerPositron(FigureManagerBase): @@ -125,11 +127,11 @@ class FigureManagerPositron(FigureManagerBase): The canvas for this figure. """ - canvas: FigureCanvasPositron + canvas: FigureCanvasPositron # type: ignore def __init__(self, canvas: FigureCanvasPositron, num: int | str): - from .plot_comm import PlotOrigin, PlotRange - from .positron_ipkernel import PositronIPyKernel + from ..plot_comm import PlotOrigin, PlotRange + from ..positron_ipkernel import PositronIPyKernel super().__init__(canvas, num) @@ -234,7 +236,7 @@ class FigureCanvasPositron(FigureCanvasAgg): The manager for this canvas. """ - manager: FigureManagerPositron + manager: FigureManagerPositron # type: ignore manager_class = FigureManagerPositron # type: ignore @@ -266,28 +268,21 @@ def draw(self, *, is_rendering=False) -> None: finally: # Do nothing if the canvas has not been rendered yet, to avoid an unnecessary update # since opening the comm will trigger a render from the frontend. - if not self._first_render_completed: - return # noqa: B012 - - # Do nothing if the canvas is currently being rendered, to avoid an infinite draw-render loop. - if is_rendering: - return # noqa: B012 - + if not self._first_render_completed or is_rendering: + pass # If the plot was closed after being opened, request an update to re-open the plot. - if self.manager.closed: + elif self.manager.closed: self.manager.update() - return # noqa: B012 - - # Check if the canvas contents have changed, and request an update if they have. - current_hash = self._hash_buffer_rgba() - logger.debug(f"Canvas: previous hash: {self._previous_hash[:6]}") - logger.debug(f"Canvas: current hash: {current_hash[:6]}") - if current_hash == self._previous_hash: - logger.debug("Canvas: hash is the same, no need to update") - return # noqa: B012 - - logger.debug("Canvas: hash changed, requesting an update") - self.manager.update() + else: + # Check if the canvas contents have changed, and request an update if they have. + current_hash = self._hash_buffer_rgba() + logger.debug(f"Canvas: previous hash: {self._previous_hash[:6]}") + logger.debug(f"Canvas: current hash: {current_hash[:6]}") + if current_hash == self._previous_hash: + logger.debug("Canvas: hash is the same, no need to update") + else: + logger.debug("Canvas: hash changed, requesting an update") + self.manager.update() def render(self, size: PlotSize | None, pixel_ratio: float, format_: str) -> bytes: # Set the device pixel ratio to the requested value. @@ -342,7 +337,9 @@ def _hash_buffer_rgba(self) -> str: return hashlib.sha1(self.buffer_rgba()).hexdigest() -_library_gca_redirect_installed = False +# The original and installed `plt.gca`, so `uninstall` can undo our changes. +_original_gca = None +_installed_gca = None def _install_library_gca_redirect() -> None: @@ -362,14 +359,14 @@ def _install_library_gca_redirect() -> None: and only fires across libraries, so plain matplotlib figures remain reusable (preserving cross-cell persistence). See https://github.com/posit-dev/positron/issues/8898. """ - global _library_gca_redirect_installed - if _library_gca_redirect_installed: + global _original_gca, _installed_gca + if _installed_gca is not None: return import matplotlib.pyplot as plt from matplotlib._pylab_helpers import Gcf - original_gca = plt.gca + _original_gca = plt.gca def gca(*args, **kwargs): manager = Gcf.get_active() @@ -381,15 +378,59 @@ def gca(*args, **kwargs): # Drawing library differs from the active figure's library: start fresh so # the existing figure isn't overwritten. return plt.figure().gca() - return original_gca(*args, **kwargs) + assert _original_gca is not None + return _original_gca(*args, **kwargs) + + plt.gca = _installed_gca = gca - plt.gca = gca - _library_gca_redirect_installed = True +def _uninstall_library_gca_redirect() -> None: + """Restore the original `plt.gca`, undoing `_install_library_gca_redirect`.""" + global _original_gca, _installed_gca + if _installed_gca is None: + return + + import matplotlib.pyplot as plt -_install_library_gca_redirect() + # Only restore if our redirect is still the installed `gca`; something else may have + # patched over it, in which case we leave that patch alone. + if plt.gca is _installed_gca: + plt.gca = _original_gca + + _original_gca = None + _installed_gca = None # Fulfill the matplotlib backend API. FigureCanvas = FigureCanvasPositron FigureManager = FigureManagerPositron + + +def install(shell: InteractiveShell) -> None: + """Install the console backend's shell hooks. See `BackendModule`.""" + # Detach high-level plotting library figures (e.g. seaborn) so that re-running + # plotting code starts a fresh figure instead of drawing onto the previous one. + # See https://github.com/posit-dev/positron/issues/8898. + shell.events.register("post_execute", _detach_library_figures) + + _install_library_gca_redirect() + + +def uninstall(shell: InteractiveShell) -> None: + """Remove the console backend's shell hooks. See `BackendModule`.""" + # Suppress ValueError in case user code unregistered the hook itself; a backend + # switch shouldn't crash over it. + with contextlib.suppress(ValueError): + shell.events.unregister("post_execute", _detach_library_figures) + + _uninstall_library_gca_redirect() + + +# If we are the selected backend, activate through the registry, which also tears +# down the other backend if it was active. This runs when matplotlib imports the +# module to switch to it. See the note at the top of the file. `Backend.from_name` +# matches both spellings, which matters here: matplotlib can still report our short +# name via `get_backend()` at this point, before it settles into the `module://` +# spelling. +if Backend.from_name(matplotlib.get_backend()) is Backend.CONSOLE: + registry.activate(Backend.CONSOLE) diff --git a/extensions/positron-python/python_files/posit/positron/matplotlib_backend/formats.py b/extensions/positron-python/python_files/posit/positron/matplotlib_backend/formats.py new file mode 100644 index 000000000000..f05c111cbc9c --- /dev/null +++ b/extensions/positron-python/python_files/posit/positron/matplotlib_backend/formats.py @@ -0,0 +1,230 @@ +# +# Copyright (C) 2026 Posit Software, PBC. All rights reserved. +# Licensed under the Elastic License 2.0. See LICENSE.txt for license information. +# +""" +Positron's support for `matplotlib_inline.backend_inline.set_matplotlib_formats`. + +That function (and its sibling `select_figure_formats`) operate entirely inside +matplotlib-inline/IPython, with no Positron code on the stack, so the only way to +intercept them is to patch `set_matplotlib_formats` itself. The patch is installed +by `PositronBackendRegistry.activate` whenever a Positron backend (console or notebook) +is active, and removed once none is -- see `install_set_matplotlib_formats_patch` for +why a shared, call-time dispatch is needed instead of one patch per backend. + +NOTE: only ever imported from `PositronBackendRegistry.activate`, by which point +matplotlib is guaranteed to be importable. +""" + +from __future__ import annotations + +import contextlib +import logging +from binascii import b2a_base64 +from functools import partial +from typing import TYPE_CHECKING, Callable, cast + +import matplotlib + +# Same private helpers IPython's own retina path uses for identical metadata; +# stable for 10+ years. +from IPython.core.display import _jpegxy, _pngxy +from IPython.core.getipython import get_ipython +from IPython.core.pylabtools import print_figure +from matplotlib.figure import Figure + +from .backend import Backend + +if TYPE_CHECKING: + from IPython.core.interactiveshell import InteractiveShell + +logger = logging.getLogger(__name__) + +# Formats accepted by `set_matplotlib_formats`, matching IPython's +# `select_figure_formats`. `retina`/`png2x` alias to `png`: Positron already renders at +# the frontend's device pixel ratio, so pixel-doubling is subsumed by that instead of +# IPython's dpi-doubling. `jpg` aliases to `jpeg`, the name Positron's formatter/mime +# tables use. +_FORMAT_ALIASES = {"retina": "png", "png2x": "png", "jpg": "jpeg"} +_MIME_BY_FORMAT = { + "png": "image/png", + "jpeg": "image/jpeg", + "svg": "image/svg+xml", + "pdf": "application/pdf", +} +# The formats IPython's `select_figure_formats` accepts; anything else is a `ValueError`. +_SUPPORTED_FORMATS = frozenset({"png", "retina", "png2x", "jpg", "jpeg", "svg", "pdf"}) + +# The pixel-size reader for each raster format's metadata. svg scales natively and +# pdf isn't rendered inline, so neither needs a pixel-size-derived `width`/`height`. +_PIXEL_SIZE_BY_FORMAT = {"png": _pngxy, "jpeg": _jpegxy} + +# The formats currently selected; applied on the notebook backend's `install()` so the +# user's choice survives a round trip through a non-Positron backend (e.g. +# `%matplotlib qt` then back to `%matplotlib inline`). +_selected_formats: set[str] = {"png"} + + +def _formatters(shell: InteractiveShell): + """The shell's mime-to-formatter mapping, asserting the display formatter exists.""" + assert shell.display_formatter is not None + return shell.display_formatter.formatters + + +def _display_figure( + fig: Figure, *, format_="png", **print_figure_kwargs +) -> tuple[str, dict] | None: + """ + Render a figure to its Jupyter display wire format. + + Rendering is delegated to `IPython.core.pylabtools.print_figure` -- the function + IPython's own inline formatters wrap -- so output matches other Jupyter frontends + (empty-figure suppression, kwarg precedence, facecolor handling, dpi). On top of + that, Positron attaches the intended logical size as metadata when rendering at a + device pixel ratio other than 1. + """ + # `bbox_inches="tight"` is `print_figure`'s own default; deliberately not passed + # here so a user-supplied `bbox_inches` in `print_figure_kwargs` can't collide + # with a duplicate keyword. + data = print_figure(fig, fmt=format_, **print_figure_kwargs) + if data is None: + # Empty figure: display nothing, mirroring IPython. + return None + + # svg comes back as an already-decoded string and scales natively, so it carries + # no size metadata; everything else is raw bytes to base64-encode. + if format_ == "svg": + # `print_figure` returns `str` only for svg; narrowed by the `format_` check above. + return cast("str", data), {} + # Same encoding `print_figure(base64=True)` would have applied, done here instead so + # the raw bytes stay available for `_pngxy`/`_jpegxy` below. + decoded = b2a_base64(cast("bytes", data), newline=False).decode("ascii") + + metadata = {} + pixel_size = _PIXEL_SIZE_BY_FORMAT.get(format_) + if pixel_size is not None and (ratio := fig.canvas.device_pixel_ratio) != 1: + w, h = pixel_size(cast("bytes", data)) + metadata["width"] = int(w) / ratio + metadata["height"] = int(h) / ratio + + return decoded, metadata + + +def select_figure_formats(shell: InteractiveShell, formats, **print_figure_kwargs) -> None: + """ + Positron's mirror of `IPython.core.pylabtools.select_figure_formats`. + + Registers Positron's `_display_figure` (as a `partial` binding `format_` and any + `print_figure_kwargs`) on the display formatter for each requested format, so + figures keep going through Positron's DPR-aware rendering no matter which format + the user asked for. + + Unlike upstream, unsupported formats are validated before any formatter is + touched, so a typo (e.g. `'bmp'`) leaves a previously working selection in place + instead of clobbering it. + """ + if isinstance(formats, str): + formats = {formats} + formats = set(formats) + + unsupported = formats - _SUPPORTED_FORMATS + if unsupported: + supported_str = ",".join(repr(f) for f in sorted(_SUPPORTED_FORMATS)) + unsupported_str = ",".join(repr(f) for f in sorted(unsupported)) + raise ValueError(f"supported formats are: {supported_str} not {unsupported_str}") + + # Pop `Figure` from every display formatter, mirroring upstream -- this also + # cleans up leftovers if IPython's own formatters somehow got installed (e.g. + # before this patch was in place). + for formatter in _formatters(shell).values(): + formatter.pop(Figure, None) + + for requested in formats: + normalized = _FORMAT_ALIASES.get(requested, requested) + mime = _MIME_BY_FORMAT[normalized] + callable_ = partial(_display_figure, format_=normalized, **print_figure_kwargs) + _formatters(shell)[mime].for_type(Figure, callable_) + + _selected_formats.clear() + _selected_formats.update(formats) + + +def apply_selected_formats(shell: InteractiveShell) -> None: + """Register the currently selected formats. Called by the notebook backend's `install`.""" + select_figure_formats(shell, _selected_formats) + + +def pop_registered_formatters(shell: InteractiveShell) -> None: + """Unregister the formatters `select_figure_formats` registered. Called by `uninstall`.""" + for formatter in _formatters(shell).values(): + # `lookup_by_type` raises KeyError if there's no formatter for the type. + with contextlib.suppress(KeyError): + current = formatter.lookup_by_type(Figure) + if isinstance(current, partial) and current.func is _display_figure: + # It's still ours (a `partial` binding `_display_figure`), remove it. + formatter.pop(Figure) + + +# The original and installed `set_matplotlib_formats`, so `uninstall_...` can undo +# `install_...`. +_original_set_matplotlib_formats: Callable | None = None +_installed_set_matplotlib_formats: Callable | None = None + + +def install_set_matplotlib_formats_patch() -> None: + """ + Patch `matplotlib_inline.backend_inline.set_matplotlib_formats`. Safe to call repeatedly. + + Installed by `PositronBackendRegistry.activate` whenever a Positron backend is + active, and dispatches at call time on `matplotlib.get_backend()`, so it stays + correct however the active backend changes between install and call time. + Dispatching on + the live backend string rather than the registry's state is deliberate: a user can + switch backends with a bare `matplotlib.use(...)` that never goes through the + registry. + """ + global _original_set_matplotlib_formats, _installed_set_matplotlib_formats + if _installed_set_matplotlib_formats is not None: + return + + import matplotlib_inline.backend_inline as backend_inline + + original = backend_inline.set_matplotlib_formats + _original_set_matplotlib_formats = original + + def set_matplotlib_formats(*formats, **kwargs) -> None: + shell = get_ipython() + target = Backend.from_name(matplotlib.get_backend()) + if shell is not None and target is Backend.NOTEBOOK: + select_figure_formats(shell, formats, **kwargs) + elif target is Backend.CONSOLE: + # The Plots pane negotiates its own format via `canvas.render(format_=...)`; + # running `select_figure_formats` here would emit stray inline outputs + # alongside it. Silent no-op, matching the deliberate + # `%config InlineBackend.*` no-op precedent (see test_enable_matplotlib.py). + logger.debug("set_matplotlib_formats is a no-op in Positron's console backend") + else: + # A non-Positron backend is active (e.g. the user called + # `matplotlib.use('agg')` directly); defer to the original so its + # behavior is unaffected. + original(*formats, **kwargs) + + backend_inline.set_matplotlib_formats = set_matplotlib_formats + _installed_set_matplotlib_formats = set_matplotlib_formats + + +def uninstall_set_matplotlib_formats_patch() -> None: + """Restore the original `set_matplotlib_formats`. Safe to call repeatedly.""" + global _original_set_matplotlib_formats, _installed_set_matplotlib_formats + if _installed_set_matplotlib_formats is None: + return + + import matplotlib_inline.backend_inline as backend_inline + + # Only restore if our patch is still installed; something else may have patched + # over it since, in which case we leave that patch alone. + if backend_inline.set_matplotlib_formats is _installed_set_matplotlib_formats: + backend_inline.set_matplotlib_formats = _original_set_matplotlib_formats + + _original_set_matplotlib_formats = None + _installed_set_matplotlib_formats = None diff --git a/extensions/positron-python/python_files/posit/positron/matplotlib_backend/notebook.py b/extensions/positron-python/python_files/posit/positron/matplotlib_backend/notebook.py new file mode 100644 index 000000000000..8c6e5ec93581 --- /dev/null +++ b/extensions/positron-python/python_files/posit/positron/matplotlib_backend/notebook.py @@ -0,0 +1,202 @@ +# +# Copyright (C) 2026 Posit Software, PBC. All rights reserved. +# Licensed under the Elastic License 2.0. See LICENSE.txt for license information. +# +""" +Positron's matplotlib backend for notebook sessions. + +This backend is designed to match Jupyter's `matplotlib_inline` backend, except where deviating +would provide a better user experience in Positron. + +NOTE: DO NOT DIRECTLY IMPORT THIS MODULE! + +This module assumes that it is only ever imported by matplotlib when it sets its backend. +Given that, it doesn't check whether matplotlib is installed in the user's environment, +and runs code on import e.g. to enable matplotlib interactive mode. This is the same approach +taken by IPython's matplotlib-inline backend, and seems to be the only way to run code when +the backend is set by matplotlib. +""" + +from __future__ import annotations + +import contextlib +import logging +from typing import TYPE_CHECKING, Literal, cast + +import matplotlib +import matplotlib.pyplot as plt +from IPython.display import display +from matplotlib._pylab_helpers import Gcf +from matplotlib.backend_bases import FigureManagerBase +from matplotlib.backends.backend_agg import FigureCanvasAgg +from matplotlib.figure import Figure + +from ..execute_request import PositronExecuteRequest, current_execute_request +from . import formats +from .backend import Backend +from .registry import registry + +if TYPE_CHECKING: + from IPython.core.interactiveshell import InteractiveShell + +logger = logging.getLogger(__name__) + + +def new_figure_manager( + num: int | str, + *args, + FigureClass=Figure, # noqa: N803 + # 3-tuple with unit is only available in matplotlib >= 3.11. + figsize: tuple[float, float] | tuple[float, float, Literal["in", "cm", "px"]] | None = None, + **kwargs, +) -> FigureManagerPositronNotebook: + """Called by matplotlib when a new figure is created.""" + # Get the current execute request. + execute_request = current_execute_request() + + # Sizing precedence: an explicit size from user code (`plt.figure(figsize=...)`, + # `plt.subplots(figsize=...)`) wins, else the cell's `#| fig-width`/`#| fig-height` + # (either may be set alone, per dimension), else matplotlib's `figure.figsize` + # rcParam, which `Figure` applies for a None figsize. + # Compare against None rather than truthiness: matplotlib accepts any array-like + # figsize, and a numpy array raises on `bool()`. + if figsize is None: + figsize = _requested_figsize(execute_request) + # `Figure` only accepts the 3-tuple form in matplotlib >= 3.11, but we pass whatever + # matplotlib handed us straight back to it, so the pair always matches at runtime. + # The cast keeps pyright quiet against the older pins (CI type-checks on 3.9). + figure = FigureClass(*args, figsize=cast("tuple[float, float] | None", figsize), **kwargs) + + # Also provide the execute request to the figure manager. + manager: FigureManagerPositronNotebook = cast( + "FigureManagerPositronNotebook", FigureCanvasPositronNotebook.new_manager(figure, num) + ) + # Set the device pixel ratio to the execute request's value, if provided. + if (pixel_ratio := execute_request.output_pixel_ratio) is not None: + manager.canvas.set_device_pixel_ratio(pixel_ratio) + + return manager + + +def _requested_figsize(execute_request: PositronExecuteRequest) -> tuple[float, float] | None: + """The figsize requested via the cell's `#| fig-width`/`#| fig-height`, if any. + + Either option may be set alone, matching `quarto render`; the missing dimension + falls back to the current `figure.figsize` rcParam. + """ + size = execute_request.figure_size + if size is None: + return None + width, height = size + default_width, default_height = matplotlib.rcParams["figure.figsize"] + return ( + width if width is not None else default_width, + height if height is not None else default_height, + ) + + +class FigureManagerPositronNotebook(FigureManagerBase): + canvas: FigureCanvasPositronNotebook # type: ignore + + # Whether this figure has already been displayed during the current cell. Only the + # post-execute hook reads it, so an explicit show always displays. Public because + # `_show_figures` reads it off another object. + displayed = False + + def show(self): + """Called by matplotlib when a figure is shown via `plt.show()` or `figure.show()`. + + Displays the figure inline, as an output of the currently executing cell. + """ + display(self.canvas.figure) + self.displayed = True + + @classmethod + def pyplot_show(cls, *, block: bool | None = None) -> None: + """Called by by matplotlib when a user calls `plt.show()`.""" + try: + super().pyplot_show(block=block) + finally: + _close_all_figures() + + +class FigureCanvasPositronNotebook(FigureCanvasAgg): + manager_class = FigureManagerPositronNotebook # type: ignore + + def set_device_pixel_ratio(self, ratio: float) -> None: + """Scale rendered pixels by `ratio`, leaving the figure's size in inches unchanged. + + A public seam over matplotlib's protected setter, since the ratio is applied by + `new_figure_manager` from outside the canvas. + """ + self._set_device_pixel_ratio(ratio) # type: ignore + + +# Fulfil the matplotlib backend API. +FigureCanvas = FigureCanvasPositronNotebook +FigureManager = FigureManagerPositronNotebook + + +def _close_all_figures() -> None: + """Close all figures, so that they don't accumulate across cells.""" + # `close("all")` triggers a gc collect, which can be slow, so skip it if there's + # nothing to close. + if Gcf.get_all_fig_managers(): + plt.close("all") + + +def _show_figures(): + """Post execute hook to show the cell's figures and log errors.""" + try: + # Don't reuse `pyplot_show` here: it shows every figure, which would emit a second + # output for a figure that user code already displayed via `plt.show()`/`fig.show()`. + # Matplotlib's own loop adds nothing else for a non-GUI backend - it only handles + # `NonGuiException` (our `show` displays instead of raising) and blocks on a main + # loop we don't have. + for manager in Gcf.get_all_fig_managers(): + if not isinstance(manager, FigureManagerPositronNotebook): + manager.show() + continue + + if not manager.displayed: + manager.show() + + # Reset even though the figure is about to be closed: should one ever outlive + # the cell, showing it again beats silently swallowing its output. + manager.displayed = False + except Exception: + logger.exception("Error showing figures in post execute hook") + finally: + _close_all_figures() + + +def install(shell: InteractiveShell) -> None: + """Install the notebook backend's shell hooks. See `BackendModule`.""" + # Register a hook to show all figures after cell execution. + shell.events.register("post_execute", _show_figures) + + # Register our formatter(s) for matplotlib Figure objects, for the currently + # selected format(s) (`png` unless the user previously called + # `set_matplotlib_formats`). + formats.apply_selected_formats(shell) + + +def uninstall(shell: InteractiveShell) -> None: + """Remove the notebook backend's shell hooks. See `BackendModule`.""" + # Suppress ValueError in case user code unregistered the hook itself; a backend + # switch shouldn't crash over it. + with contextlib.suppress(ValueError): + shell.events.unregister("post_execute", _show_figures) + + # Unregister our formatter(s) for matplotlib Figure objects. + formats.pop_registered_formatters(shell) + + +# If we are the selected backend, activate through the registry, which also tears +# down the other backend if it was active. This runs when matplotlib imports the +# module to switch to it. See the note at the top of the file. `Backend.from_name` +# matches both spellings, which matters here: matplotlib can still report our short +# name via `get_backend()` at this point, before it settles into the `module://` +# spelling. +if Backend.from_name(matplotlib.get_backend()) is Backend.NOTEBOOK: + registry.activate(Backend.NOTEBOOK) diff --git a/extensions/positron-python/python_files/posit/positron/matplotlib_backend/registry.py b/extensions/positron-python/python_files/posit/positron/matplotlib_backend/registry.py new file mode 100644 index 000000000000..cac73087e868 --- /dev/null +++ b/extensions/positron-python/python_files/posit/positron/matplotlib_backend/registry.py @@ -0,0 +1,175 @@ +# +# Copyright (C) 2026 Posit Software, PBC. All rights reserved. +# Licensed under the Elastic License 2.0. See LICENSE.txt for license information. +# +""" +Which Positron matplotlib backend is active, and the switch-time lifecycle around it. + +Keep this module free of matplotlib imports; see the package docstring. +""" + +from __future__ import annotations + +import logging +import sys +from typing import TYPE_CHECKING, Callable + +from IPython.core.getipython import get_ipython + +from .backend import Backend + +if TYPE_CHECKING: + from IPython.core.interactiveshell import InteractiveShell + + ConfigureInlineSupport = Callable[[InteractiveShell, str], None] + +logger = logging.getLogger(__name__) + +_MATPLOTLIB_INLINE_MODULE = "matplotlib_inline.backend_inline" +_MATPLOTLIB_INLINE_BACKENDS = ("inline", f"module://{_MATPLOTLIB_INLINE_MODULE}") + + +def _needs_inline_support(shell: InteractiveShell, backend: str) -> bool: + """ + Whether matplotlib-inline's `configure_inline_support` should be called for `backend`. + + Only when matplotlib-inline is the backend being switched to, or when it's currently + active and needs tearing down. Calling it in any other case would instantiate + `InlineBackend` into `shell.configurables`, arming a traitlets observer that re-runs + `select_figure_formats` -- popping Positron's figure formatter -- on any later + `%config InlineBackend.*` assignment. + """ + if backend in _MATPLOTLIB_INLINE_BACKENDS: + return True + + # matplotlib-inline is active if its post execute hook is registered, whether by an + # earlier switch through here or by its backend module self-activating on import. + module = sys.modules.get(_MATPLOTLIB_INLINE_MODULE) + flush_figures = getattr(module, "flush_figures", None) + return flush_figures is not None and flush_figures in shell.events.callbacks.get( + "post_execute", [] + ) + + +class PositronBackendRegistry: + """ + Sole owner of which Positron matplotlib backend is active. + + Instantiated once as the module-level `registry`, mirroring matplotlib's own + `backend_registry`: a process has a single active matplotlib backend, so it has a + single activation state. Backend modules hold no activation state of their own. + """ + + def __init__(self) -> None: + # The Positron backend whose hooks are currently installed. + self._active_backend: Backend | None = None + + # The real `configure_inline_support`, captured when the switch hook is installed. + self._original_configure_inline_support: ConfigureInlineSupport | None = None + + def activate(self, backend: str | Backend) -> None: + """ + Activate the Positron backend that `backend` selects, deactivating the previous one. + + Called on every matplotlib backend switch and self-dispatching on whether the + new backend is one of ours, so a `backend` that isn't Positron's deactivates + Positron's support entirely. Switching to the already active backend is a no-op. + """ + target = backend if isinstance(backend, Backend) else Backend.from_name(backend) + if target is self._active_backend: + return + + shell = get_ipython() + if shell is None: + logger.warning("No IPython shell found; Positron matplotlib support not configured") + return + + if self._active_backend is not None: + # `_active_backend` is only ever set after we imported that module and + # installed its hooks, so this `import_module()` is a cached `sys.modules` + # lookup rather than a fresh import. + self._active_backend.import_module().uninstall(shell) + self._active_backend = None + + if target is not None: + # Enable interactive mode (i.e. redraw after every plotting command). + import matplotlib + + matplotlib.interactive(True) # noqa: FBT003 + target.import_module().install(shell) + self._active_backend = target + + # The `set_matplotlib_formats` patch is installed once for any Positron backend + # rather than per backend (it dispatches on the live backend at call time), so + # it's owned here: installed while any Positron backend is active, removed when + # none is. + from . import formats + + if target is not None: + formats.install_set_matplotlib_formats_patch() + else: + formats.uninstall_set_matplotlib_formats_patch() + + def install_switch_hook(self) -> ConfigureInlineSupport: + """ + Wrap `matplotlib_inline.backend_inline.configure_inline_support` with Positron's seam. + + IPython's `enable_matplotlib` imports that function from the module at call time + on every backend switch, so wrapping the module attribute makes every switch -- + including ones to foreign backends via `super()` -- run + `configure_matplotlib_support`. Wrapping this function rather than overriding the + `%matplotlib` magic is also the only way to catch the switch that bypasses + IPython entirely: `matplotlib_inline.backend_inline` self-activates at import + (`_enable_matplotlib_integration`) and calls `configure_inline_support` directly, + so a bare `matplotlib.use("inline")` never reaches `enable_matplotlib`. + + Idempotent, and returns the real `configure_inline_support` the seam wraps. + Installed lazily (not at kernel init) because importing matplotlib_inline pulls + in matplotlib, which may not be installed until the user actually plots. Never + uninstalled, matching the kernel's other third-party patches; the wrapper + preserves upstream behavior whenever Positron isn't involved. + """ + if self._original_configure_inline_support is None: + import matplotlib_inline.backend_inline as backend_inline + + self._original_configure_inline_support = backend_inline.configure_inline_support + backend_inline.configure_inline_support = configure_matplotlib_support + return self._original_configure_inline_support + + def configure_switch(self, shell: InteractiveShell, backend: str) -> None: + """ + Configure inline figure display after a matplotlib backend switch. + + matplotlib-inline is configured only when it's involved (see + `_needs_inline_support`): calling it for any other switch would instantiate + `InlineBackend` into `shell.configurables`, arming a traitlets observer that pops + Positron's figure formatter on any later `%config InlineBackend.*` assignment. + Positron's support is configured on every switch, and goes last: tearing down + matplotlib-inline re-runs `select_figure_formats`, which pops the figure formatter + that activating Positron's backend then registers. + """ + original_configure_inline_support = self.install_switch_hook() + if _needs_inline_support(shell, backend): + original_configure_inline_support(shell, backend) + self.activate(backend) + + +registry = PositronBackendRegistry() + + +def install_backend_switch_hook() -> None: + """Install `registry`'s switch hook. See `PositronBackendRegistry.install_switch_hook`.""" + registry.install_switch_hook() + + +def configure_matplotlib_support(shell: InteractiveShell, backend: str) -> None: + """ + Configure Positron's matplotlib support after a backend switch. + + The name and signature mirror `matplotlib_inline.backend_inline.configure_inline_support`, + the de facto IPython interface for switch-time lifecycle, since this replaces it. A + module-level function rather than a bound method so the object installed by + `PositronBackendRegistry.install_switch_hook` is stable and comparable by identity. + See `PositronBackendRegistry.configure_switch`. + """ + registry.configure_switch(shell, backend) diff --git a/extensions/positron-python/python_files/posit/positron/positron_ipkernel.py b/extensions/positron-python/python_files/posit/positron/positron_ipkernel.py index ba1e88bc71c7..573f8a7b8e9f 100644 --- a/extensions/positron-python/python_files/posit/positron/positron_ipkernel.py +++ b/extensions/positron-python/python_files/posit/positron/positron_ipkernel.py @@ -40,6 +40,8 @@ from .formatters.display_formatter import PositronDisplayFormatter from .help import HelpService, _distribution_to_modules, help # noqa: A004 from .lsp import LSPService +from .matplotlib_backend.backend import Backend +from .matplotlib_backend.compat import register_with_legacy_ipython from .patch.bokeh import handle_bokeh_output, patch_bokeh_no_access from .patch.haystack import patch_haystack_is_in_jupyter from .patch.holoviews import set_holoviews_extension @@ -125,7 +127,7 @@ def _is_module_on_disk(name: str) -> bool: @magics_class class PositronMagics(Magics): - shell: PositronShell + shell: PositronShell # type: ignore # This will override the default `clear` defined in `ipykernel.zmqshell.KernelMagics`. @line_magic @@ -319,6 +321,13 @@ def init_magics(self): # Register Positron's custom magics. self.register_magics(PositronMagics) + # On IPython versions that don't read matplotlib's backend registry, add + # Positron's backends to the static table that `%matplotlib -l` lists. This + # belongs here, next to the magic that reads the table: `-l` is handled inside + # `%matplotlib` and never reaches `enable_matplotlib`, so registering lazily on + # the switch path would leave the names out of `-l` until after a switch. + register_with_legacy_ipython() + def init_user_ns(self): super().init_user_ns() @@ -340,6 +349,62 @@ def init_display_formatter(self): self.display_formatter = PositronDisplayFormatter(parent=self) self.configurables.append(self.display_formatter) # type: ignore IPython type annotation is wrong + def enable_matplotlib(self, gui=None): + """ + Enable interactive matplotlib and inline figure support. + + Overrides IPython so that bare `%matplotlib`, `%matplotlib inline`, and + Positron's own backend names activate the Positron backend that suits the + session mode (or the explicitly named backend). Every other backend is + delegated to IPython, whose switch path runs through the hook installed by + `install_backend_switch_hook`, so switching away tears Positron's hooks down. + + Overriding here rather than the `%matplotlib` magic also covers + `--matplotlib`/`--pylab` and their config-file equivalents: + `InteractiveShellApp.init_gui_pylab` calls `shell.enable_matplotlib` directly at + startup, never going through the magic. + """ + from IPython.core import pylabtools as pt + + from .matplotlib_backend.registry import ( + configure_matplotlib_support, + install_backend_switch_hook, + ) + + # Install the seam before any switch below can need it. Idempotent; lazy + # because it imports matplotlib (see its docstring). + install_backend_switch_hook() + + # Only the sentinel check case-folds: `Backend.from_name` normalizes short names + # itself, and keeps `module://` paths case-sensitive since they're module paths. + requested = gui.lower() if isinstance(gui, str) else gui + target = Backend.from_name(gui) if isinstance(gui, str) else None + if requested in (None, "auto", "inline"): + # Positron's backend is both the session's default and its inline backend. + # This is a reroute through the full switch path rather than a no-op when + # already active, so that `%matplotlib inline` after `%matplotlib qt` + # switches back. See https://github.com/posit-dev/positron/issues/15116. + target = Backend.for_session_mode(self.session_mode) + + if target is None: + # A non-Positron backend (qt, agg, matplotlib-inline by module name, ...). + return super().enable_matplotlib(gui) + + # A Positron backend: switch directly instead of via super(). + # `pt.find_gui_and_backend` only resolves entry-point names like ours on + # IPython >= 8.24 with matplotlib >= 3.9, and Positron backends never have a + # gui event loop, so IPython's gui-selection logic doesn't apply. The recipe + # below matches `InteractiveShell.enable_matplotlib`'s: activate, configure + # inline support, enable the gui event loop, fix `%run`. + backend = target.preferred_name + pt.activate_matplotlib(backend) + configure_matplotlib_support(self, backend) + self.enable_gui(None) + self.magics_manager.registry["ExecutionMagics"].default_runner = pt.mpl_runner( + self.safe_execfile + ) + return None, backend + def _inspect(self, meth, oname, namespaces=None, **kw): # For `?name`, if the name isn't a live object but is an installed # module or a known PyPI distribution name (e.g. `scikit-learn`, @@ -397,19 +462,6 @@ def _handle_post_run_cell(self, result: ExecutionResult) -> None: except Exception: logger.exception("Error polling variables") - # Detach high-level plotting library figures (e.g. seaborn) so that re-running - # plotting code starts a fresh figure instead of drawing onto the previous one. - # See https://github.com/posit-dev/positron/issues/8898. Only act if matplotlib - # has already imported our backend: importing it here would run its module-level - # setup code, so we reuse the already-loaded module instead. - if "positron.matplotlib_backend" in sys.modules: - try: - from .matplotlib_backend import detach_library_figures - - detach_library_figures() - except Exception: - logger.exception("Error detaching plotting library figures") - def _add_editor_dir_to_sys_path(self) -> str | None: """ Add the directory of the executed file to sys.path. @@ -782,12 +834,15 @@ def init_control(self, context): return result def init_gui_pylab(self): - # Enable the Positron matplotlib backend if we're not in a notebook. - # If we're in a notebook, use IPython's default backend via the super() call below. + # Enable the Positron matplotlib backend that matches the session mode. # Matplotlib uses the MPLBACKEND environment variable to determine the backend to use. - # It imports the backend module when it's first needed. - if self.session_mode != SessionMode.NOTEBOOK and not os.environ.get("MPLBACKEND"): - os.environ["MPLBACKEND"] = "module://positron.matplotlib_backend" + # It imports the backend module when it's first needed, which is when the backend + # activates itself. + # Use the `module://` spelling rather than a short name: it works on every + # matplotlib version, whereas the short names need the backend registry + # (matplotlib >= 3.9). + if not os.environ.get("MPLBACKEND"): + os.environ["MPLBACKEND"] = Backend.for_session_mode(self.session_mode).full_name return super().init_gui_pylab() diff --git a/extensions/positron-python/python_files/posit/positron/tests/test_execute_request.py b/extensions/positron-python/python_files/posit/positron/tests/test_execute_request.py index 835fdcaad171..d04bb96fb0b1 100644 --- a/extensions/positron-python/python_files/posit/positron/tests/test_execute_request.py +++ b/extensions/positron-python/python_files/posit/positron/tests/test_execute_request.py @@ -3,7 +3,18 @@ # Licensed under the Elastic License 2.0. See LICENSE.txt for license information. # -from positron.execute_request import PositronExecuteRequest +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest + +from positron.execute_request import PositronExecuteRequest, current_execute_request + +if TYPE_CHECKING: + from positron.positron_ipkernel import PositronIPyKernel + + from .conftest import MockSession def test_parses_code_location() -> None: @@ -125,3 +136,54 @@ def test_unknown_keys_are_ignored() -> None: meta = PositronExecuteRequest.from_message(message) assert meta.fig_width == 6.4 + + +@pytest.mark.parametrize( + ("fig_width", "fig_height", "expected"), + [ + (6.4, 4.8, (6.4, 4.8)), + # A lone dimension passes through; the caller fills the other from its default. + (None, 4.8, (None, 4.8)), + (6.4, None, (6.4, None)), + (None, None, None), + # A non-positive dimension counts as unspecified. + (-1.0, 4.8, (None, 4.8)), + (6.4, -1.0, (6.4, None)), + (-1.0, -1.0, None), + ], +) +def test_figure_size( + fig_width: float | None, + fig_height: float | None, + expected: tuple[float | None, float | None] | None, +) -> None: + """`figure_size` keeps each positive dimension and is None only when neither is set.""" + message = {"content": {"positron": {"fig-width": fig_width, "fig-height": fig_height}}} + + meta = PositronExecuteRequest.from_message(message) + + assert meta.figure_size == expected + + +def test_current_execute_request_reads_figure_size( + kernel: PositronIPyKernel, session: MockSession +) -> None: + """`current_execute_request` parses the kernel's current shell parent message.""" + message = session.msg( + "execute_request", content={"positron": {"fig-width": 6.4, "fig-height": 4.8}} + ) + + kernel.set_parent([], message, channel="shell") + + assert current_execute_request().figure_size == (6.4, 4.8) + + +def test_current_execute_request_with_no_metadata( + kernel: PositronIPyKernel, session: MockSession +) -> None: + """A shell parent message without `content.positron` yields an empty model.""" + message = session.msg("execute_request", content={}) + + kernel.set_parent([], message, channel="shell") + + assert current_execute_request().figure_size is None diff --git a/extensions/positron-python/python_files/posit/positron/tests/test_matplotlib_backend/__init__.py b/extensions/positron-python/python_files/posit/positron/tests/test_matplotlib_backend/__init__.py new file mode 100644 index 000000000000..1d40d5998be2 --- /dev/null +++ b/extensions/positron-python/python_files/posit/positron/tests/test_matplotlib_backend/__init__.py @@ -0,0 +1,4 @@ +# +# Copyright (C) 2026 Posit Software, PBC. All rights reserved. +# Licensed under the Elastic License 2.0. See LICENSE.txt for license information. +# diff --git a/extensions/positron-python/python_files/posit/positron/tests/test_matplotlib_backend/conftest.py b/extensions/positron-python/python_files/posit/positron/tests/test_matplotlib_backend/conftest.py new file mode 100644 index 000000000000..937c6621aaa2 --- /dev/null +++ b/extensions/positron-python/python_files/posit/positron/tests/test_matplotlib_backend/conftest.py @@ -0,0 +1,35 @@ +# +# Copyright (C) 2023-2024 Posit Software, PBC. All rights reserved. +# Licensed under the Elastic License 2.0. See LICENSE.txt for license information. +# + +from __future__ import annotations + +import contextlib +from typing import TYPE_CHECKING + +import matplotlib + +from positron.matplotlib_backend.registry import registry + +if TYPE_CHECKING: + from typing import Generator + + from positron.matplotlib_backend.backend import Backend + + +@contextlib.contextmanager +def active_backend(backend: Backend) -> Generator[None, None, None]: + """Activate a Positron matplotlib backend, restoring the previous backend on exit.""" + prev = matplotlib.get_backend() + matplotlib.use(backend.full_name) + # matplotlib has no switch-callback API (`pyplot.switch_backend` notifies nothing), so + # in production the backend module's import-time trailer is what activates Positron's + # support. `matplotlib.use` here often finds the module already imported, so that + # trailer doesn't re-run; activate manually to simulate it. + registry.activate(backend) + try: + yield + finally: + registry.activate(prev) + matplotlib.use(prev) diff --git a/extensions/positron-python/python_files/posit/positron/tests/test_matplotlib_backend/test_backend.py b/extensions/positron-python/python_files/posit/positron/tests/test_matplotlib_backend/test_backend.py new file mode 100644 index 000000000000..cb633cc42d62 --- /dev/null +++ b/extensions/positron-python/python_files/posit/positron/tests/test_matplotlib_backend/test_backend.py @@ -0,0 +1,93 @@ +# +# Copyright (C) 2026 Posit Software, PBC. All rights reserved. +# Licensed under the Elastic License 2.0. See LICENSE.txt for license information. +# +""" +Tests for `Backend`, the names of Positron's matplotlib backends. + +Pure name resolution, no shell or matplotlib backend switching involved: which session +mode maps to which backend, which spellings `from_name` accepts, and which spelling +`preferred_name` hands to matplotlib. +""" + +from __future__ import annotations + +import pytest + +from positron.matplotlib_backend import backend as backend_module +from positron.matplotlib_backend.backend import Backend +from positron.session_mode import SessionMode + +# A backend that isn't ours. Headless, matching the rest of the suite. +OTHER_BACKEND_NAME = "agg" + + +@pytest.fixture( + params=[ + pytest.param(Backend.CONSOLE, id="console"), + pytest.param(Backend.NOTEBOOK, id="notebook"), + ] +) +def backend(request: pytest.FixtureRequest) -> Backend: + """Each of Positron's matplotlib backends.""" + return request.param + + +def _other_backend(backend: Backend) -> Backend: + """The Positron backend that isn't `backend`.""" + return Backend.NOTEBOOK if backend is Backend.CONSOLE else Backend.CONSOLE + + +@pytest.mark.parametrize( + ("session_mode", "expected"), + [ + (SessionMode.CONSOLE, Backend.CONSOLE), + (SessionMode.NOTEBOOK, Backend.NOTEBOOK), + # BACKGROUND sessions have no notebook to render into, so they get the console + # backend, which routes figures to the plots pane. + (SessionMode.BACKGROUND, Backend.CONSOLE), + ], +) +def test_for_session_mode(session_mode: SessionMode, expected: Backend): + """Each session mode maps to the Positron backend that suits it.""" + assert Backend.for_session_mode(session_mode) is expected + + +def test_from_name_recognizes_own_names(backend: Backend): + """`Backend.from_name` accepts a backend's own short name and its `module://` name.""" + assert Backend.from_name(backend.short_name) is backend + assert Backend.from_name(backend.full_name) is backend + + +def test_from_name_rejects_other_names(backend: Backend): + """`Backend.from_name` resolves neither the other backend's names nor a foreign backend to `backend`.""" + other = _other_backend(backend) + + assert Backend.from_name(other.short_name) is not backend + assert Backend.from_name(other.full_name) is not backend + assert Backend.from_name(OTHER_BACKEND_NAME) is None + + +def test_from_name_short_name_case_insensitive(backend: Backend): + """Short names match case-insensitively, like matplotlib's backend registry.""" + assert Backend.from_name(backend.short_name.upper()) is backend + + +def test_from_name_full_name_case_sensitive(backend: Backend): + """The path after `module://` is an importable module path, so case matters.""" + assert Backend.from_name(f"module://{backend.module_name.upper()}") is None + + +def test_preferred_name_prefers_short_name(backend: Backend): + """The backend's short name is preferred when matplotlib's backend registry knows it.""" + assert backend.preferred_name == backend.short_name + + +def test_preferred_name_falls_back_to_module_name( + backend: Backend, monkeypatch: pytest.MonkeyPatch +): + """Falls back to the `module://` name before matplotlib 3.9, which has no registry.""" + # Patched on `backend.py`, which imported the shim by name, not on `compat.py`. + monkeypatch.setattr(backend_module, "get_backend_registry", lambda: None) + + assert backend.preferred_name == backend.full_name diff --git a/extensions/positron-python/python_files/posit/positron/tests/test_plots.py b/extensions/positron-python/python_files/posit/positron/tests/test_matplotlib_backend/test_console.py similarity index 94% rename from extensions/positron-python/python_files/posit/positron/tests/test_plots.py rename to extensions/positron-python/python_files/posit/positron/tests/test_matplotlib_backend/test_console.py index 4129de80d6e4..ce4074501221 100644 --- a/extensions/positron-python/python_files/posit/positron/tests/test_plots.py +++ b/extensions/positron-python/python_files/posit/positron/tests/test_matplotlib_backend/test_console.py @@ -6,24 +6,26 @@ import base64 import io from pathlib import Path -from typing import Any, Dict, Iterable, Optional, Tuple, cast +from typing import Any, Dict, Iterable, Iterator, Optional, Tuple, cast -import matplotlib import matplotlib.pyplot as plt import pytest from PIL import Image +from positron.matplotlib_backend.backend import Backend +from positron.matplotlib_backend.registry import registry from positron.plot_comm import PlotRenderFormat, PlotSize, PlotUnit from positron.plots import PlotsService from positron.positron_ipkernel import PositronIPyKernel, _CommTarget -from .conftest import DummyComm, PositronShell -from .utils import ( +from ..conftest import DummyComm, PositronShell +from ..utils import ( comm_close_message, json_rpc_request, json_rpc_response, percent_difference, ) +from .conftest import active_backend # # Matplotlib backend + shell + plots service integration tests. @@ -33,10 +35,11 @@ @pytest.fixture(autouse=True) -def setup_positron_matplotlib_backend() -> None: +def setup_positron_matplotlib_backend() -> Iterator[None]: # The backend is set in the kernel app, which isn't currently available in our tests, # so set it here too. - matplotlib.use("module://positron.matplotlib_backend") + with active_backend(Backend.CONSOLE): + yield @pytest.fixture(autouse=True) @@ -560,7 +563,9 @@ def test_mpl_seaborn_figure_detached_after_cell( """ # Create the figure as if it were produced by seaborn (detection is exercised # separately in test_mpl_detect_library_walks_call_stack). - monkeypatch.setattr("positron.matplotlib_backend._detect_plotting_library", lambda: "seaborn") + monkeypatch.setattr( + "positron.matplotlib_backend.console._detect_plotting_library", lambda: "seaborn" + ) plot_comm = _create_mpl_plot(shell, plots_service) # Running any cell triggers the post-cell detach. @@ -590,7 +595,9 @@ def test_mpl_seaborn_does_not_overwrite_existing_matplotlib_figure( assert plt.get_fignums() == [1] # Simulate a seaborn call resolving its axes via plt.gca(). - monkeypatch.setattr("positron.matplotlib_backend._detect_plotting_library", lambda: "seaborn") + monkeypatch.setattr( + "positron.matplotlib_backend.console._detect_plotting_library", lambda: "seaborn" + ) axes = plt.gca() # gca returned axes on a new figure (2), leaving the matplotlib figure (1) intact. @@ -644,7 +651,7 @@ def test_mpl_detect_library_walks_call_stack() -> None: See https://github.com/posit-dev/positron/issues/8898. """ - from positron.matplotlib_backend import _detect_plotting_library + from positron.matplotlib_backend.console import _detect_plotting_library # A plain call site is attributed to matplotlib. assert _detect_plotting_library() == "matplotlib" @@ -656,6 +663,24 @@ def test_mpl_detect_library_walks_call_stack() -> None: assert namespace["call"](_detect_plotting_library) == "seaborn" +def test_mpl_deactivate_restores_gca() -> None: + """Switching away restores the original `plt.gca` that installation patched over.""" + import matplotlib.pyplot as plt + + from positron.matplotlib_backend import console + + # The autouse fixture activated the console backend, installing our redirect. + assert console._installed_gca is not None # noqa: SLF001 + installed_gca = plt.gca + original_gca = console._original_gca # noqa: SLF001 + + registry.activate("agg") + + assert console._installed_gca is None # noqa: SLF001 + assert plt.gca is original_gca + assert plt.gca is not installed_gca + + def test_mpl_shutdown(shell: PositronShell, plots_service: PlotsService) -> None: plot_comms = [_create_mpl_plot(shell, plots_service) for _ in range(2)] diff --git a/extensions/positron-python/python_files/posit/positron/tests/test_matplotlib_backend/test_enable_matplotlib.py b/extensions/positron-python/python_files/posit/positron/tests/test_matplotlib_backend/test_enable_matplotlib.py new file mode 100644 index 000000000000..65cbe6aff8a6 --- /dev/null +++ b/extensions/positron-python/python_files/posit/positron/tests/test_matplotlib_backend/test_enable_matplotlib.py @@ -0,0 +1,560 @@ +# +# Copyright (C) 2026 Posit Software, PBC. All rights reserved. +# Licensed under the Elastic License 2.0. See LICENSE.txt for license information. +# +""" +Tests for switching matplotlib backends with `%matplotlib` in a Positron session. + +Exercises `PositronShell.enable_matplotlib`: bare `%matplotlib` and `%matplotlib inline` +keep the session's own Positron backend active, an explicit `positron-console` or +`positron-notebook` activates that backend outright (even across session modes), another +backend tears Positron's hooks down, and switching back restores them. `agg` is the +"other" backend throughout so the tests run headless. `Backend`'s name resolution behind +all of this is covered by `test_backend.py`. +""" + +from __future__ import annotations + +import contextlib +import sys +from functools import partial +from typing import TYPE_CHECKING, Callable, Iterator, NamedTuple + +import matplotlib +import matplotlib.pyplot as plt +import pytest +from IPython.core import pylabtools +from IPython.utils.capture import capture_output +from matplotlib.figure import Figure + +from positron.matplotlib_backend import compat, console, formats, notebook +from positron.matplotlib_backend.backend import Backend +from positron.matplotlib_backend.registry import ( + configure_matplotlib_support, + install_backend_switch_hook, + registry, +) +from positron.session_mode import SessionMode + +from ..utils import run_with_metadata +from .conftest import active_backend + +if TYPE_CHECKING: + from types import ModuleType + + from positron.positron_ipkernel import PositronShell + +INLINE_MODULE_NAME = "matplotlib_inline.backend_inline" +INLINE_BACKEND_NAME = f"module://{INLINE_MODULE_NAME}" + +# The backend to switch away to. Headless, so these tests don't need a display. +OTHER_BACKEND_NAME = "agg" + +# Whether `%matplotlib ` resolves through matplotlib's backend registry, which +# needs IPython >= 8.24 *and* matplotlib >= 3.9 -- IPython 8.24 kept the static-table +# fallback for older matplotlib. This private helper encodes both halves. +REGISTRY_BACKEND_RESOLUTION = getattr(pylabtools, "_matplotlib_manages_backends", lambda: False)() + + +class BackendCase(NamedTuple): + """One of Positron's matplotlib backends, and how to detect that it's installed.""" + + session_mode: SessionMode + module: ModuleType + backend: Backend + # Whether the backend's non-hook integration is installed: the console backend's + # `plt.gca` redirect, or the notebook backend's figure formatter. + integration_installed: Callable[[PositronShell], bool] + + +def _console_integration_installed(shell: PositronShell) -> bool: # noqa: ARG001 + return plt.gca is console._installed_gca # noqa: SLF001 + + +def _notebook_integration_installed(shell: PositronShell) -> bool: + formatter = shell.display_formatter.formatters["image/png"] + with contextlib.suppress(KeyError): + # `lookup_by_type` raises KeyError when no formatter is registered for the type. + # The registered callable is a `partial` binding `_display_figure` (see + # `formats.select_figure_formats`), not the bare function, since it also binds + # `format_` and any `print_figure_kwargs`. + current = formatter.lookup_by_type(Figure) + return isinstance(current, partial) and current.func is formats._display_figure # noqa: SLF001 + return False + + +CONSOLE = BackendCase( + SessionMode.CONSOLE, + console, + Backend.CONSOLE, + _console_integration_installed, +) +NOTEBOOK = BackendCase( + SessionMode.NOTEBOOK, + notebook, + Backend.NOTEBOOK, + _notebook_integration_installed, +) + + +def _other_case(case: BackendCase) -> BackendCase: + """The Positron backend that isn't `case`.""" + return NOTEBOOK if case is CONSOLE else CONSOLE + + +@pytest.fixture(params=[pytest.param(CONSOLE, id="console"), pytest.param(NOTEBOOK, id="notebook")]) +def backend_case(request: pytest.FixtureRequest) -> BackendCase: + """Each of Positron's matplotlib backends, one per session mode.""" + return request.param + + +@pytest.fixture +def positron_backend( + backend_case: BackendCase, shell: PositronShell, monkeypatch: pytest.MonkeyPatch +) -> Iterator[BackendCase]: + """A session with the parametrized Positron backend active.""" + yield from _session_with_backend(backend_case, shell, monkeypatch) + + +@pytest.fixture +def notebook_backend( + shell: PositronShell, monkeypatch: pytest.MonkeyPatch +) -> Iterator[BackendCase]: + """A notebook session with Positron's notebook backend active.""" + yield from _session_with_backend(NOTEBOOK, shell, monkeypatch) + + +@pytest.fixture +def console_backend(shell: PositronShell, monkeypatch: pytest.MonkeyPatch) -> Iterator[BackendCase]: + """A console session with Positron's console backend active.""" + yield from _session_with_backend(CONSOLE, shell, monkeypatch) + + +def _session_with_backend( + case: BackendCase, shell: PositronShell, monkeypatch: pytest.MonkeyPatch +) -> Iterator[BackendCase]: + """ + Fixture body: a session with `case`'s backend active, as it is at kernel startup. + + Restores matplotlib's backend, both backends' hooks and any matplotlib-inline state + afterwards, since matplotlib and the shell are process-wide singletons. + """ + monkeypatch.setattr(shell, "session_mode", case.session_mode) + + # Entering a gui event loop needs a running kernel application, which tests don't have. + monkeypatch.setattr(shell, "enable_gui", lambda gui=None: None) # noqa: ARG005 + + with active_backend(case.backend): + yield case + _reset_matplotlib_inline(shell) + shell.kernel.plots_service.shutdown() + plt.close("all") + + +def _reset_matplotlib_inline(shell: PositronShell) -> None: + """Undo any matplotlib-inline state so it doesn't leak into the next test.""" + module = sys.modules.get(INLINE_MODULE_NAME) + if module is None: + return + + from matplotlib_inline.config import InlineBackend + + with contextlib.suppress(ValueError): + shell.events.unregister("post_execute", module.flush_figures) + + saved_rc_params = shell.__dict__.pop("_saved_rcParams", None) + if saved_rc_params is not None: + matplotlib.rcParams.update(saved_rc_params) + + # `select_figure_formats` may have registered matplotlib-inline's figure formatters. + for formatter in shell.display_formatter.formatters.values(): + formatter.pop(Figure, None) + + shell.configurables[:] = [c for c in shell.configurables if not isinstance(c, InlineBackend)] + InlineBackend.clear_instance() + + # matplotlib-inline remembers the backend it last configured; forget it so the next + # test starts from the same state. + if hasattr(module.configure_inline_support, "current_backend"): + del module.configure_inline_support.current_backend + + +def _hook_count(shell: PositronShell, module_name: str) -> int: + """How many of `module_name`'s post execute hooks are registered on the shell.""" + return sum( + 1 + for callback in shell.events.callbacks["post_execute"] + if callback.__module__ == module_name + ) + + +def _state(shell: PositronShell, case: BackendCase) -> dict: + """A snapshot of the backend state that a `%matplotlib` switch should manage.""" + return { + "backend": matplotlib.get_backend(), + "positron_hooks": _hook_count(shell, case.module.__name__), + "positron_integration": case.integration_installed(shell), + "inline_hooks": _hook_count(shell, INLINE_MODULE_NAME), + } + + +def _positron_active(case: BackendCase) -> dict: + """The backend state after activating `case`'s Positron backend, inline uninvolved.""" + return { + "backend": case.backend.short_name, + "positron_hooks": 1, + "positron_integration": True, + "inline_hooks": 0, + } + + +def test_inline_keeps_positron_backend(shell: PositronShell, positron_backend: BackendCase): + """`%matplotlib inline`, the boilerplate first cell of countless notebooks, is ours.""" + shell.run_cell("%matplotlib inline").raise_error() + + assert _state(shell, positron_backend) == _positron_active(positron_backend) + + +def test_inline_is_idempotent(shell: PositronShell, positron_backend: BackendCase): + """Repeated `%matplotlib inline` registers the post execute hook exactly once.""" + shell.run_cell("%matplotlib inline").raise_error() + shell.run_cell("%matplotlib inline").raise_error() + + assert _state(shell, positron_backend) == _positron_active(positron_backend) + + +def test_inline_still_creates_positron_figures(shell: PositronShell, positron_backend: BackendCase): + """Figures created after `%matplotlib inline` still go through Positron's backend.""" + shell.run_cell("%matplotlib inline").raise_error() + # Record the canvas type in the cell that creates the figure: the notebook backend + # closes figures after each cell, which drops the canvas. + shell.run_cell( + "import matplotlib.pyplot as plt\nfig, ax = plt.subplots()\ncanvas_type = type(fig.canvas)" + ).raise_error() + + assert shell.user_ns["canvas_type"] is positron_backend.module.FigureCanvas + + +def test_inline_keeps_figure_sizing(shell: PositronShell, notebook_backend: BackendCase): # noqa: ARG001 + """`#| fig-width` and `#| fig-height` still size figures after `%matplotlib inline`.""" + shell.run_cell("%matplotlib inline").raise_error() + run_with_metadata( + "import matplotlib.pyplot as plt\nfig, ax = plt.subplots()", + {"fig-width": 8, "fig-height": 4}, + ) + + assert shell.user_ns["fig"].get_size_inches().tolist() == [8.0, 4.0] + + +def test_bare_magic_selects_positron(shell: PositronShell, positron_backend: BackendCase): + """Bare `%matplotlib` selects Positron's backend, and prints a name users can reuse.""" + with capture_output() as captured: + shell.run_cell("%matplotlib").raise_error() + + expected = f"Using matplotlib backend: {positron_backend.backend.short_name}" + + assert captured.stdout.strip() == expected + assert _state(shell, positron_backend) == _positron_active(positron_backend) + + +def test_explicit_short_name_selects_own_backend( + shell: PositronShell, positron_backend: BackendCase +): + """`%matplotlib ` selects that backend.""" + shell.run_cell(f"%matplotlib {positron_backend.backend.short_name}").raise_error() + + assert _state(shell, positron_backend) == _positron_active(positron_backend) + + +def test_explicit_short_name_selects_other_backend_across_modes( + shell: PositronShell, positron_backend: BackendCase +): + """ + `%matplotlib ` activates that backend outright. + + Cross-mode selection is intentional now that each backend has its own short name: + unlike the session-relative `positron` name it replaces, an explicit short name + always wins over the session's own mode. + """ + other = _other_case(positron_backend) + + shell.run_cell(f"%matplotlib {other.backend.short_name}").raise_error() + + assert _state(shell, other) == _positron_active(other) + # The backend that was active coming in has been torn down. + assert _state(shell, positron_backend) == { + "backend": other.backend.short_name, + "positron_hooks": 0, + "positron_integration": False, + "inline_hooks": 0, + } + + +def test_console_backend_in_notebook_session_routes_to_plots_pane( + shell: PositronShell, + notebook_backend: BackendCase, # noqa: ARG001 +): + """ + In a notebook session, `%matplotlib positron-console` routes figures to the Plots pane. + + The cross-mode switch is a feature: a notebook (or Quarto inline output) session can + opt its figures out of inline display and into the Plots pane's comm-based rendering. + """ + shell.run_cell(f"%matplotlib {Backend.CONSOLE.short_name}").raise_error() + + with capture_output() as captured: + shell.run_cell("import matplotlib.pyplot as plt\nfig, ax = plt.subplots()").raise_error() + + # The figure opened a plot comm (the Plots pane) instead of displaying inline. + assert len(shell.kernel.plots_service._plots) == 1 # noqa: SLF001 + assert not captured.outputs + + +def test_notebook_backend_in_console_session_displays_inline( + shell: PositronShell, + console_backend: BackendCase, # noqa: ARG001 +): + """In a console session, `%matplotlib positron-notebook` displays figures inline.""" + shell.run_cell(f"%matplotlib {Backend.NOTEBOOK.short_name}").raise_error() + + with capture_output() as captured: + shell.run_cell( + "import matplotlib.pyplot as plt\nfig, ax = plt.subplots()\nax.plot([0, 1], [0, 1])" + ).raise_error() + + # The figure displayed inline (as a PNG) instead of opening a plot comm. + assert not shell.kernel.plots_service._plots # noqa: SLF001 + assert len(captured.outputs) == 1 + assert captured.outputs[0]._repr_png_() is not None + + +def test_positron_short_names_are_listed_backends(shell: PositronShell): + """Both of Positron's backends are first-class names, so they show up in `%matplotlib -l`.""" + with capture_output() as captured: + shell.run_cell("%matplotlib -l").raise_error() + + assert Backend.CONSOLE.short_name in captured.stdout + assert Backend.NOTEBOOK.short_name in captured.stdout + + +def test_explicit_short_name_without_registry_resolution( + shell: PositronShell, positron_backend: BackendCase, monkeypatch: pytest.MonkeyPatch +): + """ + Explicit short names select the backend even when IPython can't resolve them. + + IPython < 8.24 resolves `%matplotlib ` through a static table that raises + KeyError for entry-point backends like Positron's, instead of matplotlib's backend + registry. Simulate that here: the switch must not reach `find_gui_and_backend`. + """ + from IPython.core import pylabtools as pt + + def legacy_find_gui_and_backend(gui=None, gui_select=None): # noqa: ARG001 + raise KeyError(gui) + + monkeypatch.setattr(pt, "find_gui_and_backend", legacy_find_gui_and_backend) + + shell.run_cell(f"%matplotlib {positron_backend.backend.short_name}").raise_error() + + assert _state(shell, positron_backend) == _positron_active(positron_backend) + + +def test_register_with_legacy_ipython_adds_short_names(monkeypatch: pytest.MonkeyPatch): + """On IPython < 8.24, the short names are added to the static backend table for `-l`.""" + from IPython.core import pylabtools as pt + + # Simulate IPython < 8.24: no registry-based lister, `-l` reads `pt.backends`. + if hasattr(pt, "_list_matplotlib_backends_and_gui_loops"): + monkeypatch.delattr(pt, "_list_matplotlib_backends_and_gui_loops") + legacy_table = {"inline": INLINE_BACKEND_NAME} + monkeypatch.setattr(pt, "backends", legacy_table) + + compat.register_with_legacy_ipython() + + assert legacy_table == { + "inline": INLINE_BACKEND_NAME, + Backend.CONSOLE.short_name: Backend.CONSOLE.full_name, + Backend.NOTEBOOK.short_name: Backend.NOTEBOOK.full_name, + } + + +def test_legacy_ipython_lists_short_names_without_a_switch( + shell: PositronShell, monkeypatch: pytest.MonkeyPatch +): + """ + On IPython < 8.24, `%matplotlib -l` lists Positron's backends before any switch. + + Legacy `%matplotlib -l` prints `list(pylabtools.backends)` from inside the magic + (verified in IPython 8.23's `core/magics/pylab.py`), so it never reaches + `enable_matplotlib`. Registering the short names on the switch path instead of at + shell init would therefore leave them out of `-l` until the user had already + switched backends, which is why `PositronShell.init_magics` is the registration site. + + The magic's own `-l` branch can't be driven here, since the installed IPython lists + from matplotlib's registry rather than the static table; this asserts on the table + that legacy `-l` reads. + """ + from IPython.core import pylabtools as pt + + # Simulate IPython < 8.24: no registry-based lister, `-l` reads `pt.backends`. + if hasattr(pt, "_list_matplotlib_backends_and_gui_loops"): + monkeypatch.delattr(pt, "_list_matplotlib_backends_and_gui_loops") + monkeypatch.setattr(pt, "backends", {"inline": INLINE_BACKEND_NAME}) + + # The shell was constructed before the simulation was in place, so re-run the hook + # that registers the names. Notably not `enable_matplotlib`: nothing here switches + # backends, because `-l` has to list them without one. + shell.init_magics() + + assert set(pt.backends) == { + "inline", + Backend.CONSOLE.short_name, + Backend.NOTEBOOK.short_name, + } + + +def test_other_backend_deactivates(shell: PositronShell, positron_backend: BackendCase): + """Switching to another backend removes Positron's hooks instead of leaving them.""" + shell.run_cell(f"%matplotlib {OTHER_BACKEND_NAME}").raise_error() + + assert _state(shell, positron_backend) == { + "backend": OTHER_BACKEND_NAME, + "positron_hooks": 0, + "positron_integration": False, + "inline_hooks": 0, + } + + +def test_switching_back_reactivates(shell: PositronShell, positron_backend: BackendCase): + """`%matplotlib inline` after another backend switches back instead of staying stuck.""" + shell.run_cell(f"%matplotlib {OTHER_BACKEND_NAME}").raise_error() + shell.run_cell("%matplotlib inline").raise_error() + + assert _state(shell, positron_backend) == _positron_active(positron_backend) + + +def test_deactivate_twice_is_noop(shell: PositronShell, positron_backend: BackendCase): + """A second teardown for a non-Positron backend is a no-op, not an error.""" + registry.activate(OTHER_BACKEND_NAME) + registry.activate(OTHER_BACKEND_NAME) + + assert _state(shell, positron_backend) == { + "backend": positron_backend.backend.full_name, + "positron_hooks": 0, + "positron_integration": False, + "inline_hooks": 0, + } + + +def test_inline_backend_config_stays_inert(shell: PositronShell, positron_backend: BackendCase): + """ + `%config InlineBackend.*` stays a no-op across switches that never target inline. + + Positron's backend never instantiates `InlineBackend` into `shell.configurables`, so + the traitlets observer that would re-run `select_figure_formats` -- popping Positron's + figure formatter -- is never armed. + """ + shell.run_cell("%matplotlib inline").raise_error() + shell.run_cell(f"%matplotlib {OTHER_BACKEND_NAME}").raise_error() + shell.run_cell("%matplotlib").raise_error() + + assert not [c for c in shell.configurables if type(c).__name__ == "InlineBackend"] + + shell.run_cell("%config InlineBackend.figure_format = 'svg'").raise_error() + + assert _state(shell, positron_backend) == _positron_active(positron_backend) + + +@pytest.mark.skipif( + not REGISTRY_BACKEND_RESOLUTION, + reason="`%matplotlib module://...` needs registry-based backend resolution", +) +def test_matplotlib_inline_escape_hatch(shell: PositronShell, positron_backend: BackendCase): + """ + The real matplotlib-inline backend stays reachable by its `module://` name. + + Only via `%matplotlib` where backends resolve through matplotlib's registry: without + it, IPython resolves the magic's argument through a static table that has `inline` + but not the `module://` spelling, so the switch raises KeyError before reaching + Positron -- upstream behavior for any `module://` name, not something the override + introduces. `matplotlib.use(INLINE_BACKEND_NAME)` is the escape hatch there instead, + on every version: the backend module self-activates at import, flowing through + Positron's `configure_inline_support` seam. + + Skipped rather than shimmed on the table-based versions (IPython < 8.24 or + matplotlib < 3.9, both 2+ years old; all of Python 3.9, EOL October 2025, is capped + at IPython 8.18): `matplotlib.use` fully covers the escape, the KeyError matches + stock IPython, and the affected stacks only shrink. If a user on one of them ever + needs the magic spelling, `PositronShell.enable_matplotlib` already switches + Positron's own names by hand on exactly these versions, and that recipe extends to + `module://` names in a few lines. + """ + shell.run_cell(f"%matplotlib {INLINE_BACKEND_NAME}").raise_error() + inline_state = _state(shell, positron_backend) + + shell.run_cell("%matplotlib inline").raise_error() + + assert inline_state == { + "backend": INLINE_BACKEND_NAME, + "positron_hooks": 0, + "positron_integration": False, + "inline_hooks": 1, + } + assert _state(shell, positron_backend) == _positron_active(positron_backend) + + +def test_set_matplotlib_formats_patch_installed_while_active(positron_backend: BackendCase): # noqa: ARG001 + """The shared `set_matplotlib_formats` patch is installed while a Positron backend is active.""" + import matplotlib_inline.backend_inline as backend_inline + + assert backend_inline.set_matplotlib_formats is formats._installed_set_matplotlib_formats # noqa: SLF001 + + +def test_set_matplotlib_formats_patch_restored_after_switch( + shell: PositronShell, + positron_backend: BackendCase, # noqa: ARG001 +): + """Switching to a non-Positron backend restores the original `set_matplotlib_formats`.""" + import matplotlib_inline.backend_inline as backend_inline + + original = formats._original_set_matplotlib_formats # noqa: SLF001 + + shell.run_cell(f"%matplotlib {OTHER_BACKEND_NAME}").raise_error() + + assert backend_inline.set_matplotlib_formats is original + + +def test_notebook_to_console_switch_keeps_patch_installed( + shell: PositronShell, + notebook_backend: BackendCase, # noqa: ARG001 +): + """ + A notebook -> console switch keeps the same `set_matplotlib_formats` patch installed. + + The patch's lifecycle is owned by `PositronBackendRegistry` ("a Positron backend is + active"), not by the individual backends, so a cross-backend switch -- which never + lets the active backend become `None` in between -- leaves the same patch object in + place instead of tearing it down and reinstalling a fresh one. + """ + import matplotlib_inline.backend_inline as backend_inline + + installed_before = backend_inline.set_matplotlib_formats + + shell.run_cell(f"%matplotlib {Backend.CONSOLE.short_name}").raise_error() + + assert backend_inline.set_matplotlib_formats is installed_before + assert backend_inline.set_matplotlib_formats is formats._installed_set_matplotlib_formats # noqa: SLF001 + + +def test_install_backend_switch_hook_is_idempotent(): + """Calling `install_backend_switch_hook` twice doesn't double-wrap `configure_inline_support`.""" + import matplotlib_inline.backend_inline as backend_inline + + install_backend_switch_hook() + install_backend_switch_hook() + + assert backend_inline.configure_inline_support is configure_matplotlib_support + assert ( + registry._original_configure_inline_support # noqa: SLF001 + is not configure_matplotlib_support + ) diff --git a/extensions/positron-python/python_files/posit/positron/tests/test_matplotlib_backend/test_formats.py b/extensions/positron-python/python_files/posit/positron/tests/test_matplotlib_backend/test_formats.py new file mode 100644 index 000000000000..f7127b449919 --- /dev/null +++ b/extensions/positron-python/python_files/posit/positron/tests/test_matplotlib_backend/test_formats.py @@ -0,0 +1,246 @@ +# +# Copyright (C) 2026 Posit Software, PBC. All rights reserved. +# Licensed under the Elastic License 2.0. See LICENSE.txt for license information. +# +""" +Tests for `set_matplotlib_formats` support in Positron's matplotlib backends. + +Exercises `matplotlib_backend/formats.py` against the notebook backend, which honours +the call: format selection and aliasing (`retina`/`png2x` -> `png`), kwargs passthrough, +the `set_matplotlib_formats` patch's `from`-import binding, and that a format selection +survives a round trip through a non-Positron backend. Also covers the console backend, +where the call is a silent no-op because figures go to the Plots pane, which negotiates +its own format. +""" + +from __future__ import annotations + +import base64 +import io +from typing import TYPE_CHECKING, Iterator + +import matplotlib +import pytest +from IPython.core.display import _jpegxy, _pngxy +from IPython.utils.capture import RichOutput, capture_output +from matplotlib.backends.backend_agg import FigureCanvasAgg +from PIL import Image + +from positron.matplotlib_backend import formats +from positron.matplotlib_backend.backend import Backend +from positron.matplotlib_backend.registry import registry +from positron.session_mode import SessionMode + +from ..utils import run_with_metadata +from .conftest import active_backend + +if TYPE_CHECKING: + from positron.positron_ipkernel import PositronShell + +# The `from`-import form IPython's own docs recommend, which binds whatever +# `matplotlib_inline.backend_inline.set_matplotlib_formats` is at import time. +_IMPORT_SET_FORMATS = "from matplotlib_inline.backend_inline import set_matplotlib_formats" + +_PLOT_CODE = "import matplotlib.pyplot as plt\nfig, ax = plt.subplots()\nax.plot([0, 1], [0, 1])" + + +@pytest.fixture +def notebook_backend( + shell: PositronShell, monkeypatch: pytest.MonkeyPatch +) -> Iterator[PositronShell]: + """A notebook session with Positron's notebook backend active.""" + monkeypatch.setattr(shell, "session_mode", SessionMode.NOTEBOOK) + with active_backend(Backend.NOTEBOOK): + yield shell + + # `_selected_formats` is module state that intentionally survives a backend round + # trip (see `test_preference_survives_backend_round_trip`), so it doesn't reset + # itself on `deactivate`. Reset it here to the default so a format selected in one + # test doesn't leak into the next (e.g. other test files' backend fixtures expect + # `png` on activation). + formats._selected_formats.clear() # noqa: SLF001 + formats._selected_formats.add("png") # noqa: SLF001 + + +@pytest.fixture +def console_backend( + shell: PositronShell, monkeypatch: pytest.MonkeyPatch +) -> Iterator[PositronShell]: + """A console session with Positron's console backend active.""" + monkeypatch.setattr(shell, "session_mode", SessionMode.CONSOLE) + with active_backend(Backend.CONSOLE): + yield shell + + +def _set_formats(shell: PositronShell, call: str) -> None: + """Run `set_matplotlib_formats(...)` via the `from`-import path IPython documents.""" + shell.run_cell(f"{_IMPORT_SET_FORMATS}\n{call}").raise_error() + + +def _plot(*, meta: dict | None = None) -> RichOutput: + """Run a cell that creates a figure and return its single captured display output.""" + with capture_output() as captured: + run_with_metadata(_PLOT_CODE, meta) + assert len(captured.outputs) == 1 + return captured.outputs[0] + + +def test_svg(notebook_backend: PositronShell): + """`set_matplotlib_formats('svg')` displays only svg, decoded as utf-8 text.""" + _set_formats(notebook_backend, "set_matplotlib_formats('svg')") + + output = _plot() + + assert "image/svg+xml" in output.data + assert "image/png" not in output.data + assert isinstance(output.data["image/svg+xml"], str) + + +def test_retina_aliases_to_png(notebook_backend: PositronShell): + """ + `retina` still renders as `image/png`, at Positron's own device pixel ratio. + + Unlike IPython's `retina_figure` (a hardcoded 2x dpi-doubling on top of Positron's + own DPR handling), Positron already renders at the frontend's actual pixel ratio, + so `retina` is a no-op alias to `png`: the reported logical size matches a ratio=1 + render regardless of the requested ratio, and the actual pixel size scales with + the ratio rather than a hardcoded 2x. + + Uses ratio=3 (not 2): IPython's fixed 2x doubling would coincidentally produce + the same numbers at ratio=2, masking a regression back to the old behavior. + """ + _set_formats(notebook_backend, "set_matplotlib_formats('retina')") + + baseline = _plot(meta={"output_pixel_ratio": 1}) + baseline_w, baseline_h = _pngxy(base64.b64decode(baseline.data["image/png"])) + + ratio = 3 + output = _plot(meta={"output_pixel_ratio": ratio}) + + assert "image/png" in output.data + assert "image/jpeg" not in output.data + w, h = _pngxy(base64.b64decode(output.data["image/png"])) + metadata = output.metadata.get("image/png", {}) + # Actual pixels scale with the requested ratio ... + assert w == pytest.approx(ratio * baseline_w, abs=5) + assert h == pytest.approx(ratio * baseline_h, abs=5) + # ... but the reported logical size stays at the ratio=1 size. + assert metadata["width"] == pytest.approx(baseline_w, abs=5) + assert metadata["height"] == pytest.approx(baseline_h, abs=5) + + +def test_jpeg_with_pixel_ratio(notebook_backend: PositronShell): + """`jpeg` output is present and its metadata reflects the requested pixel ratio.""" + _set_formats(notebook_backend, "set_matplotlib_formats('jpeg')") + + output = _plot(meta={"output_pixel_ratio": 2}) + + assert "image/jpeg" in output.data + jpeg = base64.b64decode(output.data["image/jpeg"]) + w, h = _jpegxy(jpeg) + metadata = output.metadata.get("image/jpeg", {}) + assert metadata["width"] == pytest.approx(w / 2, abs=5) + assert metadata["height"] == pytest.approx(h / 2, abs=5) + + +def test_multiple_formats(notebook_backend: PositronShell): + """`set_matplotlib_formats('png', 'svg')` displays both mimes in one output.""" + _set_formats(notebook_backend, "set_matplotlib_formats('png', 'svg')") + + output = _plot() + + assert "image/png" in output.data + assert "image/svg+xml" in output.data + + +def test_unknown_format_raises(notebook_backend: PositronShell): + """An unrecognized format raises `ValueError`, matching IPython's contract.""" + with pytest.raises(ValueError): + _set_formats(notebook_backend, "set_matplotlib_formats('bmp')") + + +def test_kwargs_passthrough(notebook_backend: PositronShell, monkeypatch: pytest.MonkeyPatch): + """Explicit kwargs (e.g. `pil_kwargs`) reach `canvas.print_figure`.""" + # Asserting on the rendered jpeg bytes would be flaky (encoder-dependent), so spy on + # the call instead. + calls: list[dict] = [] + original_print_figure = FigureCanvasAgg.print_figure + + def spy(self, *args, **kwargs): + calls.append(kwargs) + return original_print_figure(self, *args, **kwargs) + + monkeypatch.setattr(FigureCanvasAgg, "print_figure", spy) + + _set_formats(notebook_backend, "set_matplotlib_formats('jpeg', pil_kwargs={'quality': 90})") + _plot() + + assert len(calls) == 1 + assert calls[0]["pil_kwargs"] == {"quality": 90} + + +def test_from_import_binding(notebook_backend: PositronShell): + """ + `from matplotlib_inline.backend_inline import set_matplotlib_formats` binds ours. + + The `from`-import binds whatever the module attribute is at import time, which is + already Positron's patch: it's installed at backend activation, before any user + code runs. + """ + _set_formats(notebook_backend, "set_matplotlib_formats('svg')") + + output = _plot() + + assert "image/svg+xml" in output.data + # If IPython's own formatters had been installed instead, `image/png` would also + # be present (upstream's `select_figure_formats` only touches formats you ask for, + # but never registers ours, so this also confirms the request routed to Positron's + # `_display_figure` and not upstream's `print_figure`). + assert "image/png" not in output.data + + +def test_savefig_facecolor_rcparam_is_ignored(notebook_backend: PositronShell): + """Inline output uses the figure's own facecolor, not `savefig.facecolor`, matching Jupyter.""" + try: + notebook_backend.run_cell( + "import matplotlib.pyplot as plt\nplt.rcParams['savefig.facecolor'] = 'red'" + ).raise_error() + output = _plot() + png = base64.b64decode(output.data["image/png"]) + image = Image.open(io.BytesIO(png)).convert("RGB") + # The corner comes from the figure's default facecolor, not the red rcParam. + assert image.getpixel((0, 0)) != (255, 0, 0) + finally: + # rcParams are process-global and shared with the test shell; restore. + matplotlib.rcParams["savefig.facecolor"] = matplotlib.rcParamsDefault["savefig.facecolor"] + + +def test_preference_survives_backend_round_trip(notebook_backend: PositronShell): + """A format selection survives deactivating and reactivating the notebook backend.""" + _set_formats(notebook_backend, "set_matplotlib_formats('svg')") + + # Simulate switching to a non-Positron backend and back (e.g. `%matplotlib qt` then + # `%matplotlib inline`). + registry.activate("agg") + registry.activate(Backend.NOTEBOOK) + + output = _plot() + + assert "image/svg+xml" in output.data + + +def test_is_noop_in_console(console_backend: PositronShell): + """ + `set_matplotlib_formats` is a silent no-op in console mode. + + The console backend routes figures to the Plots pane via `canvas.render`, which + negotiates its own format; running IPython's `select_figure_formats` here would + register stray inline `image/*` display formatters alongside it (see + `formats.install_set_matplotlib_formats_patch`). + """ + from matplotlib.figure import Figure + + _set_formats(console_backend, "set_matplotlib_formats('png')") + + for formatter in console_backend.display_formatter.formatters.values(): + assert Figure not in formatter diff --git a/extensions/positron-python/python_files/posit/positron/tests/test_matplotlib_backend/test_no_matplotlib.py b/extensions/positron-python/python_files/posit/positron/tests/test_matplotlib_backend/test_no_matplotlib.py new file mode 100644 index 000000000000..5719c287c6a9 --- /dev/null +++ b/extensions/positron-python/python_files/posit/positron/tests/test_matplotlib_backend/test_no_matplotlib.py @@ -0,0 +1,66 @@ +# +# Copyright (C) 2026 Posit Software, PBC. All rights reserved. +# Licensed under the Elastic License 2.0. See LICENSE.txt for license information. +# +""" +Test that the kernel still imports in an environment without matplotlib. + +The kernel imports `matplotlib_backend.backend` and `.compat` unconditionally, so those +modules -- and `.registry`, which they must not drag matplotlib in through either -- have +to stay matplotlib-free; `console.py`, `notebook.py` and `formats.py` may only be +imported lazily. That invariant is documented in each module, but a stray convenience +import would break every matplotlib-less session silently, so pin it down here. +""" + +from __future__ import annotations + +import subprocess +import sys +from pathlib import Path + +import positron + +# The kernel's import root, so the subprocess resolves `positron` from this checkout. +_IMPORT_ROOT = Path(positron.__file__).parent.parent + +# Run in a subprocess: this test process has matplotlib loaded already, and a meta path +# finder can only block a module that isn't in `sys.modules` yet. +_SCRIPT = """ +import sys + + +class BlockMatplotlib: + "Meta path finder that makes matplotlib and friends look uninstalled." + + def find_spec(self, fullname, path=None, target=None): + if fullname.split(".")[0] in {"matplotlib", "matplotlib_inline", "mpl_toolkits"}: + raise ImportError(fullname) + return None + + +sys.meta_path.insert(0, BlockMatplotlib()) + +# Whatever the kernel imports at startup, plus the one module it defers to first use, so +# breaking the invariant in any of the three fails here rather than only in the field. +import positron.matplotlib_backend.registry # noqa: F401 +import positron.positron_ipkernel # noqa: F401 + +# Guard against a vacuous pass: the kernel really did import the package, and none of +# these imports pulled matplotlib in. +assert "positron.matplotlib_backend.backend" in sys.modules +assert "positron.matplotlib_backend.compat" in sys.modules +assert "matplotlib" not in sys.modules +""" + + +def test_kernel_imports_without_matplotlib(): + """Importing the kernel with matplotlib blocked succeeds and doesn't import matplotlib.""" + result = subprocess.run( + [sys.executable, "-c", _SCRIPT], + cwd=_IMPORT_ROOT, + capture_output=True, + text=True, + check=False, + ) + + assert result.returncode == 0, result.stderr diff --git a/extensions/positron-python/python_files/posit/positron/tests/test_matplotlib_backend/test_notebook.py b/extensions/positron-python/python_files/posit/positron/tests/test_matplotlib_backend/test_notebook.py new file mode 100644 index 000000000000..a537bf4971b1 --- /dev/null +++ b/extensions/positron-python/python_files/posit/positron/tests/test_matplotlib_backend/test_notebook.py @@ -0,0 +1,288 @@ +# +# Copyright (C) 2026 Posit Software, PBC. All rights reserved. +# Licensed under the Elastic License 2.0. See LICENSE.txt for license information. +# + +from __future__ import annotations + +import base64 +from typing import TYPE_CHECKING + +import matplotlib +import matplotlib.pyplot as plt +import pytest +from IPython.core.display import _pngxy +from IPython.utils.capture import RichOutput, capture_output + +from positron.matplotlib_backend.backend import Backend +from positron.session_mode import SessionMode + +from ..utils import run_with_metadata +from .conftest import active_backend + +if TYPE_CHECKING: + from matplotlib.figure import Figure + + from positron.positron_ipkernel import PositronShell + + +DEFAULT_FIGSIZE: list[float] = matplotlib.rcParamsDefault["figure.figsize"] + + +@pytest.fixture(autouse=True) +def _setup(shell, monkeypatch): + """Configure the shell to match the notebook environment.""" + monkeypatch.setattr(shell, "session_mode", SessionMode.NOTEBOOK) + + +@pytest.fixture +def backend(shell): + """A fixture that configures matplotlib to use the Positron notebook backend.""" + with active_backend(Backend.NOTEBOOK): + yield NotebookBackendFixture(shell) + + +def _parse_png_output(output: RichOutput) -> tuple[bytes, dict]: + result = output._repr_png_() + assert result is not None, "Expected a PNG output, but got None." + data, metadata = result if len(result) == 2 else (result, {}) + png = base64.b64decode(data) + return png, metadata + + +class PlotResult: + def __init__(self, figure: Figure, png: bytes, metadata: dict): + self.figure = figure + self.png = png + self.metadata = metadata + + @property + def figure_size(self): + return self.figure.get_size_inches().tolist() + + @property + def png_pixel_size(self): + return _pngxy(self.png) + + +class NotebookBackendFixture: + def __init__(self, shell: PositronShell) -> None: + self.shell = shell + + def import_matplotlib(self) -> None: + self.shell.run_cell("import matplotlib.pyplot as plt").raise_error() + + def run(self, *lines: str) -> list[RichOutput]: + """Run a cell and return its display outputs.""" + with capture_output() as captured: + self.shell.run_cell("\n".join(lines)).raise_error() + return captured.outputs + + def plot( + self, + *, + do_import=True, + figsize: tuple[float, float] | None = None, + meta: dict | None = None, + ): + # Create the code snippet. + lines = [] + if do_import: + lines.append("import matplotlib.pyplot as plt") + subplots_args = f"figsize={figsize}" if figsize is not None else "" + lines.append(f"fig, ax = plt.subplots({subplots_args})") + lines.append("ax.plot([0, 1], [0, 1])") + code = "\n".join(lines) + + # Run the code and capture the output. + with capture_output() as captured: + run_with_metadata(code, meta) + + # Return the created plot. + assert len(captured.outputs) == 1 + png, metadata = _parse_png_output(captured.outputs[0]) + return PlotResult(figure=self.shell.user_ns["fig"], png=png, metadata=metadata) + + +def test_backend_activates(backend): # noqa: ARG001 + assert matplotlib.get_backend() == "module://positron.matplotlib_backend.notebook" + + +def test_figure_size_set_when_matplotlib_already_imported(backend): + """A figure made after matplotlib was imported in an earlier cell is resized.""" + # NOTE: We can't easily guarantee that matplotlib isn't imported since our tests use + # an in-process shell that shares imported modules with the test runner. + # This also makes it hard to test the case where matplotlib is imported + # in the same cell as the figure is created - so we test that manually for now. + backend.import_matplotlib() + result = backend.plot(do_import=False, meta={"fig-width": 8, "fig-height": 4}) + + assert result.figure_size == [8.0, 4.0] + + +def test_explicit_figsize_wins(backend): + """An explicit figsize in the cell beats the pending default.""" + result = backend.plot(figsize=(1, 1), meta={"fig-width": 8, "fig-height": 4}) + assert result.figure_size == [1.0, 1.0] + + +def test_explicit_array_like_figsize_wins(backend): + """An array-like figsize also beats the pending default. + + matplotlib accepts any array-like figsize, e.g. a numpy array, which raises on `bool()`. + """ + run_with_metadata( + "import numpy as np\n" + "import matplotlib.pyplot as plt\n" + "fig, ax = plt.subplots(figsize=np.array([4.0, 3.0]) * 2)", + {"fig-width": 8, "fig-height": 4}, + ) + figure: Figure = backend.shell.user_ns["fig"] + assert figure.get_size_inches().tolist() == [8.0, 6.0] + + +def test_figure_size_does_not_leak_to_later_cell(backend): + """A sized cell does not leak into a later unsized cell.""" + backend.plot(meta={"fig-width": 8, "fig-height": 4}) + assert backend.plot().figure_size == DEFAULT_FIGSIZE + + +@pytest.mark.parametrize( + "meta", + [ + # Non-positive dimension is a no-op. + {"fig-width": 0}, + {"fig-height": 0}, + {"fig-width": -1}, + ], +) +def test_figure_size_noop(backend, meta): + """Invalid sizing metadata leaves the figure at the default size.""" + assert backend.plot(meta=meta).figure_size == DEFAULT_FIGSIZE + + +@pytest.mark.parametrize( + ("meta", "expected"), + [ + ({"fig-width": 8}, [8.0, DEFAULT_FIGSIZE[1]]), + ({"fig-height": 4}, [DEFAULT_FIGSIZE[0], 4.0]), + # A non-positive dimension counts as unspecified. + ({"fig-width": 0, "fig-height": 4}, [DEFAULT_FIGSIZE[0], 4.0]), + ], +) +def test_lone_figure_dimension_uses_default_for_other(backend, meta, expected): + """A lone `fig-width`/`fig-height` applies; the other falls back to the default.""" + assert backend.plot(meta=meta).figure_size == expected + + +def test_lone_figure_dimension_falls_back_to_rc_params(backend): + """The unspecified dimension falls back to the current rcParam, not the stock default.""" + with matplotlib.rc_context({"figure.figsize": (3.0, 2.0)}): + result = backend.plot(meta={"fig-width": 8}) + + assert result.figure_size == [8.0, 2.0] + + +def test_pixel_ratio_scales_png_size_and_attaches_metadata(backend): + """A pixel ratio scales the pixels, not the figure's inch size.""" + base = backend.plot(meta={"fig-width": 8, "fig-height": 4, "output_pixel_ratio": 1}) + scaled = backend.plot(meta={"fig-width": 8, "fig-height": 4, "output_pixel_ratio": 2}) + + # The requested inch size is honored regardless of the pixel ratio. + assert base.figure_size == [8.0, 4.0] + assert scaled.figure_size == [8.0, 4.0] + # Physical pixels ~double ... + assert scaled.png_pixel_size[0] == pytest.approx(2 * base.png_pixel_size[0], abs=5) + assert scaled.png_pixel_size[1] == pytest.approx(2 * base.png_pixel_size[1], abs=5) + # ... while the reported logical size stays at the 1x pixel size. + assert scaled.metadata["width"] == pytest.approx(base.png_pixel_size[0], abs=5) + assert scaled.metadata["height"] == pytest.approx(base.png_pixel_size[1], abs=5) + + +def test_pixel_ratio_missing_is_noop(backend): + """ratio=1 produces the same output as no ratio at all.""" + result = backend.plot() + + assert "width" not in result.metadata + assert "height" not in result.metadata + + +def test_pixel_ratio_one_is_noop(backend): + """ratio=1 produces the same output as no ratio at all.""" + base = backend.plot() + scaled = backend.plot(meta={"output_pixel_ratio": 1}) + + assert scaled.figure_size == DEFAULT_FIGSIZE + assert scaled.png_pixel_size == base.png_pixel_size + assert "width" not in scaled.metadata + assert "height" not in scaled.metadata + + +@pytest.mark.parametrize("ratio", [0, -1]) +def test_pixel_ratio_zero_raises(backend, ratio): + with pytest.raises(ValueError): + backend.plot(meta={"output_pixel_ratio": ratio}) + + +def test_plt_show_displays_figure(backend): + """An explicit `plt.show()` displays the figure inline.""" + outputs = backend.run( + "import matplotlib.pyplot as plt", + "fig, ax = plt.subplots()", + "ax.plot([0, 1], [0, 1])", + "plt.show()", + ) + + # The lone output comes from the explicit `plt.show()`: it closes every figure, so the + # post-execute hook has nothing left to display. + assert len(outputs) == 1 + assert plt.get_fignums() == [] + png, _metadata = _parse_png_output(outputs[0]) + assert png.startswith(b"\x89PNG") + + +def test_figure_show_displays_figure_once(backend): + """An explicit `fig.show()` displays the figure inline, exactly once.""" + outputs = backend.run( + "import matplotlib.pyplot as plt", + "fig, ax = plt.subplots()", + "ax.plot([0, 1], [0, 1])", + "fig.show()", + ) + + # `Figure.show` doesn't close the figure, so the post-execute hook still finds it; it + # skips the figure since the explicit show already displayed it. + assert len(outputs) == 1 + + +def test_repeated_figure_show_displays_each_time(backend): + """Only the automatic end-of-cell display is skipped; explicit shows always display.""" + outputs = backend.run( + "import matplotlib.pyplot as plt", + "fig, ax = plt.subplots()", + "fig.show()", + "ax.plot([0, 1], [0, 1])", + "fig.show()", + ) + + assert len(outputs) == 2 + + +def test_explicit_show_does_not_suppress_a_later_cell(backend): + """A cell's explicit show doesn't suppress the automatic display in the next cell.""" + shown = backend.run("import matplotlib.pyplot as plt", "fig, ax = plt.subplots()", "fig.show()") + assert len(shown) == 1 + + # No explicit show in this cell, so the post-execute hook must display its figure. A + # "displayed" flag that outlived the previous cell would silently swallow the output. + assert len(backend.run("fig, ax = plt.subplots()", "ax.plot([0, 1], [0, 1])")) == 1 + + +def test_figures_do_not_accumulate_across_cells(backend): + """Figures are closed once shown, so a later cell doesn't re-display earlier ones.""" + backend.plot() + assert plt.get_fignums() == [] + + # `plot` asserts a single output, which would also fail if the first figure lingered. + backend.plot() + assert plt.get_fignums() == [] diff --git a/extensions/positron-python/python_files/posit/positron_matplotlib-0.1.0.dist-info/METADATA b/extensions/positron-python/python_files/posit/positron_matplotlib-0.1.0.dist-info/METADATA new file mode 100644 index 000000000000..41838ceed888 --- /dev/null +++ b/extensions/positron-python/python_files/posit/positron_matplotlib-0.1.0.dist-info/METADATA @@ -0,0 +1,17 @@ +Metadata-Version: 2.1 +Name: positron-matplotlib +Version: 0.1.0 +Summary: Registers Positron's matplotlib backends under the names "positron-console" and "positron-notebook". + +This directory is checked into Positron, not produced by a package installer. + +matplotlib discovers named backends through the "matplotlib.backend" entry point +group, which it reads with importlib.metadata -- whose unit of discovery is a +*.dist-info directory on sys.path. This directory sits next to the kernel's +`positron` package, which is on sys.path before matplotlib loads, so the entry +points in entry_points.txt make `%matplotlib positron-console`, +`%matplotlib positron-notebook`, `matplotlib.use("positron-console")` and +`%matplotlib -l` work without installing anything. + +Do not publish this as a standalone distribution: Positron's matplotlib backends +only function inside a Positron session. diff --git a/extensions/positron-python/python_files/posit/positron_matplotlib-0.1.0.dist-info/entry_points.txt b/extensions/positron-python/python_files/posit/positron_matplotlib-0.1.0.dist-info/entry_points.txt new file mode 100644 index 000000000000..1ec0ccdbaf66 --- /dev/null +++ b/extensions/positron-python/python_files/posit/positron_matplotlib-0.1.0.dist-info/entry_points.txt @@ -0,0 +1,3 @@ +[matplotlib.backend] +positron-console = positron.matplotlib_backend.console +positron-notebook = positron.matplotlib_backend.notebook