From 93196654c24dc0b77d9e39da9a093020b85f6443 Mon Sep 17 00:00:00 2001 From: Hassan Kibirige Date: Fri, 14 Aug 2026 17:42:11 +0300 Subject: [PATCH 1/3] fix(themes): accept normal line spacing on Matplotlib 3.10 Matplotlib 3.10 requires numeric line-spacing values. Convert `normal` to its equivalent default, 1.2, so themes render on both supported Matplotlib versions. --- plotnine/_mpl/__init__.py | 8 ++++++++ plotnine/themes/elements/element_text.py | 5 +++++ tests/test_theme.py | 15 +++++++++++++++ 3 files changed, 28 insertions(+) diff --git a/plotnine/_mpl/__init__.py b/plotnine/_mpl/__init__.py index e69de29bb2..6d83afea07 100644 --- a/plotnine/_mpl/__init__.py +++ b/plotnine/_mpl/__init__.py @@ -0,0 +1,8 @@ +import matplotlib as mpl +from packaging.version import Version + +# Matplotlib 3.10 requires numeric line spacing and produces different text +# extents from 3.11. Text elements use this flag to convert `normal`; the test +# suite uses it to require Matplotlib 3.11. Remove the flag and both uses when +# plotnine requires Matplotlib 3.11. +MPL_LT_311 = Version(mpl.__version__) < Version("3.11") diff --git a/plotnine/themes/elements/element_text.py b/plotnine/themes/elements/element_text.py index c6ef9c1b7a..fa73262fab 100644 --- a/plotnine/themes/elements/element_text.py +++ b/plotnine/themes/elements/element_text.py @@ -7,6 +7,8 @@ from contextlib import suppress from typing import TYPE_CHECKING +from plotnine._mpl import MPL_LT_311 + from .element_base import element_base from .margin import margin as Margin @@ -152,6 +154,9 @@ def __init__( with suppress(KeyError): rotation = kwargs.pop("angle") + if MPL_LT_311 and linespacing == "normal": + linespacing = 1.2 + super().__init__() self.properties.update(**kwargs) diff --git a/tests/test_theme.py b/tests/test_theme.py index a7436e9e18..c5f025eae5 100644 --- a/tests/test_theme.py +++ b/tests/test_theme.py @@ -345,3 +345,18 @@ def test_blank_all_text_draws(): # not crash when computing the tick-label padding. (Regression) p = ggplot() + lims(x=(0, 100), y=(0, 100)) + theme(text=element_blank()) p.draw_test() # pyright: ignore # must not raise + + +def test_element_text_linespacing_normal(): + # Matplotlib 3.10 uses `1.2` for the `normal` line spacing that + # Matplotlib 3.11 accepts by name. + from plotnine._mpl import MPL_LT_311 + + expected = 1.2 if MPL_LT_311 else "normal" + assert ( + element_text(linespacing="normal").properties["linespacing"] + == expected + ) + assert ( + element_text(lineheight="normal").properties["linespacing"] == expected + ) From cca3d63ea0cb1ab84ee4419a4f892c471fb45ced Mon Sep 17 00:00:00 2001 From: Hassan Kibirige Date: Fri, 14 Aug 2026 17:45:25 +0300 Subject: [PATCH 2/3] fix(sec_axis): support ticks and limits on Matplotlib 3.10 Matplotlib 3.10 raised `StopIteration` when setting secondary-axis ticks or limits because it could not resolve the axis name. Panels now expose both primary and secondary axes through Matplotlib's name lookup. --- plotnine/_mpl/axes.py | 37 ++++++++++++++++++++++++++++--------- tests/test_sec_axis.py | 12 ++++++++++++ 2 files changed, 40 insertions(+), 9 deletions(-) diff --git a/plotnine/_mpl/axes.py b/plotnine/_mpl/axes.py index 9b89c280f9..3fc712b968 100644 --- a/plotnine/_mpl/axes.py +++ b/plotnine/_mpl/axes.py @@ -32,8 +32,8 @@ class p9Axes(Axes): name = "plotnine" - # mpl resolves every Axis operation through the per-name axis - # registries; the secondary axes register under their own names. + # Matplotlib looks up shared axes in the registry for each axis name. + # Secondary axes use distinct names. _shared_axes = { **Axes._shared_axes, # pyright: ignore[reportAttributeAccessIssue] "sec_x": cbook.Grouper(), @@ -59,6 +59,27 @@ def __init__(self, *args, **kwargs): # the spine of the side it occupies. self.spines[:].set_visible(False) + @property + def _axis_map(self) -> dict[str, XAxis | YAxis]: + """ + Mapping from Matplotlib axis names to panel axes + + Matplotlib uses this mapping to resolve tick and limit operations. + """ + m: dict[str, XAxis | YAxis] = {"x": self.xaxis, "y": self.yaxis} + if self.sec_xaxis is not None: + m["sec_x"] = self.sec_xaxis + if self.sec_yaxis is not None: + m["sec_y"] = self.sec_yaxis + return m + + @_axis_map.setter + def _axis_map(self, value: dict[str, XAxis | YAxis]): # pyright: ignore[reportIncompatibleVariableOverride] + # Matplotlib 3.11 and later assign the primary axes here during panel + # initialisation. The getter derives that mapping from the panel, so + # ignore the assigned value. + ... + def add_sec_axis(self, side: Side) -> XAxis | YAxis: """ Return the secondary axis for `side`, creating it if needed @@ -78,19 +99,17 @@ def add_sec_axis(self, side: Side) -> XAxis | YAxis: """ if side in ("top", "bottom"): if self.sec_xaxis is None: - self.sec_xaxis = self._make_sec_axis(XAxis, "sec_x") + self.sec_xaxis = self._make_sec_axis(XAxis) return self.sec_xaxis else: if self.sec_yaxis is None: - self.sec_yaxis = self._make_sec_axis(YAxis, "sec_y") + self.sec_yaxis = self._make_sec_axis(YAxis) return self.sec_yaxis - def _make_sec_axis(self, cls: type[AxisT], name: str) -> AxisT: + def _make_sec_axis(self, cls: type[AxisT]) -> AxisT: axis = cls(self) - # Register the axis so mpl can resolve its name, and add it to - # the draw tree. Panels are never cleared after this point; - # Axes.clear() would detach the artist. - self._axis_map[name] = axis + # Add the axis to the draw tree. Plotnine does not clear the panel + # after this point because clearing it would detach the secondary axis. self.add_artist(axis) axis.set_clip_on(False) axis.grid(False) diff --git a/tests/test_sec_axis.py b/tests/test_sec_axis.py index 2b5861dcc0..2e7b233dfb 100644 --- a/tests/test_sec_axis.py +++ b/tests/test_sec_axis.py @@ -138,3 +138,15 @@ def test_facet_wrap_sec_axis(): + scale_y_continuous(sec_axis=sec_axis(lambda y: y * 2, name="2x")) ) assert p == "facet_wrap_sec_axis" + + +def test_secondary_axis_resolves_its_name(): + # Matplotlib resolves tick and limit operations through the panel's named + # axes. Preserve distinct names for the primary and secondary axes. + plot = p0 + scale_y_continuous(sec_axis=dup_axis()) + plot.draw_test() + ax = plot.axs[0] + + assert ax._axis_map["sec_y"] is ax.sec_yaxis + assert ax.sec_yaxis._get_axis_name() == "sec_y" + assert ax._axis_map["y"] is ax.yaxis From cb1fc22f24abc1d4919431a2f467ae4a95dcf77c Mon Sep 17 00:00:00 2001 From: Hassan Kibirige Date: Fri, 14 Aug 2026 17:48:03 +0300 Subject: [PATCH 3/3] build: support Pyodide's Matplotlib and statsmodels versions Pyodide supplies Matplotlib 3.10.8 and statsmodels 0.14.6 as platform packages. plotnine's previous requirements excluded both versions. Allow Matplotlib 3.10 and require statsmodels 0.14.6 on every platform. Tests continue to require Matplotlib 3.11 or later because the existing baseline images use Matplotlib 3.11 text metrics. --- doc/changelog.qmd | 8 ++++++++ pyproject.toml | 10 ++++------ tests/conftest.py | 9 +++++++++ 3 files changed, 21 insertions(+), 6 deletions(-) diff --git a/doc/changelog.qmd b/doc/changelog.qmd index b17fb867d1..83fd02d505 100644 --- a/doc/changelog.qmd +++ b/doc/changelog.qmd @@ -110,6 +110,14 @@ title: Changelog `geom="blank"`, `position="identity"` and `na_rm=False`, so all geoms and stats include them automatically. +- plotnine now supports Matplotlib 3.10. This allows installation under + Pyodide. Matplotlib 3.10 and 3.11 use different text metrics, so text + extents, line spacing, and plot margins may differ. Use Matplotlib 3.11 or + later to reproduce the documentation output. + +- `statsmodels>=0.14.6` is now required on every platform. This replaces the + separate `statsmodels<=0.14.4` requirement for Pyodide. + ### Bug Fixes - [](:class:`~plotnine.scale_size_datetime`) now honours its `range` diff --git a/pyproject.toml b/pyproject.toml index c9d84149d0..446d454530 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,16 +23,12 @@ classifiers = [ "Topic :: Scientific/Engineering :: Visualization" ] dependencies = [ - "matplotlib>=3.11.0", + "matplotlib>=3.10.0", "pandas>=2.2.0", "mizani~=0.14.0", "numpy>=1.25.0", "scipy>=1.15.0", - # pyodide ships with statmodels==0.14.4 which is broken for later versions - # of numpy and pandas - # https://github.com/statsmodels/statsmodels/releases/tag/v0.14.6 - "statsmodels<=0.14.4; sys_platform == 'emscripten'", - "statsmodels>=0.14.6; sys_platform != 'emscripten'", + "statsmodels>=0.14.6", ] requires-python = ">=3.11" @@ -71,6 +67,8 @@ lint = [ ] test = [ + # The baseline images use text metrics from Matplotlib 3.11. + "matplotlib>=3.11.0", "pytest-cov>=4.0.0", "pytest-xdist>=3.8.0", "pytest-sugar>=1.1.1", diff --git a/tests/conftest.py b/tests/conftest.py index 0961fe8e30..b9fc6ae25d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -11,6 +11,7 @@ from matplotlib.testing.compare import compare_images from plotnine import ggplot, theme +from plotnine._mpl import MPL_LT_311 from plotnine.composition import ( Beside, Compose, @@ -50,6 +51,14 @@ "test data." ) +if MPL_LT_311: + raise OSError( + "Install Matplotlib>=3.11 to run the tests. The baseline images use " + "Matplotlib 3.11 text metrics, which differ from those in installed " + f"Matplotlib {mpl.__version__}. plotnine itself supports " + "Matplotlib>=3.10." + ) + def raise_no_baseline_image(filename: str): raise Exception(f"Baseline image {filename} is missing")