Repository: qutip/qutip

Issue:
matplotlib 3.4 issues new deprecation warnings
matplotlib 3.4 has begun issuing some new deprecation warnings against some of the stuff we do in `bloch.py`.

Simple reproducer from within `ipython --matplotlib`:
```python
In [1]: import qutip
   ...: b = qutip.Bloch()
   ...: b.add_vectors([0, 1, 0])
   ...: b.render()
```
produces
```
/Users/jake/.anaconda3/envs/qutip/lib/python3.9/site-packages/qutip/bloch.py:451: MatplotlibDeprecationWarning: Axes3D(fig) adding itself to the figure is deprecated since 3.4. Pass the keyword argument auto_add_to_figure=False and use fig.add_axes(ax) to suppress this warning. The default value of auto_add_to_figure will change to False in mpl3.5 and True values will no longer work in 3.6.  This is consistent with other Axes classes.
  self.axes = Axes3D(self.fig, azim=self.view[0], elev=self.view[1])

/Users/jake/.anaconda3/envs/qutip/lib/python3.9/site-packages/qutip/bloch.py:59: MatplotlibDeprecationWarning:
The M attribute was deprecated in Matplotlib 3.4 and will be removed two minor releases later. Use self.axes.M instead.
  xs, ys, zs = proj3d.proj_transform(xs3d, ys3d, zs3d, renderer.M)
```

This particular environment had `matplotlib==3.4.1`.

The warnings themselves suggest fixes, however some care needs to be taken; I suspect that the additional keyword `auto_add_to_figure` might be new in matplotlib 3.4 (not sure about the `.M` attribute).  We want to keep compatibility with at least `matplotlib>=3` (we don't want to picky so it's easy to use QuTiP with other packages), so it might be necessary to include a version test using `packaging.version.parse`.  If someone could test whether that's necessary, that'd be great.


Discussion:

Resolving patch:
diff --git a/qutip/bloch.py b/qutip/bloch.py
--- a/qutip/bloch.py
+++ b/qutip/bloch.py
@@ -38,16 +38,29 @@
 from numpy import (ndarray, array, linspace, pi, outer, cos, sin, ones, size,
                    sqrt, real, mod, append, ceil, arange)
 
+from packaging.version import parse as parse_version
+
 from qutip.qobj import Qobj
 from qutip.expect import expect
 from qutip.operators import sigmax, sigmay, sigmaz
 
 try:
+    import matplotlib
     import matplotlib.pyplot as plt
     from mpl_toolkits.mplot3d import Axes3D
     from matplotlib.patches import FancyArrowPatch
     from mpl_toolkits.mplot3d import proj3d
 
+    # Define a custom _axes3D function based on the matplotlib version.
+    # The auto_add_to_figure keyword is new for matplotlib>=3.4.
+    if parse_version(matplotlib.__version__) >= parse_version('3.4'):
+        def _axes3D(fig, *args, **kwargs):
+            ax = Axes3D(fig, *args, auto_add_to_figure=False, **kwargs)
+            return fig.add_axes(ax)
+    else:
+        def _axes3D(*args, **kwargs):
+            return Axes3D(*args, **kwargs)
+
     class Arrow3D(FancyArrowPatch):
         def __init__(self, xs, ys, zs, *args, **kwargs):
             FancyArrowPatch.__init__(self, (0, 0), (0, 0), *args, **kwargs)
@@ -56,7 +69,7 @@ def __init__(self, xs, ys, zs, *args, **kwargs):
 
         def draw(self, renderer):
             xs3d, ys3d, zs3d = self._verts3d
-            xs, ys, zs = proj3d.proj_transform(xs3d, ys3d, zs3d, renderer.M)
+            xs, ys, zs = proj3d.proj_transform(xs3d, ys3d, zs3d, self.axes.M)
 
             self.set_positions((xs[0], ys[0]), (xs[1], ys[1]))
             FancyArrowPatch.draw(self, renderer)
@@ -448,7 +461,8 @@ def render(self, fig=None, axes=None):
             self.fig = plt.figure(figsize=self.figsize)
 
         if not axes:
-            self.axes = Axes3D(self.fig, azim=self.view[0], elev=self.view[1])
+            self.axes = _axes3D(self.fig, azim=self.view[0],
+                                elev=self.view[1])
 
         if self.background:
             self.axes.clear()
@@ -463,7 +477,8 @@ def render(self, fig=None, axes=None):
             self.axes.set_zlim3d(-0.7, 0.7)
         # Manually set aspect ratio to fit a square bounding box.
         # Matplotlib did this stretching for < 3.3.0, but not above.
-        self.axes.set_box_aspect((1, 1, 1))
+        if parse_version(matplotlib.__version__) >= parse_version('3.3'):
+            self.axes.set_box_aspect((1, 1, 1))
 
         self.axes.grid(False)
         self.plot_back()
diff --git a/qutip/visualization.py b/qutip/visualization.py
--- a/qutip/visualization.py
+++ b/qutip/visualization.py
@@ -49,13 +49,7 @@
 import numpy as np
 from numpy import pi, array, sin, cos, angle, log2, sqrt
 
-try:
-    import matplotlib.pyplot as plt
-    import matplotlib as mpl
-    from matplotlib import cm
-    from mpl_toolkits.mplot3d import Axes3D
-except:
-    pass
+from packaging.version import parse as parse_version
 
 from qutip.qobj import Qobj, isket
 from qutip.states import ket2dm
@@ -68,6 +62,24 @@
 
 from qutip import settings
 
+try:
+    import matplotlib.pyplot as plt
+    import matplotlib as mpl
+    from matplotlib import cm
+    from mpl_toolkits.mplot3d import Axes3D
+
+    # Define a custom _axes3D function based on the matplotlib version.
+    # The auto_add_to_figure keyword is new for matplotlib>=3.4.
+    if parse_version(mpl.__version__) >= parse_version('3.4'):
+        def _axes3D(fig, *args, **kwargs):
+            ax = Axes3D(fig, *args, auto_add_to_figure=False, **kwargs)
+            return fig.add_axes(ax)
+    else:
+        def _axes3D(*args, **kwargs):
+            return Axes3D(*args, **kwargs)
+except:
+    pass
+
 
 def plot_wigner_sphere(fig, ax, wigner, reflections):
     """Plots a coloured Bloch sphere.
@@ -372,7 +384,7 @@ def sphereplot(theta, phi, values, fig=None, ax=None, save=False):
     """
     if fig is None or ax is None:
         fig = plt.figure()
-        ax = Axes3D(fig)
+        ax = _axes3D(fig)
 
     thetam, phim = np.meshgrid(theta, phi)
     xx = sin(thetam) * cos(phim)
@@ -468,7 +480,7 @@ def matrix_histogram(M, xlabels=None, ylabels=None, title=None, limits=None,
 
     if ax is None:
         fig = plt.figure()
-        ax = Axes3D(fig, azim=-35, elev=35)
+        ax = _axes3D(fig, azim=-35, elev=35)
 
     ax.bar3d(xpos, ypos, zpos, dx, dy, dz, color=colors)
 
@@ -589,7 +601,7 @@ def matrix_histogram_complex(M, xlabels=None, ylabels=None,
 
     if ax is None:
         fig = plt.figure()
-        ax = Axes3D(fig, azim=-35, elev=35)
+        ax = _axes3D(fig, azim=-35, elev=35)
 
     ax.bar3d(xpos, ypos, zpos, dx, dy, dz, color=colors)
 
@@ -1138,7 +1150,7 @@ def plot_spin_distribution_3d(P, THETA, PHI,
 
     if fig is None or ax is None:
         fig = plt.figure(figsize=figsize)
-        ax = Axes3D(fig, azim=-35, elev=35)
+        ax = _axes3D(fig, azim=-35, elev=35)
 
     xx = sin(THETA) * cos(PHI)
     yy = sin(THETA) * sin(PHI)


Test patch: