Repository: matplotlib/matplotlib
Pull request: FIX: better error message for shared axes and axis('equal')
## PR Summary
Closes #11416

The error message was pretty mysterious if you called `ax.axis('equal')` on a shared axes...

Not clear that the functionality users are _trying_ to get from this could not be provided, but at least clarifying the error message will help...

## PR Checklist

<!-- Please mark any checkboxes that do not apply to this PR as [N/A]. -->

- [ ] Has pytest style unit tests (and `pytest` passes).
- [ ] Is [Flake 8](https://flake8.pycqa.org/en/latest/) compliant (run `flake8` on changed files to check).
- [ ] New features are documented, with examples if plot related.
- [ ] Documentation is sphinx and numpydoc compliant (the docs should [build](https://matplotlib.org/devel/documenting_mpl.html#building-the-docs) without error).
- [ ] Conforms to Matplotlib style conventions (install `flake8-docstrings` and run `flake8 --docstring-convention=all`).
- [ ] New features have an entry in `doc/users/next_whats_new/` (follow instructions in README.rst there).
- [ ] API changes documented in `doc/api/next_api_changes/` (follow instructions in README.rst there).

<!--
Thank you so much for your PR!  To help us review your contribution, please
consider the following points:

- A development guide is available at https://matplotlib.org/devdocs/devel/index.html.

- Help with git and github is available at
  https://matplotlib.org/devel/gitwash/development_workflow.html.

- Do not create the PR out of master, but out of a separate branch.

- The PR title should summarize the changes, for example "Raise ValueError on
  non-numeric input to set_xlim".  Avoid non-descriptive titles such as
  "Addresses issue #8576".

- The summary should provide at least 1-2 sentences describing the pull request
  in detail (Why is this change required?  What problem does it solve?) and
  link to any relevant issues.

- If you are contributing fixes to docstrings, please pay attention to
  http://matplotlib.org/devel/documenting_mpl.html#formatting.  In particular,
  note the difference between using single backquotes, double backquotes, and
  asterisks in the markup.

We understand that PRs can sometimes be overwhelming, especially as the
reviews start coming in.  Please let us know if the reviews are unclear or
the recommended next step seems overly demanding, if you would like help in
addressing a reviewer's comments, or if you have been waiting too long to hear
back on your PR.
-->


Problem statement:
RuntimeError: adjustable='datalim' is not allowed when both axes are shared.
<!--To help us understand and resolve your issue, please fill out the form to the best of your ability.-->
<!--You can feel free to delete the sections that do not apply.-->

### Bug report

**Bug summary**

Matplotlib 2.2.2 fails to plot the figure described below. The code works with Matplotlib 2.0.2.

The issue seems to be the combination of ``sharex=True, sharey=True`` and ``axis('equal')``.

**Code for reproduction**

```python
import matplotlib.pyplot as plt
import numpy as np

nrows = 3
ncols = 2
fig, axes = plt.subplots(ncols=ncols, nrows=nrows, sharex=True, sharey=True)

n = 20
np.random.seed(1234)
data = np.random.uniform(size=(nrows, ncols, n, n))

for i in range(nrows):
    for j in range(ncols):
        ax = axes[i, j]
        ax.imshow(data[i, j])
        ax.axis("equal")

plt.show()

```

**Actual outcome**

With Matplotlib 2.2.2, the figure is not drawn and the following exception is raised:

```
Traceback (most recent call last):
  File "C:\ProgramData\Anaconda3\lib\site-packages\matplotlib\backends\backend_qt5.py", line 519, in _draw_idle
    self.draw()
  File "C:\ProgramData\Anaconda3\lib\site-packages\matplotlib\backends\backend_agg.py", line 433, in draw
    self.figure.draw(self.renderer)
  File "C:\ProgramData\Anaconda3\lib\site-packages\matplotlib\artist.py", line 55, in draw_wrapper
    return draw(artist, renderer, *args, **kwargs)
  File "C:\ProgramData\Anaconda3\lib\site-packages\matplotlib\figure.py", line 1475, in draw
    renderer, self, artists, self.suppressComposite)
  File "C:\ProgramData\Anaconda3\lib\site-packages\matplotlib\image.py", line 141, in _draw_list_compositing_images
    a.draw(renderer)
  File "C:\ProgramData\Anaconda3\lib\site-packages\matplotlib\artist.py", line 55, in draw_wrapper
    return draw(artist, renderer, *args, **kwargs)
  File "C:\ProgramData\Anaconda3\lib\site-packages\matplotlib\axes\_base.py", line 2546, in draw
    self.apply_aspect()
  File "C:\ProgramData\Anaconda3\lib\site-packages\matplotlib\axes\_base.py", line 1631, in apply_aspect
    raise RuntimeError("adjustable='datalim' is not allowed when both"
RuntimeError: adjustable='datalim' is not allowed when both axes are shared.
```

**Expected outcome**

Drawn figure, no error, as with Matplotlib 2.0.2


**Matplotlib version**
<!--Please specify your platform and versions of the relevant libraries you are using:-->
  * Operating system: Windows 7 64 bits
  * Matplotlib version: 2.2.2
  * Matplotlib backend (`print(matplotlib.get_backend())`): Qt5Agg
  * Python version: 3.6
  * Jupyter version (if applicable):
  * Other libraries: Anaconda 5.2

<!--Please tell us how you installed matplotlib and python e.g., from source, pip, conda-->
<!--If you installed from conda, please specify which channel you used if not the default-->

Hints from issue discussion:
This was a purposeful change in #10033.  However, it should have had an API change entry - our apologies.  

I'm also not sure that your use case should error.  I think we need to still think about all the sharing/datalim interactions to make sure we always do the right thing....  ping @efiring 
This combination is internally consistent only in the special case where all shared Axes have identical box aspect ratios in screen space.  This is a common case, but by no means universal.  Rather than blocking datalim at the outset, it might be possible to check for the consistency condition in `apply_aspect`.  If it is met, then `apply_aspect` *might* be able to proceed as if there were no sharing.  But it can get very complicated, because axes A and B might share both x and y, but C might share only x, etc.

I'm not sure it is worth trying to untangle this web in order to relax the simple blanket restriction against combining sharing of both axes with adjustable datalim.  Maybe the only sensible step in that direction would be to handle the single special case in which *all* sharing involves *both* x and y in a set of Axes in a single Figure.

Or maybe `apply_aspect` can be replaced with something based on a general constraint solver, which could raise an exception whenever it runs into a conflict.
I have the same problem as @nbud. It is fairly common to have say a 2x2 grid of images displayed via imshow and you want to preserve the x-y scale after a zoom event say (e.g. R,G,B in three panels then RGB in the fourth). I'll switch back for 2.0.2 in the mean time.
Have you considered using `adjustable='box'`?
matplotlib 1.5.3 and 2.2.2 seem in conflict over this, or maybe I just don't understand which way to go.

I'm updating a codebase from matplotlib 1.5.3 to 2.2.2. `adjustable='box-forced'` is no longer defined so I switched to `'box'`:

    fig, axesList = plt.subplots(n_generation, sharey=True, sharex=True,
        subplot_kw={'aspect': 0.4, 'adjustable': 'box'})

That works in matplotlib 2.2.2 but matplotlib 1.5.3 raises:

    ValueError: adjustable must be "datalim" for shared axes

It'd be good for the new code to pass our Jenkins build before I merge in the change and update the shared pyenv to the new libraries, so I tried `'datalim'`. Then matplotlib 2.2.2 raises the opposite error:

    RuntimeError: adjustable='datalim' is not allowed when both axes are shared.

I don't see docs on these choices and their compatibility with shared axes.

Matplotlib version

* Operating system: sys.platform = linux2
* Matplotlib version: 2.2.2
* Matplotlib backend: Agg
* Python version: 2.7.15

To me the error here is that `ax.axis('equal')` calls `ax.set_aspect('equal', adjustable='datalim')`.  Why does it do that instead of the default 'box'?  
I think this is just a matter of leaving long-standing behavior in place.  This definition of 'equal' predates the `Axes` refactoring 6 years ago.  (I think it originated in Matlab compatibility.) Most or all of the behavior of `ax.axis()` has been kept unchanged from early days.  There are two arguments that offer variations on 'equal': 'scaled', and 'image'.  Both of those use 'box' adjustable.
Anyhow, the workaround is to call `ax.set_aspect('equal')`.  We should probably deprecate `ax.axis('equal')`, since its hard to see why we need two ways to do the same thing.  But I think this can wait for 3.2...
@timhoffm recently updated the docstring of `axis("equal")` and cohorts in https://github.com/matplotlib/matplotlib/pull/15032. Would that qualify to close this issue, or is there another action item?
#15032 only adds documentation that `ax.axis('equal')` is  `ax.set_aspect('equal', adjustable='datalim')`.

That does not help too much for a user with the above problem because his code does not mention `datalim`. And the error message does not mention aspect/equal. Any idea how to hint at a solution in the error message without assuming too much?

Commit message: TST: add test for error

Patch to review:
diff --git a/lib/matplotlib/axes/_base.py b/lib/matplotlib/axes/_base.py
index 2f0f8865280f..42ebcd41a51e 100644
--- a/lib/matplotlib/axes/_base.py
+++ b/lib/matplotlib/axes/_base.py
@@ -1941,8 +1941,10 @@ def apply_aspect(self, position=None):
         shared_y = self in self._shared_axes["y"]
         # Not sure whether we need this check:
         if shared_x and shared_y:
-            raise RuntimeError("adjustable='datalim' is not allowed when both "
-                               "axes are shared")
+            raise RuntimeError("set_aspect(..., adjustable='datalim') or "
+                               "axis('equal') are not allowed when both axes "
+                               "are shared.  Try set_aspect(..., "
+                               "adjustable='box').")
 
         # If y is shared, then we are only allowed to change x, etc.
         if shared_y:
@@ -2044,7 +2046,11 @@ def axis(self, *args, emit=True, **kwargs):
                 self.autoscale_view(tight=False)
                 # self.apply_aspect()
                 if s == 'equal':
-                    self.set_aspect('equal', adjustable='datalim')
+                    try:
+                        self.set_aspect('equal', adjustable='datalim')
+                    except RuntimeError:
+                        raise RuntimeError("'equal' is not allowed on shared "
+                                           "axes, try 'scaled' instead.")
                 elif s == 'scaled':
                     self.set_aspect('equal', adjustable='box', anchor='C')
                     self.set_autoscale_on(False)  # Req. by Mark Bakker
diff --git a/lib/matplotlib/tests/test_axes.py b/lib/matplotlib/tests/test_axes.py
index 1b349b2a9c31..ef6164655122 100644
--- a/lib/matplotlib/tests/test_axes.py
+++ b/lib/matplotlib/tests/test_axes.py
@@ -4938,6 +4938,13 @@ def test_shared_with_aspect_3():
             assert round(expected, 4) == round(ax.get_aspect(), 4)
 
 
+def test_shared_aspect_error():
+    fig, axes = plt.subplots(1, 2, sharex=True, sharey=True)
+    axes[0].axis("equal")
+    with pytest.raises(RuntimeError, match=r"set_aspect\(..., adjustable="):
+        fig.draw_without_rendering()
+
+
 @pytest.mark.parametrize('twin', ('x', 'y'))
 def test_twin_with_aspect(twin):
     fig, ax = plt.subplots()


Code review comments:
diff_hunk: @@ -2044,7 +2046,11 @@ def axis(self, *args, emit=True, **kwargs):
                 self.autoscale_view(tight=False)
                 # self.apply_aspect()
                 if s == 'equal':
-                    self.set_aspect('equal', adjustable='datalim')
+                    try:
+                        self.set_aspect('equal', adjustable='datalim')
+                    except RuntimeError:
path: lib/matplotlib/axes/_base.py
text: @user1:
 We only want to catch the above exception, right? Technically, we should make sure not to catch other RuntimeErrors here, either by inspecting the message or by using a custom exception above.

But I'm not sure if we have to be that pedantic. Catching the wrong exception does not change control flow, it could only issue a non-instructive error message.

@author:
OK, looking back at it now, I'm not sure what this is supposed to catch.  `set_aspect` does not call `apply_aspect`, and `apply_aspect` is only called at draw time so far as I can tell.  I couldn't figure out a test for this, so lets see if anything fails just removing it again.  


@author:
Confirmed, this doesn't fail any tests - so I'm not even sure this was reachable.  Sorry I don't remember what prompted this int he first place!

diff_hunk: @@ -2044,7 +2046,11 @@ def axis(self, *args, emit=True, **kwargs):
                 self.autoscale_view(tight=False)
                 # self.apply_aspect()
                 if s == 'equal':
-                    self.set_aspect('equal', adjustable='datalim')
+                    try:
+                        self.set_aspect('equal', adjustable='datalim')
+                    except RuntimeError:
+                        raise RuntimeError("'equal' is not allowed on shared "
+                                           "axes, try 'scaled' instead.")
path: lib/matplotlib/axes/_base.py
text: @user1:
Is the original exception important here, or should we do `from None` here?

@user2:
Is it a RunTimeError that we want to re-raise here, or should we change it to `ValueError` instead since you are saying that value isn't allowed?

@author:
As above, I'm not sure I can even trigger this so lets try removing...