Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Rationalise artist get_figure methods; make figure attribute a property #28177

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Jul 11, 2024
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
3 changes: 3 additions & 0 deletions ci/mypy-stubtest-allowlist.txt
Original file line number Diff line number Diff line change
Expand Up @@ -46,3 +46,6 @@ matplotlib.tri.*TriInterpolator.gradient
matplotlib.backend_bases.FigureCanvasBase._T
matplotlib.backend_managers.ToolManager._T
matplotlib.spines.Spine._T

# Parameter inconsistency due to 3.10 deprecation
matplotlib.figure.FigureBase.get_figure
7 changes: 7 additions & 0 deletions doc/api/next_api_changes/behavior/28177-REC.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
(Sub)Figure.get_figure
~~~~~~~~~~~~~~~~~~~~~~

...in future will by default return the direct parent figure, which may be a SubFigure.
This will make the default behavior consistent with the
`~matplotlib.artist.Artist.get_figure` method of other artists. To control the
behavior, use the newly introduced *root* parameter.
5 changes: 5 additions & 0 deletions doc/api/next_api_changes/deprecations/28177-REC.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
(Sub)Figure.set_figure
~~~~~~~~~~~~~~~~~~~~~~

...is deprecated and in future will always raise an exception. The parent and
root figures of a (Sub)Figure are set at instantiation and cannot be changed.
37 changes: 26 additions & 11 deletions lib/matplotlib/artist.py
Original file line number Diff line number Diff line change
Expand Up @@ -181,7 +181,7 @@ def __init__(self):
self._stale = True
self.stale_callback = None
self._axes = None
self.figure = None
self._parent_figure = None

self._transform = None
self._transformSet = False
Expand Down Expand Up @@ -251,7 +251,7 @@ def remove(self):
if self.figure:
if not _ax_flag:
self.figure.stale = True
self.figure = None
self._parent_figure = None

else:
raise NotImplementedError('cannot remove artist')
Expand Down Expand Up @@ -720,34 +720,49 @@ def set_path_effects(self, path_effects):
def get_path_effects(self):
return self._path_effects

def get_figure(self):
"""Return the `.Figure` instance the artist belongs to."""
return self.figure
def get_figure(self, root=False):
"""
Return the `.Figure` or `.SubFigure` instance the artist belongs to.

Parameters
----------
root : bool, default=False
If False, return the (Sub)Figure this artist is on. If True,
return the root Figure for a nested tree of SubFigures.
"""
if root and self._parent_figure is not None:
return self._parent_figure.get_figure(root=True)

return self._parent_figure

def set_figure(self, fig):
"""
Set the `.Figure` instance the artist belongs to.
Set the `.Figure` or `.SubFigure` instance the artist belongs to.

Parameters
----------
fig : `~matplotlib.figure.Figure`
fig : `~matplotlib.figure.Figure` or `~matplotlib.figure.SubFigure`
"""
# if this is a no-op just return
if self.figure is fig:
if self._parent_figure is fig:
return
# if we currently have a figure (the case of both `self.figure`
# and *fig* being none is taken care of above) we then user is
# trying to change the figure an artist is associated with which
# is not allowed for the same reason as adding the same instance
# to more than one Axes
if self.figure is not None:
if self._parent_figure is not None:
raise RuntimeError("Can not put single artist in "
"more than one figure")
self.figure = fig
if self.figure and self.figure is not self:
self._parent_figure = fig
if self._parent_figure and self._parent_figure is not self:
self.pchanged()
self.stale = True

figure = property(get_figure, set_figure,
doc=("The (Sub)Figure that the artist is on. For more "
"control, use the `get_figure` method."))

def set_clip_box(self, clipbox):
"""
Set the artist's clip `.Bbox`.
Expand Down
7 changes: 4 additions & 3 deletions lib/matplotlib/artist.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,8 @@ class _Unset: ...
class Artist:
zorder: float
stale_callback: Callable[[Artist, bool], None] | None
figure: Figure | SubFigure | None
@property
def figure(self) -> Figure | SubFigure: ...
clipbox: BboxBase | None
def __init__(self) -> None: ...
def remove(self) -> None: ...
Expand Down Expand Up @@ -87,8 +88,8 @@ class Artist:
) -> None: ...
def set_path_effects(self, path_effects: list[AbstractPathEffect]) -> None: ...
def get_path_effects(self) -> list[AbstractPathEffect]: ...
def get_figure(self) -> Figure | None: ...
def set_figure(self, fig: Figure) -> None: ...
def get_figure(self, root: bool = ...) -> Figure | SubFigure | None: ...
def set_figure(self, fig: Figure | SubFigure) -> None: ...
def set_clip_box(self, clipbox: BboxBase | None) -> None: ...
def set_clip_path(
self,
Expand Down
2 changes: 1 addition & 1 deletion lib/matplotlib/axes/_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -1296,7 +1296,7 @@ def __clear(self):
self._gridOn = mpl.rcParams['axes.grid']
old_children, self._children = self._children, []
for chld in old_children:
chld.axes = chld.figure = None
chld.axes = chld._parent_figure = None
self._mouseover_set = _OrderedSet()
self.child_axes = []
self._current_image = None # strictly for pyplot via _sci, _gci
Expand Down
4 changes: 2 additions & 2 deletions lib/matplotlib/axes/_base.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ from matplotlib.cm import ScalarMappable
from matplotlib.legend import Legend
from matplotlib.lines import Line2D
from matplotlib.gridspec import SubplotSpec, GridSpec
from matplotlib.figure import Figure
from matplotlib.figure import Figure, SubFigure
from matplotlib.image import AxesImage
from matplotlib.patches import Patch
from matplotlib.scale import ScaleBase
Expand Down Expand Up @@ -81,7 +81,7 @@ class _AxesBase(martist.Artist):
def get_subplotspec(self) -> SubplotSpec | None: ...
def set_subplotspec(self, subplotspec: SubplotSpec) -> None: ...
def get_gridspec(self) -> GridSpec | None: ...
def set_figure(self, fig: Figure) -> None: ...
def set_figure(self, fig: Figure | SubFigure) -> None: ...
@property
def viewLim(self) -> Bbox: ...
def get_xaxis_transform(
Expand Down
66 changes: 64 additions & 2 deletions lib/matplotlib/figure.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
from contextlib import ExitStack
import inspect
import itertools
import functools
import logging
from numbers import Integral
import threading
Expand Down Expand Up @@ -227,6 +228,67 @@ def get_children(self):
*self.legends,
*self.subfigs]

def get_figure(self, root=None):
"""
Return the `.Figure` or `.SubFigure` instance the (Sub)Figure belongs to.

Parameters
----------
root : bool, default=True
If False, return the (Sub)Figure this artist is on. If True,
return the root Figure for a nested tree of SubFigures.

.. deprecated:: 3.10

From version 3.12 *root* will default to False.
"""
if self._root_figure is self:
# Top level Figure
return self

if self._parent is self._root_figure:
# Return early to prevent the deprecation warning when *root* does not
# matter
return self._parent

if root is None:
# When deprecation expires, consider removing the docstring and just
# inheriting the one from Artist.
message = ('From Matplotlib 3.12 SubFigure.get_figure will by default '
'return the direct parent figure, which may be a SubFigure. '
'To suppress this warning, pass the root parameter. Pass '
'`True` to maintain the old behavior and `False` to opt-in to '
'the future behavior.')
_api.warn_deprecated('3.10', message=message)
root = True

if root:
return self._root_figure

return self._parent

def set_figure(self, fig):
"""
.. deprecated:: 3.10
Currently this method will raise an exception if *fig* is anything other
than the root `.Figure` this (Sub)Figure is on. In future it will always
raise an exception.
"""
no_switch = ("The parent and root figures of a (Sub)Figure are set at "
"instantiation and cannot be changed.")
if fig is self._root_figure:
_api.warn_deprecated(
"3.10",
message=(f"{no_switch} From Matplotlib 3.12 this operation will raise "
"an exception."))
return

raise ValueError(no_switch)

figure = property(functools.partial(get_figure, root=True), set_figure,
doc=("The root `Figure`. To get the parent of a `SubFigure`, "
"use the `get_figure` method."))

def contains(self, mouseevent):
"""
Test whether the mouse event occurred on the figure.
Expand Down Expand Up @@ -2222,7 +2284,7 @@ def __init__(self, parent, subplotspec, *,

self._subplotspec = subplotspec
self._parent = parent
self.figure = parent.figure
self._root_figure = parent._root_figure

# subfigures use the parent axstack
self._axstack = parent._axstack
Expand Down Expand Up @@ -2503,7 +2565,7 @@ def __init__(self,
%(Figure:kwdoc)s
"""
super().__init__(**kwargs)
self.figure = self
self._root_figure = self
self._layout_engine = None

if layout is not None:
Expand Down
6 changes: 4 additions & 2 deletions lib/matplotlib/figure.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -260,7 +260,8 @@ class FigureBase(Artist):
) -> dict[Hashable, Axes]: ...

class SubFigure(FigureBase):
figure: Figure
@property
def figure(self) -> Figure: ...
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@ksunden can you advise what I should do here? It is a fact of the implementation that (Sub)Figure.figure can only ever be a Figure. It is also a fact that the setter can at best be a no-op, so it doesn't really make sense to me to have a type hint encouraging the user to set it. I can add the setter, but MyPy still complains about Figure being stricter than the parent's Union[Figure, SubFigure]. Adding this to the allowlist doesn't seem to make any difference. I'm stumped.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Firstly, the allow list is only for stubtest, and this is breaking a mypy check, so that is why that is not helping. A # type: ignore comment could be added, but that is a bit more of a last resort. (stubtest requires mypy to pass and runs it first, part of why it takes so long)

Secondly, is there a reason to be more strict here? Subfigures can be nested inside of subfigures, can they not? Type narrowing for a subclass isn't actually the problem here, but also not clear to me why it is narrowed here. (Not fully sure why you get two errors for each... but both are resolved below without adjusting their own type hints...)

Unfortunately, getting rid of the setter is breaking a pretty fundamental Object Orientation principle that all things that are provided by the parent class can be done on instances of child classes... and the method still exists, so I think type hinting it is fine...

That said, I would not be too opposed to making Artist.figure a read-only (type hinted at least) attribute, since I think writing to it (without using Artist.set_figure) is generally a mistake).

If I comment out the two lines from artist.pyi, I get no mypy issues found.

diff --git a/lib/matplotlib/artist.pyi b/lib/matplotlib/artist.pyi
index 08a99c0ed1..730c530dd2 100644
--- a/lib/matplotlib/artist.pyi
+++ b/lib/matplotlib/artist.pyi
@@ -33,8 +33,8 @@ class Artist:
     stale_callback: Callable[[Artist, bool], None] | None
     @property
     def figure(self) -> Figure | SubFigure: ...
-    @figure.setter
-    def figure(self, fig: Figure | SubFigure): ...
+    #@figure.setter
+    #def figure(self, fig: Figure | SubFigure): ...
     clipbox: BboxBase | None
     def __init__(self) -> None: ...
     def remove(self) -> None: ...

In implementation, Artist.figure is a standard instance attribute, made in __init__, so it is writable by anyone... but I'm good with making the type hint stricter than that (even without making the implementation enforce it)

Also, looking back... the type hint is technically wrong, as artist.figure can in fact be None... but that may just be a "useful fib", since 99% of artists that you have will in fact have a figure, and so checking for the none case in all usages is perhaps not something we wish to enforce with type hints...

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re type narrowing, I guess actually closely reading the code, you do narrow, so not opposed to encapsulating that meaning if that is what the code does... but do have some slight hesitations there, particularly if standard artists don't get root for art.figure... I'd have leaned towards not special casing Figure there...

Copy link
Member Author

@rcomer rcomer May 13, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is there a reason to be more strict here? Subfigures can be nested inside of subfigures, can they not?

Unfortunately we have a mismatch in what this property is: Artist.figure is the parent (Sub)Figure but SubFigure.figure is the root Figure. See #28170 (comment) for why it's difficult to change that.

[slow x-post]

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

#28170 (comment) suggested discouraging use of the figure attribute. Would it therefore be reasonable to just not type hint it? Looks like that would require a few updates in pyplot to use get_figure instead, but that's simple enough.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The motivation for moving from figure to get_figure() is clarity - do we want the parent or the root? Internally, we should not be using figure anymore. For now, it should only stay available for backward compatibility. Whether or not we want to deprecate it and force downstream users to migrate can be decided later.

I'm not clear what the effect of not type-hinting is. Would it not show up in completions? Would there be warnings on incomplete typing?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm (clearly!) no expert on type hinting but I took out the hint for this property in my branch and the mypy-precommit flagged failures in pyplot.

Internally, we should not be using figure anymore.

Should internal usage be removed in this PR or could it wait for a follow-up? I lean towards doing it separately as I think the current change stands by itself and should be useful for the subfigure_mosaic work (#28170 (comment)). I have started having a go at removing the internal use but there is, um, rather a lot of it and not every instance is obvious to me whether it should be root or not. So I think separating those concerns off into a separate PR may make the review easier.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sure, the internal refactoring can be a follow-up.

and not every instance is obvious to me whether it should be root or not

That's part of the motivation to get rid of it 😏. I also won't guarantee that the current usages are always correct. Subfigures (and in particular nested subfigures) are fairly recent and thus not that common.

subplotpars: SubplotParams
dpi_scale_trans: Affine2D
transFigure: Transform
Expand Down Expand Up @@ -298,7 +299,8 @@ class SubFigure(FigureBase):
def get_axes(self) -> list[Axes]: ...

class Figure(FigureBase):
figure: Figure
@property
def figure(self) -> Figure: ...
bbox_inches: Bbox
dpi_scale_trans: Affine2D
bbox: BboxBase
Expand Down
6 changes: 3 additions & 3 deletions lib/matplotlib/offsetbox.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import matplotlib.artist as martist
from matplotlib.backend_bases import RendererBase, Event, FigureCanvasBase
from matplotlib.colors import Colormap, Normalize
import matplotlib.text as mtext
from matplotlib.figure import Figure
from matplotlib.figure import Figure, SubFigure
from matplotlib.font_manager import FontProperties
from matplotlib.image import BboxImage
from matplotlib.patches import FancyArrowPatch, FancyBboxPatch
Expand All @@ -26,7 +26,7 @@ class OffsetBox(martist.Artist):
width: float | None
height: float | None
def __init__(self, *args, **kwargs) -> None: ...
def set_figure(self, fig: Figure) -> None: ...
def set_figure(self, fig: Figure | SubFigure) -> None: ...
def set_offset(
self,
xy: tuple[float, float]
Expand Down Expand Up @@ -271,7 +271,7 @@ class AnnotationBbox(martist.Artist, mtext._AnnotationBase):
| Callable[[RendererBase], Bbox | Transform],
) -> None: ...
def get_children(self) -> list[martist.Artist]: ...
def set_figure(self, fig: Figure) -> None: ...
def set_figure(self, fig: Figure | SubFigure) -> None: ...
def set_fontsize(self, s: str | float | None = ...) -> None: ...
def get_fontsize(self) -> float: ...
def get_tightbbox(self, renderer: RendererBase | None = ...) -> Bbox: ...
Expand Down
4 changes: 2 additions & 2 deletions lib/matplotlib/quiver.pyi
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import matplotlib.artist as martist
import matplotlib.collections as mcollections
from matplotlib.axes import Axes
from matplotlib.figure import Figure
from matplotlib.figure import Figure, SubFigure
from matplotlib.text import Text
from matplotlib.transforms import Transform, Bbox

Expand Down Expand Up @@ -49,7 +49,7 @@ class QuiverKey(martist.Artist):
) -> None: ...
@property
def labelsep(self) -> float: ...
def set_figure(self, fig: Figure) -> None: ...
def set_figure(self, fig: Figure | SubFigure) -> None: ...

class Quiver(mcollections.PolyCollection):
X: ArrayLike
Expand Down
34 changes: 34 additions & 0 deletions lib/matplotlib/tests/test_artist.py
Original file line number Diff line number Diff line change
Expand Up @@ -562,3 +562,37 @@ def draw(self, renderer, extra):

assert 'aardvark' == art.draw(renderer, 'aardvark')
assert 'aardvark' == art.draw(renderer, extra='aardvark')


def test_get_figure():
fig = plt.figure()
sfig1 = fig.subfigures()
sfig2 = sfig1.subfigures()
ax = sfig2.subplots()

assert fig.get_figure(root=True) is fig
assert fig.get_figure(root=False) is fig

assert ax.get_figure() is sfig2
assert ax.get_figure(root=False) is sfig2
assert ax.get_figure(root=True) is fig

# SubFigure.get_figure has separate implementation but should give consistent
# results to other artists.
assert sfig2.get_figure(root=False) is sfig1
assert sfig2.get_figure(root=True) is fig
# Currently different results by default.
with pytest.warns(mpl.MatplotlibDeprecationWarning):
assert sfig2.get_figure() is fig
# No deprecation warning if root and parent figure are the same.
assert sfig1.get_figure() is fig

# An artist not yet attached to anything has no figure.
ln = mlines.Line2D([], [])
assert ln.get_figure(root=True) is None
assert ln.get_figure(root=False) is None

# figure attribute is root for (Sub)Figures but parent for other artists.
assert ax.figure is sfig2
assert fig.figure is fig
assert sfig2.figure is fig
16 changes: 16 additions & 0 deletions lib/matplotlib/tests/test_figure.py
Original file line number Diff line number Diff line change
Expand Up @@ -1735,6 +1735,22 @@ def test_warn_colorbar_mismatch():
subfig3_1.colorbar(im4_1)


def test_set_figure():
fig = plt.figure()
sfig1 = fig.subfigures()
sfig2 = sfig1.subfigures()

for f in fig, sfig1, sfig2:
with pytest.warns(mpl.MatplotlibDeprecationWarning):
f.set_figure(fig)

with pytest.raises(ValueError, match="cannot be changed"):
sfig2.set_figure(sfig1)

with pytest.raises(ValueError, match="cannot be changed"):
sfig1.set_figure(plt.figure())


def test_subfigure_row_order():
# Test that subfigures are drawn in row-major order.
fig = plt.figure()
Expand Down
Loading