Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
8 changes: 8 additions & 0 deletions doc/changelog.qmd
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
8 changes: 8 additions & 0 deletions plotnine/_mpl/__init__.py
Original file line number Diff line number Diff line change
@@ -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")
37 changes: 28 additions & 9 deletions plotnine/_mpl/axes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand All @@ -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
Expand All @@ -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)
Expand Down
5 changes: 5 additions & 0 deletions plotnine/themes/elements/element_text.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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)

Expand Down
10 changes: 4 additions & 6 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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",
Expand Down
9 changes: 9 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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")
Expand Down
12 changes: 12 additions & 0 deletions tests/test_sec_axis.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
15 changes: 15 additions & 0 deletions tests/test_theme.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
Loading