Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
bdf594c
`current_execute_request` helper
seeM Jul 27, 2026
e9e33f7
add `PositronExecuteRequest.figure_size`
seeM Jul 28, 2026
7688d29
move console matplotlib backend
seeM Jul 28, 2026
e1abd4a
python: respect quarto figure size settings
seeM Jul 24, 2026
41c5add
fix pyright errors and warnings
seeM Jul 29, 2026
d886d5d
wip: support `%matplotlib` to switch backends
seeM Jul 29, 2026
6f838b1
wip: set_matplotlib_formats support
seeM Jul 29, 2026
881f32c
refactor: matplotlib backend registry + simplifications
seeM Jul 29, 2026
7a2079c
registry owns set_matplotlib_formats patch
seeM Jul 29, 2026
3fd9fd4
narrow enable_matplotlib override to a seam
seeM Jul 29, 2026
8952c8e
shared backend activation helper for tests
seeM Jul 29, 2026
3e75ba5
public set_device_pixel_ratio on the notebook canvas
seeM Jul 30, 2026
ac7f616
move matplotlib bootstrap out of init_magics; drop double normalization
seeM Jul 30, 2026
c9dd98b
test explicit show and cross-cell figure cleanup in the notebook backend
seeM Jul 30, 2026
a174078
keep the old test images gitignore path
seeM Jul 30, 2026
5924597
move console set_matplotlib_formats test to test_formats
seeM Jul 30, 2026
78083c2
split matplotlib_backend package into backend/compat/registry modules
seeM Jul 30, 2026
2f79ab0
merge notebook backend fixes and tests
seeM Jul 30, 2026
e76de25
pin the no-matplotlib import invariant with a subprocess test
seeM Jul 30, 2026
b92a752
name the inline self-activation bypass in the switch hook docstring
seeM Jul 30, 2026
4c10306
merge matplotlib_backend package split and registry class
seeM Jul 30, 2026
acf8ad3
move legacy backend registration back to init_magics, with a regressi…
seeM Jul 30, 2026
308544a
don't duplicate the output of an explicit fig.show()
seeM Jul 30, 2026
ab7a641
merge register_with_legacy_ipython revert and -l regression test
seeM Jul 30, 2026
a3126af
merge fig.show() output dedupe
seeM Jul 30, 2026
e3ae6f1
test current_execute_request; fix python 3.9 annotations in test_exec…
seeM Jul 30, 2026
d04ba1f
skip module:// escape hatch test on IPython < 8.24
seeM Jul 31, 2026
c142301
skip module:// escape hatch test without registry backend resolution
seeM Jul 31, 2026
9622930
fix numpy array figure sizes
seeM Jul 31, 2026
bd23bf0
apply a lone fig-width or fig-height, filling the other from rcParams
seeM Jul 31, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions extensions/positron-python/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
#

import logging
from typing import Optional, Union
from typing import Optional, Union, cast

from ._vendor.pydantic import (
BaseModel,
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Original file line number Diff line number Diff line change
@@ -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-<version>.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.
"""
Original file line number Diff line number Diff line change
@@ -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`.
"""
...
Original file line number Diff line number Diff line change
@@ -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 <name>` 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)
Loading
Loading