Skip to content
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

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

Open
wants to merge 1 commit into
base: main
Choose a base branch
from

Conversation

rcomer
Copy link
Member

@rcomer rcomer commented May 6, 2024

PR summary

Closes #28170.

  • Introduce the root parameter to get_figure and make the behaviour consistent between SubFigure and other artists.
  • Turn the figure attribute into a property so that users cannot set the figure without going through the checks of the set_figure method.

Not included here is my attempt to find/replace all internal use of the figure attribute with _figure, which would obviously be necessary if we want to deprecate the figure property. I gave up when I realised we have other objects that have the attribute but are not artists (GridSpec, FigureCanvasBase, maybe others) which will make it more complicated to find what does and doesn't need to change. I have my progress so far saved in a commit in case we do decide we want to do that. Is there any performance consideration for making the internal use go through the get and set methods?

PR checklist

@@ -2239,6 +2239,20 @@ def __init__(self, parent, subplotspec, *,
self._set_artist_props(self.patch)
self.patch.set_antialiased(False)

def get_figure(self, root=None):
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 did not include a docstring here because this method is not included in figure_api.rst

Copy link
Member

Choose a reason for hiding this comment

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

Not sure whether not listing in the docs is an oversight. But even if it's for internal use only, a docstring is helpful.

If you don't do anything, this inherits the docstring from Artist. If that is the intended behavior, we mark this with the comment # docstring inherited. If you'd like a different phrasing for subfigures, please add an explicit docstring.

Copy link
Member Author

Choose a reason for hiding this comment

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

Actually I guess it would also still show for any user that does something like sfig.getfigure? in ipython. Currently it will need a different docstring for the deprecated behaviour.

return the root Figure for a nested tree of SubFigures.
"""
if root:
return self._figure._figure
Copy link
Member

Choose a reason for hiding this comment

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

Does this hold in general, i.e. also for deeper subfigure nestings?

Copy link
Member Author

Choose a reason for hiding this comment

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

My test has the axes on the second subfigure down.

Copy link
Member

Choose a reason for hiding this comment

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

Ok, I now see that this is technically correct: From #28170 (comment)

[for subfigures] I explicitly set self.figure = parent.figure which therefore resolves to the root figure, and I think that is because artists have things like self.figure.stale and self.figure.suppressComposite which would somehow have to magically thread themselves through.

But still needs some explanation, because it looks confusing: self._figure is the enclosing (sub)figure. and the _figure attribute of that is always the root figure. Maybe it helps to rename Artist._figure to Artist._enclosing_figure. Also, the second access is to a private attribute of (sub)figures, which general artists have no business with - we're relying on an implementation detail of a different class. Possibly a better spelling would be

Suggested change
return self._figure._figure
return self._enclosing_figure.get_figure(root=True)

Copy link
Member

Choose a reason for hiding this comment

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

I'd go with _root_figure to make the tree structure clear.

Copy link
Member Author

Choose a reason for hiding this comment

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

Oh OK, it never occurred to me that we shouldn't use private attributes on another class in the same package. But that means we shouldn't use self._enclosing_figure._root_figure so @jklymak have I misunderstood your comment?

Copy link
Member

Choose a reason for hiding this comment

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

@jklymak Am I correct here? Currently the _figure attribute semantics is

  • _root_figure for subfigures
  • _enclosing_figure for all other artists
  • self for Figure

Copy link
Member

Choose a reason for hiding this comment

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

Yeah, I thought you meant the _root_figure by _enclosing_figure. Maybe call the artists' subfigure the _parent_figure or _parent_subfigure if we want to be more specific. I'd keep with standard tree terminology? https://en.wikipedia.org/wiki/Tree_(data_structure)#:~:text=A%20node%20that%20has%20a,same%20parent%20are%20sibling%20nodes.

Copy link
Member Author

@rcomer rcomer May 7, 2024

Choose a reason for hiding this comment

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

I think _parent_figure as it may be either a Figure or a SubFigure. I note also that we already have SubFigure.parent, so it's good to be consistent with that.

lib/matplotlib/artist.py Outdated Show resolved Hide resolved
@rcomer
Copy link
Member Author

rcomer commented May 8, 2024

Next question: what should fig.get_figure(root=False) do if fig is a Figure instance? It has no parent to return, so maybe it should return None or even error. However that doesn’t seem like a useful thing for any method to do by default, so my inclination is to declare Figure is a special case that ignores the root parameter and always returns itself.

@timhoffm
Copy link
Member

timhoffm commented May 8, 2024

Yes, the root figure of a Figure is the figure itself.

@rcomer
Copy link
Member Author

rcomer commented May 8, 2024

Yes, the root figure of a Figure is the figure itself.

Right, so there is no ambiguity if root=True, but after the deprecation period root=False will be the default.

@jklymak
Copy link
Member

jklymak commented May 8, 2024

It should return the (sub)figure one level above, which in this case is the root figure.

@rcomer rcomer force-pushed the get_figure branch 2 times, most recently from 7345058 to 73c0840 Compare May 8, 2024 20:02
@rcomer
Copy link
Member Author

rcomer commented May 8, 2024

OK, I found many and varied ways to break the tests but I think I have things straightened out now.

My current problem is that MyPy says "Read-only property cannot override read-write property". So if Artist.figure is writeable then FigureBase.figure also has to be writeable. But since figures are instantiated with their parent and Artist.set_figure doesn't allow switching to a different figure, it makes sense to me that FigureBase.figure should not be writeable 😕

@rcomer
Copy link
Member Author

rcomer commented May 9, 2024

Actually there is still more to do here (aside from placating MyPy) as currently FigureBase.set_figure will just set a private attribute that never gets used for anything 😬

@rcomer rcomer marked this pull request as draft May 9, 2024 15:23
@rcomer rcomer added this to the v3.10.0 milestone May 9, 2024
Comment on lines 263 to 286
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.deprecation("3.10",
message=(f"{no_switch} From Matplotlib 3.12 this "
"operation will raise an exception."))
return

raise ValueError(no_switch)
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 not 100% sure this is the best solution here, but it's based on the following considerations:

  1. Since the parent figure is set at instantiation and Artist.set_figure does not allow switching to a different figure, (Sub)Figure.set_figure will always either be a no-op or raise an exception.
  2. For consistency with Artist.set_figure, the no-op should be on the parent figure.
  3. For backwards compatibility and consistency with the (Sub)Figure.figure property, the no-op should be on the root figure.

Copy link
Member

Choose a reason for hiding this comment

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

Not sure we can get rid of the setter without breaking OO principles (specifically Liskov's Substitution Principle)... And not sure it is worth working around that expectation break.

The parent implementation already raises if a figure has already been set.

It is a reasonable point that we already break the semantics of having a "set_figure" on a "Figure"... but on the other hand, all that has really changed is "a figure is set for you at instantiation", and it is actually consistent to say "set_figure on an artist that already has a figure is an error, here is a method that we just didn't override, it will error, but hey that is expected".

Copy link
Member

Choose a reason for hiding this comment

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

Not sure we can get rid of the setter without breaking OO principles (specifically Liskov's Substitution Principle)... And not sure it is worth working around that expectation break.

Liskov is already broken. You can’t use a Figure everywhere where you can use an Artist. If one wanted strict Liskov Substitution Principle, one would need a more refined inheritance architecture, which is not where we are. But I see this pragmatic: As long as nobody expects a functionality (you would not try figure.add_artist(figure)) or the behavior is reasonable (you can’t set another figure if one is already set), the API is good enough.

@@ -246,7 +246,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.

@rcomer rcomer marked this pull request as ready for review May 11, 2024 13:06
@rcomer rcomer force-pushed the get_figure branch 2 times, most recently from 78a229c to c1fc4cf Compare May 14, 2024 18:51
lib/matplotlib/figure.py Outdated Show resolved Hide resolved
Comment on lines 594 to 596
assert ax.figure == sfig2
assert fig.figure == fig
assert sfig2.figure == fig
Copy link
Member

Choose a reason for hiding this comment

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

Is there a reason the rest of the test uses is and these use == ?

Copy link
Member Author

Choose a reason for hiding this comment

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

Yes, my brain was better engaged when I wrote is

Copy link
Member Author

Choose a reason for hiding this comment

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

Fixed.

doc/api/next_api_changes/behavior/28177-REC.rst Outdated Show resolved Hide resolved
doc/api/next_api_changes/behavior/28177-REC.rst Outdated Show resolved Hide resolved
lib/matplotlib/figure.py Show resolved Hide resolved
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

[Doc]: get_figure may return a SubFigure
5 participants