diff --git a/doc/users/next_whats_new/pie_hatch.rst b/doc/users/next_whats_new/pie_hatch.rst
new file mode 100644
index 000000000000..f54def10e954
--- /dev/null
+++ b/doc/users/next_whats_new/pie_hatch.rst
@@ -0,0 +1,18 @@
+``hatch`` parameter for pie
+-------------------------------------------
+
+`~matplotlib.axes.Axes.pie` now accepts a *hatch* keyword that takes as input
+a hatch or list of hatches:
+
+.. plot::
+    :include-source: true
+    :alt: Two pie charts, identified as ax1 and ax2, both have a small blue slice, a medium orange slice, and a large green slice. ax1 has a dot hatching on the small slice, a small open circle hatching on the medium slice, and a large open circle hatching on the large slice. ax2 has the same large open circle with a dot hatch on every slice.
+
+    fig, (ax1, ax2) = plt.subplots(ncols=2)
+    x = [10, 30, 60]
+
+    ax1.pie(x, hatch=['.', 'o', 'O'])
+    ax2.pie(x, hatch='.O')
+
+    ax1.set_title("hatch=['.', 'o', 'O']")
+    ax2.set_title("hatch='.O'")
diff --git a/lib/matplotlib/axes/_axes.py b/lib/matplotlib/axes/_axes.py
index e615305793b4..a6fecd374fba 100644
--- a/lib/matplotlib/axes/_axes.py
+++ b/lib/matplotlib/axes/_axes.py
@@ -3075,7 +3075,7 @@ def pie(self, x, explode=None, labels=None, colors=None,
             autopct=None, pctdistance=0.6, shadow=False, labeldistance=1.1,
             startangle=0, radius=1, counterclock=True,
             wedgeprops=None, textprops=None, center=(0, 0),
-            frame=False, rotatelabels=False, *, normalize=True):
+            frame=False, rotatelabels=False, *, normalize=True, hatch=None):
         """
         Plot a pie chart.

@@ -3101,29 +3101,34 @@ def pie(self, x, explode=None, labels=None, colors=None,
             A sequence of colors through which the pie chart will cycle.  If
             *None*, will use the colors in the currently active cycle.

+        hatch : str or list, default: None
+            Hatching pattern applied to all pie wedges or sequence of patterns
+            through which the chart will cycle. For a list of valid patterns,
+            see :doc:`/gallery/shapes_and_collections/hatch_style_reference`.
+
+            .. versionadded:: 3.7
+
         autopct : None or str or callable, default: None
-            If not *None*, is a string or function used to label the wedges
-            with their numeric value.  The label will be placed inside the
-            wedge.  If it is a format string, the label will be ``fmt % pct``.
-            If it is a function, it will be called.
+            If not *None*, *autopct* is a string or function used to label the
+            wedges with their numeric value. The label will be placed inside
+            the wedge. If *autopct* is a format string, the label will be
+            ``fmt % pct``. If *autopct* is a function, then it will be called.

         pctdistance : float, default: 0.6
-            The ratio between the center of each pie slice and the start of
-            the text generated by *autopct*.  Ignored if *autopct* is *None*.
+            The relative distance along the radius at which the the text
+            generated by *autopct* is drawn. To draw the text outside the pie,
+            set *pctdistance* > 1. This parameter is ignored if *autopct* is
+            ``None``.
+
+        labeldistance : float or None, default: 1.1
+            The relative distance along the radius at which the labels are
+            drawn. To draw the labels inside the pie, set  *labeldistance* < 1.
+            If set to ``None``, labels are not drawn but are still stored for
+            use in `.legend`.

         shadow : bool, default: False
             Draw a shadow beneath the pie.

-        normalize : bool, default: True
-            When *True*, always make a full pie by normalizing x so that
-            ``sum(x) == 1``. *False* makes a partial pie if ``sum(x) <= 1``
-            and raises a `ValueError` for ``sum(x) > 1``.
-
-        labeldistance : float or None, default: 1.1
-            The radial distance at which the pie labels are drawn.
-            If set to ``None``, label are not drawn, but are stored for use in
-            ``legend()``
-
         startangle : float, default: 0 degrees
             The angle by which the start of the pie is rotated,
             counterclockwise from the x-axis.
@@ -3135,11 +3140,11 @@ def pie(self, x, explode=None, labels=None, colors=None,
             Specify fractions direction, clockwise or counterclockwise.

         wedgeprops : dict, default: None
-            Dict of arguments passed to the wedge objects making the pie.
-            For example, you can pass in ``wedgeprops = {'linewidth': 3}``
-            to set the width of the wedge border lines equal to 3.
-            For more details, look at the doc/arguments of the wedge object.
-            By default, ``clip_on=False``.
+            Dict of arguments passed to each `.patches.Wedge` of the pie.
+            For example, ``wedgeprops = {'linewidth': 3}`` sets the width of
+            the wedge border lines equal to 3. By default, ``clip_on=False``.
+            When there is a conflict between these properties and other
+            keywords, properties passed to *wedgeprops* take precedence.

         textprops : dict, default: None
             Dict of arguments to pass to the text objects.
@@ -3153,6 +3158,11 @@ def pie(self, x, explode=None, labels=None, colors=None,
         rotatelabels : bool, default: False
             Rotate each label to the angle of the corresponding slice if true.

+        normalize : bool, default: True
+            When *True*, always make a full pie by normalizing x so that
+            ``sum(x) == 1``. *False* makes a partial pie if ``sum(x) <= 1``
+            and raises a `ValueError` for ``sum(x) > 1``.
+
         data : indexable object, optional
             DATA_PARAMETER_PLACEHOLDER

@@ -3207,6 +3217,8 @@ def pie(self, x, explode=None, labels=None, colors=None,
             def get_next_color():
                 return next(color_cycle)

+        hatch_cycle = itertools.cycle(np.atleast_1d(hatch))
+
         _api.check_isinstance(Number, radius=radius, startangle=startangle)
         if radius <= 0:
             raise ValueError(f'radius must be a positive number, not {radius}')
@@ -3233,6 +3245,7 @@ def get_next_color():
             w = mpatches.Wedge((x, y), radius, 360. * min(theta1, theta2),
                                360. * max(theta1, theta2),
                                facecolor=get_next_color(),
+                               hatch=next(hatch_cycle),
                                clip_on=False,
                                label=label)
             w.set(**wedgeprops)
diff --git a/lib/matplotlib/pyplot.py b/lib/matplotlib/pyplot.py
index 36172522e0d0..acd187d5d1c9 100644
--- a/lib/matplotlib/pyplot.py
+++ b/lib/matplotlib/pyplot.py
@@ -2779,7 +2779,7 @@ def pie(
         pctdistance=0.6, shadow=False, labeldistance=1.1,
         startangle=0, radius=1, counterclock=True, wedgeprops=None,
         textprops=None, center=(0, 0), frame=False,
-        rotatelabels=False, *, normalize=True, data=None):
+        rotatelabels=False, *, normalize=True, hatch=None, data=None):
     return gca().pie(
         x, explode=explode, labels=labels, colors=colors,
         autopct=autopct, pctdistance=pctdistance, shadow=shadow,
@@ -2787,7 +2787,7 @@ def pie(
         radius=radius, counterclock=counterclock,
         wedgeprops=wedgeprops, textprops=textprops, center=center,
         frame=frame, rotatelabels=rotatelabels, normalize=normalize,
-        **({"data": data} if data is not None else {}))
+        hatch=hatch, **({"data": data} if data is not None else {}))

 # Autogenerated by boilerplate.py.  Do not edit as changes will be lost.

diff --git a/lib/matplotlib/tests/test_axes.py b/lib/matplotlib/tests/test_axes.py
index f3f6b8938eb5..642316e4c000 100644
--- a/lib/matplotlib/tests/test_axes.py
+++ b/lib/matplotlib/tests/test_axes.py
@@ -5744,6 +5744,24 @@ def test_normalize_kwarg_pie():
     assert abs(t2[0][-1].theta2 - 360.) > 1e-3

+@check_figures_equal()
+def test_pie_hatch_single(fig_test, fig_ref):
+    x = [0.3, 0.3, 0.1]
+    hatch = '+'
+    fig_test.subplots().pie(x, hatch=hatch)
+    wedges, _ = fig_ref.subplots().pie(x)
+    [w.set_hatch(hatch) for w in wedges]
+
+
+@check_figures_equal()
+def test_pie_hatch_multi(fig_test, fig_ref):
+    x = [0.3, 0.3, 0.1]
+    hatch = ['/', '+', '.']
+    fig_test.subplots().pie(x, hatch=hatch)
+    wedges, _ = fig_ref.subplots().pie(x)
+    [w.set_hatch(hp) for w, hp in zip(wedges, hatch)]
+
+
 @image_comparison(['set_get_ticklabels.png'])
 def test_set_get_ticklabels():
     # test issue 2246


diff --git a/doc/users/next_whats_new/callbacks_on_norms.rst b/doc/users/next_whats_new/callbacks_on_norms.rst
new file mode 100644
index 000000000000..1904a92d2fba
--- /dev/null
+++ b/doc/users/next_whats_new/callbacks_on_norms.rst
@@ -0,0 +1,8 @@
+A callback registry has been added to Normalize objects
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+`.colors.Normalize` objects now have a callback registry, ``callbacks``,
+that can be connected to by other objects to be notified when the norm is
+updated. The callback emits the key ``changed`` when the norm is modified.
+`.cm.ScalarMappable` is now a listener and will register a change
+when the norm's vmin, vmax or other attributes are changed.
diff --git a/lib/matplotlib/cm.py b/lib/matplotlib/cm.py
index 0af2f0f327d9..76c1c5d4f7f2 100644
--- a/lib/matplotlib/cm.py
+++ b/lib/matplotlib/cm.py
@@ -337,7 +337,7 @@ def __init__(self, norm=None, cmap=None):
             The colormap used to map normalized data values to RGBA colors.
         """
         self._A = None
-        self.norm = None  # So that the setter knows we're initializing.
+        self._norm = None  # So that the setter knows we're initializing.
         self.set_norm(norm)  # The Normalize instance of this ScalarMappable.
         self.cmap = None  # So that the setter knows we're initializing.
         self.set_cmap(cmap)  # The Colormap instance of this ScalarMappable.
@@ -496,6 +496,8 @@ def set_clim(self, vmin=None, vmax=None):

              .. ACCEPTS: (vmin: float, vmax: float)
         """
+        # If the norm's limits are updated self.changed() will be called
+        # through the callbacks attached to the norm
         if vmax is None:
             try:
                 vmin, vmax = vmin
@@ -505,7 +507,6 @@ def set_clim(self, vmin=None, vmax=None):
             self.norm.vmin = colors._sanitize_extrema(vmin)
         if vmax is not None:
             self.norm.vmax = colors._sanitize_extrema(vmax)
-        self.changed()

     def get_alpha(self):
         """
@@ -531,6 +532,30 @@ def set_cmap(self, cmap):
         if not in_init:
             self.changed()  # Things are not set up properly yet.

+    @property
+    def norm(self):
+        return self._norm
+
+    @norm.setter
+    def norm(self, norm):
+        _api.check_isinstance((colors.Normalize, None), norm=norm)
+        if norm is None:
+            norm = colors.Normalize()
+
+        if norm is self.norm:
+            # We aren't updating anything
+            return
+
+        in_init = self.norm is None
+        # Remove the current callback and connect to the new one
+        if not in_init:
+            self.norm.callbacks.disconnect(self._id_norm)
+        self._norm = norm
+        self._id_norm = self.norm.callbacks.connect('changed',
+                                                    self.changed)
+        if not in_init:
+            self.changed()
+
     def set_norm(self, norm):
         """
         Set the normalization instance.
@@ -545,13 +570,7 @@ def set_norm(self, norm):
         the norm of the mappable will reset the norm, locator, and formatters
         on the colorbar to default.
         """
-        _api.check_isinstance((colors.Normalize, None), norm=norm)
-        in_init = self.norm is None
-        if norm is None:
-            norm = colors.Normalize()
         self.norm = norm
-        if not in_init:
-            self.changed()  # Things are not set up properly yet.

     def autoscale(self):
         """
@@ -560,8 +579,9 @@ def autoscale(self):
         """
         if self._A is None:
             raise TypeError('You must first set_array for mappable')
+        # If the norm's limits are updated self.changed() will be called
+        # through the callbacks attached to the norm
         self.norm.autoscale(self._A)
-        self.changed()

     def autoscale_None(self):
         """
@@ -570,8 +590,9 @@ def autoscale_None(self):
         """
         if self._A is None:
             raise TypeError('You must first set_array for mappable')
+        # If the norm's limits are updated self.changed() will be called
+        # through the callbacks attached to the norm
         self.norm.autoscale_None(self._A)
-        self.changed()

     def changed(self):
         """
diff --git a/lib/matplotlib/colorbar.py b/lib/matplotlib/colorbar.py
index 459b14f6c5a7..9d40ac8e5e9c 100644
--- a/lib/matplotlib/colorbar.py
+++ b/lib/matplotlib/colorbar.py
@@ -471,6 +471,7 @@ def __init__(self, ax, mappable=None, *, cmap=None,
         self.ax.add_collection(self.dividers)

         self.locator = None
+        self.minorlocator = None
         self.formatter = None
         self.__scale = None  # linear, log10 for now.  Hopefully more?

@@ -1096,7 +1097,7 @@ def _mesh(self):
         # vmax of the colorbar, not the norm.  This allows the situation
         # where the colormap has a narrower range than the colorbar, to
         # accommodate extra contours:
-        norm = copy.copy(self.norm)
+        norm = copy.deepcopy(self.norm)
         norm.vmin = self.vmin
         norm.vmax = self.vmax
         x = np.array([0.0, 1.0])
diff --git a/lib/matplotlib/colors.py b/lib/matplotlib/colors.py
index c5db6117f1bc..58e3fe198498 100644
--- a/lib/matplotlib/colors.py
+++ b/lib/matplotlib/colors.py
@@ -1123,10 +1123,50 @@ def __init__(self, vmin=None, vmax=None, clip=False):
         -----
         Returns 0 if ``vmin == vmax``.
         """
-        self.vmin = _sanitize_extrema(vmin)
-        self.vmax = _sanitize_extrema(vmax)
-        self.clip = clip
-        self._scale = None  # will default to LinearScale for colorbar
+        self._vmin = _sanitize_extrema(vmin)
+        self._vmax = _sanitize_extrema(vmax)
+        self._clip = clip
+        self._scale = None
+        self.callbacks = cbook.CallbackRegistry()
+
+    @property
+    def vmin(self):
+        return self._vmin
+
+    @vmin.setter
+    def vmin(self, value):
+        value = _sanitize_extrema(value)
+        if value != self._vmin:
+            self._vmin = value
+            self._changed()
+
+    @property
+    def vmax(self):
+        return self._vmax
+
+    @vmax.setter
+    def vmax(self, value):
+        value = _sanitize_extrema(value)
+        if value != self._vmax:
+            self._vmax = value
+            self._changed()
+
+    @property
+    def clip(self):
+        return self._clip
+
+    @clip.setter
+    def clip(self, value):
+        if value != self._clip:
+            self._clip = value
+            self._changed()
+
+    def _changed(self):
+        """
+        Call this whenever the norm is changed to notify all the
+        callback listeners to the 'changed' signal.
+        """
+        self.callbacks.process('changed')

     @staticmethod
     def process_value(value):
@@ -1273,7 +1313,7 @@ def __init__(self, vcenter, vmin=None, vmax=None):
         """

         super().__init__(vmin=vmin, vmax=vmax)
-        self.vcenter = vcenter
+        self._vcenter = vcenter
         if vcenter is not None and vmax is not None and vcenter >= vmax:
             raise ValueError('vmin, vcenter, and vmax must be in '
                              'ascending order')
@@ -1281,6 +1321,16 @@ def __init__(self, vcenter, vmin=None, vmax=None):
             raise ValueError('vmin, vcenter, and vmax must be in '
                              'ascending order')

+    @property
+    def vcenter(self):
+        return self._vcenter
+
+    @vcenter.setter
+    def vcenter(self, value):
+        if value != self._vcenter:
+            self._vcenter = value
+            self._changed()
+
     def autoscale_None(self, A):
         """
         Get vmin and vmax, and then clip at vcenter
@@ -1387,7 +1437,9 @@ def vcenter(self):

     @vcenter.setter
     def vcenter(self, vcenter):
-        self._vcenter = vcenter
+        if vcenter != self._vcenter:
+            self._vcenter = vcenter
+            self._changed()
         if self.vmax is not None:
             # recompute halfrange assuming vmin and vmax represent
             # min and max of data
diff --git a/lib/matplotlib/contour.py b/lib/matplotlib/contour.py
index 7dec80943993..4d124ce8c57c 100644
--- a/lib/matplotlib/contour.py
+++ b/lib/matplotlib/contour.py
@@ -1090,6 +1090,15 @@ def _make_paths(self, segs, kinds):
                     in zip(segs, kinds)]

     def changed(self):
+        if not hasattr(self, "cvalues"):
+            # Just return after calling the super() changed function
+            cm.ScalarMappable.changed(self)
+            return
+        # Force an autoscale immediately because self.to_rgba() calls
+        # autoscale_None() internally with the data passed to it,
+        # so if vmin/vmax are not set yet, this would override them with
+        # content from *cvalues* rather than levels like we want
+        self.norm.autoscale_None(self.levels)
         tcolors = [(tuple(rgba),)
                    for rgba in self.to_rgba(self.cvalues, alpha=self.alpha)]
         self.tcolors = tcolors
diff --git a/lib/matplotlib/image.py b/lib/matplotlib/image.py
index ca5b7da5f808..2036bf7e17c9 100644
--- a/lib/matplotlib/image.py
+++ b/lib/matplotlib/image.py
@@ -537,11 +537,14 @@ def _make_image(self, A, in_bbox, out_bbox, clip_bbox, magnification=1.0,
                 if isinstance(self.norm, mcolors.LogNorm) and s_vmin <= 0:
                     # Don't give 0 or negative values to LogNorm
                     s_vmin = np.finfo(scaled_dtype).eps
-                with cbook._setattr_cm(self.norm,
-                                       vmin=s_vmin,
-                                       vmax=s_vmax,
-                                       ):
-                    output = self.norm(resampled_masked)
+                # Block the norm from sending an update signal during the
+                # temporary vmin/vmax change
+                with self.norm.callbacks.blocked():
+                    with cbook._setattr_cm(self.norm,
+                                           vmin=s_vmin,
+                                           vmax=s_vmax,
+                                           ):
+                        output = self.norm(resampled_masked)
             else:
                 if A.ndim == 2:  # _interpolation_stage == 'rgba'
                     self.norm.autoscale_None(A)

diff --git a/lib/matplotlib/tests/test_colors.py b/lib/matplotlib/tests/test_colors.py
index ae004e957591..bf89a3a82364 100644
--- a/lib/matplotlib/tests/test_colors.py
+++ b/lib/matplotlib/tests/test_colors.py
@@ -1,5 +1,6 @@
 import copy
 import itertools
+import unittest.mock

 from io import BytesIO
 import numpy as np
@@ -17,7 +18,7 @@
 import matplotlib.cbook as cbook
 import matplotlib.pyplot as plt
 import matplotlib.scale as mscale
-from matplotlib.testing.decorators import image_comparison
+from matplotlib.testing.decorators import image_comparison, check_figures_equal

 @pytest.mark.parametrize('N, result', [
@@ -1408,3 +1409,69 @@ def test_norm_deepcopy():
     norm2 = copy.deepcopy(norm)
     assert norm2._scale is None
     assert norm2.vmin == norm.vmin
+
+
+def test_norm_callback():
+    increment = unittest.mock.Mock(return_value=None)
+
+    norm = mcolors.Normalize()
+    norm.callbacks.connect('changed', increment)
+    # Haven't updated anything, so call count should be 0
+    assert increment.call_count == 0
+
+    # Now change vmin and vmax to test callbacks
+    norm.vmin = 1
+    assert increment.call_count == 1
+    norm.vmax = 5
+    assert increment.call_count == 2
+    # callback shouldn't be called if setting to the same value
+    norm.vmin = 1
+    assert increment.call_count == 2
+    norm.vmax = 5
+    assert increment.call_count == 2
+
+
+def test_scalarmappable_norm_update():
+    norm = mcolors.Normalize()
+    sm = matplotlib.cm.ScalarMappable(norm=norm, cmap='plasma')
+    # sm doesn't have a stale attribute at first, set it to False
+    sm.stale = False
+    # The mappable should be stale after updating vmin/vmax
+    norm.vmin = 5
+    assert sm.stale
+    sm.stale = False
+    norm.vmax = 5
+    assert sm.stale
+    sm.stale = False
+    norm.clip = True
+    assert sm.stale
+    # change to the CenteredNorm and TwoSlopeNorm to test those
+    # Also make sure that updating the norm directly and with
+    # set_norm both update the Norm callback
+    norm = mcolors.CenteredNorm()
+    sm.norm = norm
+    sm.stale = False
+    norm.vcenter = 1
+    assert sm.stale
+    norm = mcolors.TwoSlopeNorm(vcenter=0, vmin=-1, vmax=1)
+    sm.set_norm(norm)
+    sm.stale = False
+    norm.vcenter = 1
+    assert sm.stale
+
+
+@check_figures_equal()
+def test_norm_update_figs(fig_test, fig_ref):
+    ax_ref = fig_ref.add_subplot()
+    ax_test = fig_test.add_subplot()
+
+    z = np.arange(100).reshape((10, 10))
+    ax_ref.imshow(z, norm=mcolors.Normalize(10, 90))
+
+    # Create the norm beforehand with different limits and then update
+    # after adding to the plot
+    norm = mcolors.Normalize(0, 1)
+    ax_test.imshow(z, norm=norm)
+    # Force initial draw to make sure it isn't already stale
+    fig_test.canvas.draw()
+    norm.vmin, norm.vmax = 10, 90
diff --git a/lib/matplotlib/tests/test_image.py b/lib/matplotlib/tests/test_image.py
index 37dddd4e4706..2e7fae6c58d8 100644
--- a/lib/matplotlib/tests/test_image.py
+++ b/lib/matplotlib/tests/test_image.py
@@ -1017,8 +1017,8 @@ def test_imshow_bool():
 def test_full_invalid():
     fig, ax = plt.subplots()
     ax.imshow(np.full((10, 10), np.nan))
-    with pytest.warns(UserWarning):
-        fig.canvas.draw()
+
+    fig.canvas.draw()

 @pytest.mark.parametrize("fmt,counted",

diff --git a/doc/users/next_whats_new/callbacks_on_norms.rst b/doc/users/next_whats_new/callbacks_on_norms.rst
new file mode 100644
index 0000000000..1904a92d2f
--- /dev/null
+++ b/doc/users/next_whats_new/callbacks_on_norms.rst
@@ -0,0 +1,8 @@
+A callback registry has been added to Normalize objects
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+`.colors.Normalize` objects now have a callback registry, ``callbacks``,
+that can be connected to by other objects to be notified when the norm is
+updated. The callback emits the key ``changed`` when the norm is modified.
+`.cm.ScalarMappable` is now a listener and will register a change
+when the norm's vmin, vmax or other attributes are changed.

If completing this task requires creating new files, classes, fields, or error messages, you may consider using the following suggested entity names:


diff --git a/doc/users/next_whats_new/callback_blocking.rst b/doc/users/next_whats_new/callback_blocking.rst
new file mode 100644
index 000000000000..090e06c61bbf
--- /dev/null
+++ b/doc/users/next_whats_new/callback_blocking.rst
@@ -0,0 +1,25 @@
+``CallbackRegistry`` objects gain a method to temporarily block signals
+-----------------------------------------------------------------------
+
+The context manager `~matplotlib.cbook.CallbackRegistry.blocked` can be used
+to block callback signals from being processed by the ``CallbackRegistry``.
+The optional keyword, *signal*, can be used to block a specific signal
+from being processed and let all other signals pass.
+
+.. code-block::
+
+    import matplotlib.pyplot as plt
+
+    fig, ax = plt.subplots()
+    ax.imshow([[0, 1], [2, 3]])
+
+    # Block all interactivity through the canvas callbacks
+    with fig.canvas.callbacks.blocked():
+        plt.show()
+
+    fig, ax = plt.subplots()
+    ax.imshow([[0, 1], [2, 3]])
+
+    # Only block key press events
+    with fig.canvas.callbacks.blocked(signal="key_press_event"):
+        plt.show()
diff --git a/lib/matplotlib/cbook/__init__.py b/lib/matplotlib/cbook/__init__.py
index ae7e5cd056e0..109b9ea69cc9 100644
--- a/lib/matplotlib/cbook/__init__.py
+++ b/lib/matplotlib/cbook/__init__.py
@@ -122,7 +122,8 @@ def _weak_or_strong_ref(func, callback):

 class CallbackRegistry:
     """
-    Handle registering and disconnecting for a set of signals and callbacks:
+    Handle registering, processing, blocking, and disconnecting
+    for a set of signals and callbacks:

         >>> def oneat(x):
         ...    print('eat', x)
@@ -140,9 +141,15 @@ class CallbackRegistry:
         >>> callbacks.process('eat', 456)
         eat 456
         >>> callbacks.process('be merry', 456) # nothing will be called
+
         >>> callbacks.disconnect(id_eat)
         >>> callbacks.process('eat', 456)      # nothing will be called

+        >>> with callbacks.blocked(signal='drink'):
+        ...     callbacks.process('drink', 123) # nothing will be called
+        >>> callbacks.process('drink', 123)
+        drink 123
+
     In practice, one should always disconnect all callbacks when they are
     no longer needed to avoid dangling references (and thus memory leaks).
     However, real code in Matplotlib rarely does so, and due to its design,
@@ -280,6 +287,31 @@ def process(self, s, *args, **kwargs):
                     else:
                         raise

+    @contextlib.contextmanager
+    def blocked(self, *, signal=None):
+        """
+        Block callback signals from being processed.
+
+        A context manager to temporarily block/disable callback signals
+        from being processed by the registered listeners.
+
+        Parameters
+        ----------
+        signal : str, optional
+            The callback signal to block. The default is to block all signals.
+        """
+        orig = self.callbacks
+        try:
+            if signal is None:
+                # Empty out the callbacks
+                self.callbacks = {}
+            else:
+                # Only remove the specific signal
+                self.callbacks = {k: orig[k] for k in orig if k != signal}
+            yield
+        finally:
+            self.callbacks = orig
+

 class silent_list(list):
     """

diff --git a/lib/matplotlib/tests/test_cbook.py b/lib/matplotlib/tests/test_cbook.py
index 47287524afde..474ea1a41d93 100644
--- a/lib/matplotlib/tests/test_cbook.py
+++ b/lib/matplotlib/tests/test_cbook.py
@@ -361,6 +361,39 @@ def test_callbackregistry_custom_exception_handler(monkeypatch, cb, excp):
         cb.process('foo')

+def test_callbackregistry_blocking():
+    # Needs an exception handler for interactive testing environments
+    # that would only print this out instead of raising the exception
+    def raise_handler(excp):
+        raise excp
+    cb = cbook.CallbackRegistry(exception_handler=raise_handler)
+    def test_func1():
+        raise ValueError("1 should be blocked")
+    def test_func2():
+        raise ValueError("2 should be blocked")
+    cb.connect("test1", test_func1)
+    cb.connect("test2", test_func2)
+
+    # block all of the callbacks to make sure they aren't processed
+    with cb.blocked():
+        cb.process("test1")
+        cb.process("test2")
+
+    # block individual callbacks to make sure the other is still processed
+    with cb.blocked(signal="test1"):
+        # Blocked
+        cb.process("test1")
+        # Should raise
+        with pytest.raises(ValueError, match="2 should be blocked"):
+            cb.process("test2")
+
+    # Make sure the original callback functions are there after blocking
+    with pytest.raises(ValueError, match="1 should be blocked"):
+        cb.process("test1")
+    with pytest.raises(ValueError, match="2 should be blocked"):
+        cb.process("test2")
+
+
 def test_sanitize_sequence():
     d = {'a': 1, 'b': 2, 'c': 3}
     k = ['a', 'b', 'c']

diff --git a/doc/users/next_whats_new/callback_blocking.rst b/doc/users/next_whats_new/callback_blocking.rst
new file mode 100644
index 0000000000..090e06c61b
--- /dev/null
+++ b/doc/users/next_whats_new/callback_blocking.rst
@@ -0,0 +1,25 @@
+``CallbackRegistry`` objects gain a method to temporarily block signals
+-----------------------------------------------------------------------
+
+The context manager `~matplotlib.cbook.CallbackRegistry.blocked` can be used
+to block callback signals from being processed by the ``CallbackRegistry``.
+The optional keyword, *signal*, can be used to block a specific signal
+from being processed and let all other signals pass.
+
+.. code-block::
+
+    import matplotlib.pyplot as plt
+
+    fig, ax = plt.subplots()
+    ax.imshow([[0, 1], [2, 3]])
+
+    # Block all interactivity through the canvas callbacks
+    with fig.canvas.callbacks.blocked():
+        plt.show()
+
+    fig, ax = plt.subplots()
+    ax.imshow([[0, 1], [2, 3]])
+
+    # Only block key press events
+    with fig.canvas.callbacks.blocked(signal="key_press_event"):
+        plt.show()


diff --git a/doc/users/next_whats_new/get_suptitle.rst b/doc/users/next_whats_new/get_suptitle.rst
new file mode 100644
index 000000000000..b03ad10b1b4c
--- /dev/null
+++ b/doc/users/next_whats_new/get_suptitle.rst
@@ -0,0 +1,4 @@
+``Figure.get_suptitle()``, ``Figure.get_supxlabel()``, ``Figure.get_supylabel()``
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+These methods return the strings set by ``Figure.suptitle()``, ``Figure.supxlabel()``
+and ``Figure.supylabel()`` respectively.
diff --git a/lib/matplotlib/figure.py b/lib/matplotlib/figure.py
index 7fdcc8cb6627..970bf957d4bf 100644
--- a/lib/matplotlib/figure.py
+++ b/lib/matplotlib/figure.py
@@ -388,6 +388,11 @@ def suptitle(self, t, **kwargs):
                 'size': 'figure.titlesize', 'weight': 'figure.titleweight'}
         return self._suplabels(t, info, **kwargs)

+    def get_suptitle(self):
+        """Return the suptitle as string or an empty string if not set."""
+        text_obj = self._suptitle
+        return "" if text_obj is None else text_obj.get_text()
+
     @_docstring.Substitution(x0=0.5, y0=0.01, name='supxlabel', ha='center',
                              va='bottom', rc='label')
     @_docstring.copy(_suplabels)
@@ -398,6 +403,11 @@ def supxlabel(self, t, **kwargs):
                 'size': 'figure.labelsize', 'weight': 'figure.labelweight'}
         return self._suplabels(t, info, **kwargs)

+    def get_supxlabel(self):
+        """Return the supxlabel as string or an empty string if not set."""
+        text_obj = self._supxlabel
+        return "" if text_obj is None else text_obj.get_text()
+
     @_docstring.Substitution(x0=0.02, y0=0.5, name='supylabel', ha='left',
                              va='center', rc='label')
     @_docstring.copy(_suplabels)
@@ -409,6 +419,11 @@ def supylabel(self, t, **kwargs):
                 'weight': 'figure.labelweight'}
         return self._suplabels(t, info, **kwargs)

+    def get_supylabel(self):
+        """Return the supylabel as string or an empty string if not set."""
+        text_obj = self._supylabel
+        return "" if text_obj is None else text_obj.get_text()
+
     def get_edgecolor(self):
         """Get the edge color of the Figure rectangle."""
         return self.patch.get_edgecolor()
diff --git a/lib/matplotlib/figure.pyi b/lib/matplotlib/figure.pyi
index ee21892f32ac..f4c31506a2e1 100644
--- a/lib/matplotlib/figure.pyi
+++ b/lib/matplotlib/figure.pyi
@@ -90,8 +90,11 @@ class FigureBase(Artist):
     def get_children(self) -> list[Artist]: ...
     def contains(self, mouseevent: MouseEvent) -> tuple[bool, dict[Any, Any]]: ...
     def suptitle(self, t: str, **kwargs) -> Text: ...
+    def get_suptitle(self) -> str: ...
     def supxlabel(self, t: str, **kwargs) -> Text: ...
+    def get_supxlabel(self) -> str: ...
     def supylabel(self, t: str, **kwargs) -> Text: ...
+    def get_supylabel(self) -> str: ...
     def get_edgecolor(self) -> ColorType: ...
     def get_facecolor(self) -> ColorType: ...
     def get_frameon(self) -> bool: ...

diff --git a/lib/matplotlib/tests/test_figure.py b/lib/matplotlib/tests/test_figure.py
index 4188ca878fed..d8f137ddd61a 100644
--- a/lib/matplotlib/tests/test_figure.py
+++ b/lib/matplotlib/tests/test_figure.py
@@ -302,6 +302,19 @@ def test_suptitle_subfigures():
     assert sf2.get_facecolor() == (1.0, 1.0, 1.0, 1.0)

+def test_get_suptitle_supxlabel_supylabel():
+    fig, ax = plt.subplots()
+    assert fig.get_suptitle() == ""
+    assert fig.get_supxlabel() == ""
+    assert fig.get_supylabel() == ""
+    fig.suptitle('suptitle')
+    assert fig.get_suptitle() == 'suptitle'
+    fig.supxlabel('supxlabel')
+    assert fig.get_supxlabel() == 'supxlabel'
+    fig.supylabel('supylabel')
+    assert fig.get_supylabel() == 'supylabel'
+
+
 @image_comparison(['alpha_background'],
                   # only test png and svg. The PDF output appears correct,
                   # but Ghostscript does not preserve the background color.

diff --git a/doc/users/next_whats_new/get_suptitle.rst b/doc/users/next_whats_new/get_suptitle.rst
new file mode 100644
index 0000000000..b03ad10b1b
--- /dev/null
+++ b/doc/users/next_whats_new/get_suptitle.rst
@@ -0,0 +1,4 @@
+``Figure.get_suptitle()``, ``Figure.get_supxlabel()``, ``Figure.get_supylabel()``
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+These methods return the strings set by ``Figure.suptitle()``, ``Figure.supxlabel()``
+and ``Figure.supylabel()`` respectively.


diff --git a/doc/api/toolkits/mplot3d/axes3d.rst b/doc/api/toolkits/mplot3d/axes3d.rst
index 99c57db64d9a..f6d8e2529896 100644
--- a/doc/api/toolkits/mplot3d/axes3d.rst
+++ b/doc/api/toolkits/mplot3d/axes3d.rst
@@ -209,6 +209,7 @@ Sharing
    :nosignatures:

    sharez
+   shareview

 Interactive
diff --git a/doc/users/next_whats_new/3d_plots_shareview.rst b/doc/users/next_whats_new/3d_plots_shareview.rst
new file mode 100644
index 000000000000..e71d06fd9297
--- /dev/null
+++ b/doc/users/next_whats_new/3d_plots_shareview.rst
@@ -0,0 +1,7 @@
+3D plots can share view angles
+------------------------------
+
+3D plots can now share the same view angles, so that when you rotate one plot
+the other plots also rotate. This can be done with the *shareview* keyword
+argument when adding an axes, or by using the *ax1.shareview(ax2)* method of
+existing 3D axes.
diff --git a/lib/mpl_toolkits/mplot3d/axes3d.py b/lib/mpl_toolkits/mplot3d/axes3d.py
index 35b0aaa01f97..cf2722eb4aae 100644
--- a/lib/mpl_toolkits/mplot3d/axes3d.py
+++ b/lib/mpl_toolkits/mplot3d/axes3d.py
@@ -56,6 +56,7 @@ class Axes3D(Axes):

     _axis_names = ("x", "y", "z")
     Axes._shared_axes["z"] = cbook.Grouper()
+    Axes._shared_axes["view"] = cbook.Grouper()

     vvec = _api.deprecate_privatize_attribute("3.7")
     eye = _api.deprecate_privatize_attribute("3.7")
@@ -66,6 +67,7 @@ def __init__(
             self, fig, rect=None, *args,
             elev=30, azim=-60, roll=0, sharez=None, proj_type='persp',
             box_aspect=None, computed_zorder=True, focal_length=None,
+            shareview=None,
             **kwargs):
         """
         Parameters
@@ -111,6 +113,8 @@ def __init__(
             or infinity (numpy.inf). If None, defaults to infinity.
             The focal length can be computed from a desired Field Of View via
             the equation: focal_length = 1/tan(FOV/2)
+        shareview : Axes3D, optional
+            Other Axes to share view angles with.

         **kwargs
             Other optional keyword arguments:
@@ -142,6 +146,10 @@ def __init__(
             self._shared_axes["z"].join(self, sharez)
             self._adjustable = 'datalim'

+        self._shareview = shareview
+        if shareview is not None:
+            self._shared_axes["view"].join(self, shareview)
+
         if kwargs.pop('auto_add_to_figure', False):
             raise AttributeError(
                 'auto_add_to_figure is no longer supported for Axes3D. '
@@ -757,7 +765,8 @@ def clabel(self, *args, **kwargs):
         """Currently not implemented for 3D axes, and returns *None*."""
         return None

-    def view_init(self, elev=None, azim=None, roll=None, vertical_axis="z"):
+    def view_init(self, elev=None, azim=None, roll=None, vertical_axis="z",
+                  share=False):
         """
         Set the elevation and azimuth of the axes in degrees (not radians).

@@ -804,29 +813,34 @@ def view_init(self, elev=None, azim=None, roll=None, vertical_axis="z"):
             constructor is used.
         vertical_axis : {"z", "x", "y"}, default: "z"
             The axis to align vertically. *azim* rotates about this axis.
+        share : bool, default: False
+            If ``True``, apply the settings to all Axes with shared views.
         """

         self._dist = 10  # The camera distance from origin. Behaves like zoom

         if elev is None:
-            self.elev = self.initial_elev
-        else:
-            self.elev = elev
-
+            elev = self.initial_elev
         if azim is None:
-            self.azim = self.initial_azim
-        else:
-            self.azim = azim
-
+            azim = self.initial_azim
         if roll is None:
-            self.roll = self.initial_roll
-        else:
-            self.roll = roll
-
-        self._vertical_axis = _api.check_getitem(
+            roll = self.initial_roll
+        vertical_axis = _api.check_getitem(
             dict(x=0, y=1, z=2), vertical_axis=vertical_axis
         )

+        if share:
+            axes = {sibling for sibling
+                    in self._shared_axes['view'].get_siblings(self)}
+        else:
+            axes = [self]
+
+        for ax in axes:
+            ax.elev = elev
+            ax.azim = azim
+            ax.roll = roll
+            ax._vertical_axis = vertical_axis
+
     def set_proj_type(self, proj_type, focal_length=None):
         """
         Set the projection type.
@@ -964,7 +978,7 @@ def sharez(self, other):
         Axes, and cannot be used if the z-axis is already being shared with
         another Axes.
         """
-        _api.check_isinstance(maxes._base._AxesBase, other=other)
+        _api.check_isinstance(Axes3D, other=other)
         if self._sharez is not None and other is not self._sharez:
             raise ValueError("z-axis is already shared")
         self._shared_axes["z"].join(self, other)
@@ -975,6 +989,23 @@ def sharez(self, other):
         self.set_zlim(z0, z1, emit=False, auto=other.get_autoscalez_on())
         self.zaxis._scale = other.zaxis._scale

+    def shareview(self, other):
+        """
+        Share the view angles with *other*.
+
+        This is equivalent to passing ``shareview=other`` when
+        constructing the Axes, and cannot be used if the view angles are
+        already being shared with another Axes.
+        """
+        _api.check_isinstance(Axes3D, other=other)
+        if self._shareview is not None and other is not self._shareview:
+            raise ValueError("view angles are already shared")
+        self._shared_axes["view"].join(self, other)
+        self._shareview = other
+        vertical_axis = {0: "x", 1: "y", 2: "z"}[other._vertical_axis]
+        self.view_init(elev=other.elev, azim=other.azim, roll=other.roll,
+                       vertical_axis=vertical_axis, share=True)
+
     def clear(self):
         # docstring inherited.
         super().clear()
@@ -1107,8 +1138,9 @@ def _on_move(self, event):
             roll = np.deg2rad(self.roll)
             delev = -(dy/h)*180*np.cos(roll) + (dx/w)*180*np.sin(roll)
             dazim = -(dy/h)*180*np.sin(roll) - (dx/w)*180*np.cos(roll)
-            self.elev = self.elev + delev
-            self.azim = self.azim + dazim
+            elev = self.elev + delev
+            azim = self.azim + dazim
+            self.view_init(elev=elev, azim=azim, roll=roll, share=True)
             self.stale = True

         elif self.button_pressed in self._pan_btn:

diff --git a/lib/mpl_toolkits/mplot3d/tests/test_axes3d.py b/lib/mpl_toolkits/mplot3d/tests/test_axes3d.py
index 3b890d31ae44..f4114e7be511 100644
--- a/lib/mpl_toolkits/mplot3d/tests/test_axes3d.py
+++ b/lib/mpl_toolkits/mplot3d/tests/test_axes3d.py
@@ -1689,6 +1689,20 @@ def test_set_zlim():
         ax.set_zlim(top=0, zmax=1)

+@check_figures_equal(extensions=["png"])
+def test_shared_view(fig_test, fig_ref):
+    elev, azim, roll = 5, 20, 30
+    ax1 = fig_test.add_subplot(131, projection="3d")
+    ax2 = fig_test.add_subplot(132, projection="3d", shareview=ax1)
+    ax3 = fig_test.add_subplot(133, projection="3d")
+    ax3.shareview(ax1)
+    ax2.view_init(elev=elev, azim=azim, roll=roll, share=True)
+
+    for subplot_num in (131, 132, 133):
+        ax = fig_ref.add_subplot(subplot_num, projection="3d")
+        ax.view_init(elev=elev, azim=azim, roll=roll)
+
+
 def test_shared_axes_retick():
     fig = plt.figure()
     ax1 = fig.add_subplot(211, projection="3d")

diff --git a/doc/api/toolkits/mplot3d/axes3d.rst b/doc/api/toolkits/mplot3d/axes3d.rst
index 99c57db64d..f6d8e25298 100644
--- a/doc/api/toolkits/mplot3d/axes3d.rst
+++ b/doc/api/toolkits/mplot3d/axes3d.rst
@@ -209,6 +209,7 @@ Sharing
    :nosignatures:

    sharez
+   shareview

 Interactive

diff --git a/doc/users/next_whats_new/3d_plots_shareview.rst b/doc/users/next_whats_new/3d_plots_shareview.rst
new file mode 100644
index 0000000000..e71d06fd92
--- /dev/null
+++ b/doc/users/next_whats_new/3d_plots_shareview.rst
@@ -0,0 +1,7 @@
+3D plots can share view angles
+------------------------------
+
+3D plots can now share the same view angles, so that when you rotate one plot
+the other plots also rotate. This can be done with the *shareview* keyword
+argument when adding an axes, or by using the *ax1.shareview(ax2)* method of
+existing 3D axes.


diff --git a/doc/users/next_whats_new/reverse_legend.rst b/doc/users/next_whats_new/reverse_legend.rst
new file mode 100644
index 000000000000..00f2c0dbeac7
--- /dev/null
+++ b/doc/users/next_whats_new/reverse_legend.rst
@@ -0,0 +1,4 @@
+Reversed order of legend entries
+--------------------------------
+The order of legend entries can now be reversed by passing ``reverse=True`` to
+`~.Axes.legend`.
diff --git a/lib/matplotlib/legend.py b/lib/matplotlib/legend.py
index 82f92167c55c..c61be2c5550a 100644
--- a/lib/matplotlib/legend.py
+++ b/lib/matplotlib/legend.py
@@ -209,6 +209,12 @@ def _update_bbox_to_anchor(self, loc_in_canvas):
     If *True*, legend marker is placed to the left of the legend label.
     If *False*, legend marker is placed to the right of the legend label.

+reverse : bool, default: False
+    If *True*, the legend labels are displayed in reverse order from the input.
+    If *False*, the legend labels are displayed in the same order as the input.
+
+    ..versionadded:: 3.7
+
 frameon : bool, default: :rc:`legend.frameon`
     Whether the legend should be drawn on a patch (frame).

@@ -312,6 +318,7 @@ def __init__(
         numpoints=None,      # number of points in the legend line
         markerscale=None,    # relative size of legend markers vs. original
         markerfirst=True,    # left/right ordering of legend marker and label
+        reverse=False,       # reverse ordering of legend marker and label
         scatterpoints=None,  # number of scatter points
         scatteryoffsets=None,
         prop=None,           # properties for the legend texts
@@ -437,6 +444,10 @@ def val_or_rc(val, rc_name):
                 _hand.append(handle)
         labels, handles = _lab, _hand

+        if reverse:
+            labels.reverse()
+            handles.reverse()
+
         handles = list(handles)
         if len(handles) < 2:
             ncols = 1

diff --git a/lib/matplotlib/tests/test_legend.py b/lib/matplotlib/tests/test_legend.py
index 0016dc4d2d14..56794a3428b8 100644
--- a/lib/matplotlib/tests/test_legend.py
+++ b/lib/matplotlib/tests/test_legend.py
@@ -294,6 +294,38 @@ def test_legend_remove():
     assert ax.get_legend() is None

+def test_reverse_legend_handles_and_labels():
+    """Check that the legend handles and labels are reversed."""
+    fig, ax = plt.subplots()
+    x = 1
+    y = 1
+    labels = ["First label", "Second label", "Third label"]
+    markers = ['.', ',', 'o']
+
+    ax.plot(x, y, markers[0], label=labels[0])
+    ax.plot(x, y, markers[1], label=labels[1])
+    ax.plot(x, y, markers[2], label=labels[2])
+    leg = ax.legend(reverse=True)
+    actual_labels = [t.get_text() for t in leg.get_texts()]
+    actual_markers = [h.get_marker() for h in leg.legend_handles]
+    assert actual_labels == list(reversed(labels))
+    assert actual_markers == list(reversed(markers))
+
+
+@check_figures_equal(extensions=["png"])
+def test_reverse_legend_display(fig_test, fig_ref):
+    """Check that the rendered legend entries are reversed"""
+    ax = fig_test.subplots()
+    ax.plot([1], 'ro', label="first")
+    ax.plot([2], 'bx', label="second")
+    ax.legend(reverse=True)
+
+    ax = fig_ref.subplots()
+    ax.plot([2], 'bx', label="second")
+    ax.plot([1], 'ro', label="first")
+    ax.legend()
+
+
 class TestLegendFunction:
     # Tests the legend function on the Axes and pyplot.
     def test_legend_no_args(self):

diff --git a/doc/users/next_whats_new/reverse_legend.rst b/doc/users/next_whats_new/reverse_legend.rst
new file mode 100644
index 0000000000..00f2c0dbea
--- /dev/null
+++ b/doc/users/next_whats_new/reverse_legend.rst
@@ -0,0 +1,4 @@
+Reversed order of legend entries
+--------------------------------
+The order of legend entries can now be reversed by passing ``reverse=True`` to
+`~.Axes.legend`.


diff --git a/doc/users/next_whats_new/set_loc.rst b/doc/users/next_whats_new/set_loc.rst
new file mode 100644
index 000000000000..2a8722a18da0
--- /dev/null
+++ b/doc/users/next_whats_new/set_loc.rst
@@ -0,0 +1,23 @@
+Add a public method to modify the location of ``Legend``
+--------------------------------------------------------
+
+`~matplotlib.legend.Legend` locations now can be tweaked after they've been defined.
+
+.. plot::
+    :include-source: true
+
+    from matplotlib import pyplot as plt
+
+    fig = plt.figure()
+    ax = fig.add_subplot(1, 1, 1)
+
+    x = list(range(-100, 101))
+    y = [i**2 for i in x]
+
+    ax.plot(x, y, label="f(x)")
+    ax.legend()
+    ax.get_legend().set_loc("right")
+    # Or
+    # ax.get_legend().set(loc="right")
+
+    plt.show()
diff --git a/lib/matplotlib/legend.py b/lib/matplotlib/legend.py
index b0e50d67f9b7..a0ad612e7084 100644
--- a/lib/matplotlib/legend.py
+++ b/lib/matplotlib/legend.py
@@ -330,6 +330,12 @@ def _update_bbox_to_anchor(self, loc_in_canvas):
     _legend_kw_doc_base)
 _docstring.interpd.update(_legend_kw_doc=_legend_kw_both_st)

+_legend_kw_set_loc_st = (
+    _loc_doc_base.format(parent='axes/figure',
+                         default=":rc:`legend.loc` for Axes, 'upper right' for Figure",
+                         best=_loc_doc_best, outside=_outside_doc))
+_docstring.interpd.update(_legend_kw_set_loc_doc=_legend_kw_set_loc_st)
+

 class Legend(Artist):
     """
@@ -503,58 +509,6 @@ def val_or_rc(val, rc_name):
             )
         self.parent = parent

-        loc0 = loc
-        self._loc_used_default = loc is None
-        if loc is None:
-            loc = mpl.rcParams["legend.loc"]
-            if not self.isaxes and loc in [0, 'best']:
-                loc = 'upper right'
-
-        type_err_message = ("loc must be string, coordinate tuple, or"
-                            f" an integer 0-10, not {loc!r}")
-
-        # handle outside legends:
-        self._outside_loc = None
-        if isinstance(loc, str):
-            if loc.split()[0] == 'outside':
-                # strip outside:
-                loc = loc.split('outside ')[1]
-                # strip "center" at the beginning
-                self._outside_loc = loc.replace('center ', '')
-                # strip first
-                self._outside_loc = self._outside_loc.split()[0]
-                locs = loc.split()
-                if len(locs) > 1 and locs[0] in ('right', 'left'):
-                    # locs doesn't accept "left upper", etc, so swap
-                    if locs[0] != 'center':
-                        locs = locs[::-1]
-                    loc = locs[0] + ' ' + locs[1]
-            # check that loc is in acceptable strings
-            loc = _api.check_getitem(self.codes, loc=loc)
-        elif np.iterable(loc):
-            # coerce iterable into tuple
-            loc = tuple(loc)
-            # validate the tuple represents Real coordinates
-            if len(loc) != 2 or not all(isinstance(e, numbers.Real) for e in loc):
-                raise ValueError(type_err_message)
-        elif isinstance(loc, int):
-            # validate the integer represents a string numeric value
-            if loc < 0 or loc > 10:
-                raise ValueError(type_err_message)
-        else:
-            # all other cases are invalid values of loc
-            raise ValueError(type_err_message)
-
-        if self.isaxes and self._outside_loc:
-            raise ValueError(
-                f"'outside' option for loc='{loc0}' keyword argument only "
-                "works for figure legends")
-
-        if not self.isaxes and loc == 0:
-            raise ValueError(
-                "Automatic legend placement (loc='best') not implemented for "
-                "figure legend")
-
         self._mode = mode
         self.set_bbox_to_anchor(bbox_to_anchor, bbox_transform)

@@ -598,9 +552,8 @@ def val_or_rc(val, rc_name):
         # init with null renderer
         self._init_legend_box(handles, labels, markerfirst)

-        tmp = self._loc_used_default
-        self._set_loc(loc)
-        self._loc_used_default = tmp  # ignore changes done by _set_loc
+        # Set legend location
+        self.set_loc(loc)

         # figure out title font properties:
         if title_fontsize is not None and title_fontproperties is not None:
@@ -686,6 +639,73 @@ def _set_artist_props(self, a):

         a.set_transform(self.get_transform())

+    @_docstring.dedent_interpd
+    def set_loc(self, loc=None):
+        """
+        Set the location of the legend.
+
+        .. versionadded:: 3.8
+
+        Parameters
+        ----------
+        %(_legend_kw_set_loc_doc)s
+        """
+        loc0 = loc
+        self._loc_used_default = loc is None
+        if loc is None:
+            loc = mpl.rcParams["legend.loc"]
+            if not self.isaxes and loc in [0, 'best']:
+                loc = 'upper right'
+
+        type_err_message = ("loc must be string, coordinate tuple, or"
+                            f" an integer 0-10, not {loc!r}")
+
+        # handle outside legends:
+        self._outside_loc = None
+        if isinstance(loc, str):
+            if loc.split()[0] == 'outside':
+                # strip outside:
+                loc = loc.split('outside ')[1]
+                # strip "center" at the beginning
+                self._outside_loc = loc.replace('center ', '')
+                # strip first
+                self._outside_loc = self._outside_loc.split()[0]
+                locs = loc.split()
+                if len(locs) > 1 and locs[0] in ('right', 'left'):
+                    # locs doesn't accept "left upper", etc, so swap
+                    if locs[0] != 'center':
+                        locs = locs[::-1]
+                    loc = locs[0] + ' ' + locs[1]
+            # check that loc is in acceptable strings
+            loc = _api.check_getitem(self.codes, loc=loc)
+        elif np.iterable(loc):
+            # coerce iterable into tuple
+            loc = tuple(loc)
+            # validate the tuple represents Real coordinates
+            if len(loc) != 2 or not all(isinstance(e, numbers.Real) for e in loc):
+                raise ValueError(type_err_message)
+        elif isinstance(loc, int):
+            # validate the integer represents a string numeric value
+            if loc < 0 or loc > 10:
+                raise ValueError(type_err_message)
+        else:
+            # all other cases are invalid values of loc
+            raise ValueError(type_err_message)
+
+        if self.isaxes and self._outside_loc:
+            raise ValueError(
+                f"'outside' option for loc='{loc0}' keyword argument only "
+                "works for figure legends")
+
+        if not self.isaxes and loc == 0:
+            raise ValueError(
+                "Automatic legend placement (loc='best') not implemented for "
+                "figure legend")
+
+        tmp = self._loc_used_default
+        self._set_loc(loc)
+        self._loc_used_default = tmp  # ignore changes done by _set_loc
+
     def _set_loc(self, loc):
         # find_offset function will be provided to _legend_box and
         # _legend_box will draw itself at the location of the return
diff --git a/lib/matplotlib/legend.pyi b/lib/matplotlib/legend.pyi
index 77ef273766c2..7116f40f3e0c 100644
--- a/lib/matplotlib/legend.pyi
+++ b/lib/matplotlib/legend.pyi
@@ -118,6 +118,7 @@ class Legend(Artist):
     def get_texts(self) -> list[Text]: ...
     def set_alignment(self, alignment: Literal["center", "left", "right"]) -> None: ...
     def get_alignment(self) -> Literal["center", "left", "right"]: ...
+    def set_loc(self, loc: str | tuple[float, float] | int | None = ...) -> None: ...
     def set_title(
         self, title: str, prop: FontProperties | str | pathlib.Path | None = ...
     ) -> None: ...

diff --git a/lib/matplotlib/tests/test_legend.py b/lib/matplotlib/tests/test_legend.py
index c94a0f5f6169..720dfc3e6c65 100644
--- a/lib/matplotlib/tests/test_legend.py
+++ b/lib/matplotlib/tests/test_legend.py
@@ -755,6 +755,26 @@ def test_legend_alignment(alignment):
     assert leg.get_alignment() == alignment

+@pytest.mark.parametrize('loc', ('center', 'best',))
+def test_ax_legend_set_loc(loc):
+    fig, ax = plt.subplots()
+    ax.plot(range(10), label='test')
+    leg = ax.legend()
+    leg.set_loc(loc)
+    assert leg._get_loc() == mlegend.Legend.codes[loc]
+
+
+@pytest.mark.parametrize('loc', ('outside right', 'right',))
+def test_fig_legend_set_loc(loc):
+    fig, ax = plt.subplots()
+    ax.plot(range(10), label='test')
+    leg = fig.legend()
+    leg.set_loc(loc)
+
+    loc = loc.split()[1] if loc.startswith("outside") else loc
+    assert leg._get_loc() == mlegend.Legend.codes[loc]
+
+
 @pytest.mark.parametrize('alignment', ('center', 'left', 'right'))
 def test_legend_set_alignment(alignment):
     fig, ax = plt.subplots()

diff --git a/doc/users/next_whats_new/set_loc.rst b/doc/users/next_whats_new/set_loc.rst
new file mode 100644
index 0000000000..2a8722a18d
--- /dev/null
+++ b/doc/users/next_whats_new/set_loc.rst
@@ -0,0 +1,23 @@
+Add a public method to modify the location of ``Legend``
+--------------------------------------------------------
+
+`~matplotlib.legend.Legend` locations now can be tweaked after they've been defined.
+
+.. plot::
+    :include-source: true
+
+    from matplotlib import pyplot as plt
+
+    fig = plt.figure()
+    ax = fig.add_subplot(1, 1, 1)
+
+    x = list(range(-100, 101))
+    y = [i**2 for i in x]
+
+    ax.plot(x, y, label="f(x)")
+    ax.legend()
+    ax.get_legend().set_loc("right")
+    # Or
+    # ax.get_legend().set(loc="right")
+
+    plt.show()


diff --git a/doc/users/next_whats_new/get_vertices_co_vertices.rst b/doc/users/next_whats_new/get_vertices_co_vertices.rst
new file mode 100644
index 000000000000..98254a82ce63
--- /dev/null
+++ b/doc/users/next_whats_new/get_vertices_co_vertices.rst
@@ -0,0 +1,7 @@
+``Ellipse.get_vertices()``, ``Ellipse.get_co_vertices()``
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+These methods return the coordinates of ellipse vertices of
+major and minor axis. Additionally, an example gallery demo is added which
+shows how to add an arrow to an ellipse showing a clockwise or counter-clockwise
+rotation of the ellipse. To place the arrow exactly on the ellipse,
+the coordinates of the vertices are used.
diff --git a/galleries/examples/shapes_and_collections/ellipse_arrow.py b/galleries/examples/shapes_and_collections/ellipse_arrow.py
new file mode 100644
index 000000000000..4bcdc016faa6
--- /dev/null
+++ b/galleries/examples/shapes_and_collections/ellipse_arrow.py
@@ -0,0 +1,53 @@
+"""
+===================================
+Ellipse with orientation arrow demo
+===================================
+
+This demo shows how to draw an ellipse with
+an orientation arrow (clockwise or counterclockwise).
+Compare this to the :doc:`Ellipse collection example
+</gallery/shapes_and_collections/ellipse_collection>`.
+"""
+
+import matplotlib.pyplot as plt
+
+from matplotlib.markers import MarkerStyle
+from matplotlib.patches import Ellipse
+from matplotlib.transforms import Affine2D
+
+# Create a figure and axis
+fig, ax = plt.subplots(subplot_kw={"aspect": "equal"})
+
+ellipse = Ellipse(
+    xy=(2, 4),
+    width=30,
+    height=20,
+    angle=35,
+    facecolor="none",
+    edgecolor="b"
+)
+ax.add_patch(ellipse)
+
+# Plot an arrow marker at the end point of minor axis
+vertices = ellipse.get_co_vertices()
+t = Affine2D().rotate_deg(ellipse.angle)
+ax.plot(
+    vertices[0][0],
+    vertices[0][1],
+    color="b",
+    marker=MarkerStyle(">", "full", t),
+    markersize=10
+)
+# Note: To reverse the orientation arrow, switch the marker type from > to <.
+
+plt.show()
+
+# %%
+#
+# .. admonition:: References
+#
+#    The use of the following functions, methods, classes and modules is shown
+#    in this example:
+#
+#    - `matplotlib.patches`
+#    - `matplotlib.patches.Ellipse`
diff --git a/lib/matplotlib/patches.py b/lib/matplotlib/patches.py
index 245bb7b777c8..ecf03ca4051b 100644
--- a/lib/matplotlib/patches.py
+++ b/lib/matplotlib/patches.py
@@ -1654,6 +1654,37 @@ def get_corners(self):
         return self.get_patch_transform().transform(
             [(-1, -1), (1, -1), (1, 1), (-1, 1)])

+    def _calculate_length_between_points(self, x0, y0, x1, y1):
+        return np.sqrt((x1 - x0)**2 + (y1 - y0)**2)
+
+    def get_vertices(self):
+        """
+        Return the vertices coordinates of the ellipse.
+
+        The definition can be found `here <https://en.wikipedia.org/wiki/Ellipse>`_
+
+        .. versionadded:: 3.8
+        """
+        if self.width < self.height:
+            ret = self.get_patch_transform().transform([(0, 1), (0, -1)])
+        else:
+            ret = self.get_patch_transform().transform([(1, 0), (-1, 0)])
+        return [tuple(x) for x in ret]
+
+    def get_co_vertices(self):
+        """
+        Return the co-vertices coordinates of the ellipse.
+
+        The definition can be found `here <https://en.wikipedia.org/wiki/Ellipse>`_
+
+        .. versionadded:: 3.8
+        """
+        if self.width < self.height:
+            ret = self.get_patch_transform().transform([(1, 0), (-1, 0)])
+        else:
+            ret = self.get_patch_transform().transform([(0, 1), (0, -1)])
+        return [tuple(x) for x in ret]
+

 class Annulus(Patch):
     """
diff --git a/lib/matplotlib/patches.pyi b/lib/matplotlib/patches.pyi
index 1e70a1efc3be..e9302563083c 100644
--- a/lib/matplotlib/patches.pyi
+++ b/lib/matplotlib/patches.pyi
@@ -259,6 +259,10 @@ class Ellipse(Patch):

     def get_corners(self) -> np.ndarray: ...

+    def get_vertices(self) -> list[tuple[float, float]]: ...
+    def get_co_vertices(self) -> list[tuple[float, float]]: ...
+
+
 class Annulus(Patch):
     a: float
     b: float

diff --git a/lib/matplotlib/tests/test_patches.py b/lib/matplotlib/tests/test_patches.py
index b9e1db32d419..fd872bac98d4 100644
--- a/lib/matplotlib/tests/test_patches.py
+++ b/lib/matplotlib/tests/test_patches.py
@@ -104,6 +104,57 @@ def test_corner_center():
     assert_almost_equal(ellipse.get_corners(), corners_rot)

+def test_ellipse_vertices():
+    # expect 0 for 0 ellipse width, height
+    ellipse = Ellipse(xy=(0, 0), width=0, height=0, angle=0)
+    assert_almost_equal(
+        ellipse.get_vertices(),
+        [(0.0, 0.0), (0.0, 0.0)],
+    )
+    assert_almost_equal(
+        ellipse.get_co_vertices(),
+        [(0.0, 0.0), (0.0, 0.0)],
+    )
+
+    ellipse = Ellipse(xy=(0, 0), width=2, height=1, angle=30)
+    assert_almost_equal(
+        ellipse.get_vertices(),
+        [
+            (
+                ellipse.center[0] + ellipse.width / 4 * np.sqrt(3),
+                ellipse.center[1] + ellipse.width / 4,
+            ),
+            (
+                ellipse.center[0] - ellipse.width / 4 * np.sqrt(3),
+                ellipse.center[1] - ellipse.width / 4,
+            ),
+        ],
+    )
+    assert_almost_equal(
+        ellipse.get_co_vertices(),
+        [
+            (
+                ellipse.center[0] - ellipse.height / 4,
+                ellipse.center[1] + ellipse.height / 4 * np.sqrt(3),
+            ),
+            (
+                ellipse.center[0] + ellipse.height / 4,
+                ellipse.center[1] - ellipse.height / 4 * np.sqrt(3),
+            ),
+        ],
+    )
+    v1, v2 = np.array(ellipse.get_vertices())
+    np.testing.assert_almost_equal((v1 + v2) / 2, ellipse.center)
+    v1, v2 = np.array(ellipse.get_co_vertices())
+    np.testing.assert_almost_equal((v1 + v2) / 2, ellipse.center)
+
+    ellipse = Ellipse(xy=(2.252, -10.859), width=2.265, height=1.98, angle=68.78)
+    v1, v2 = np.array(ellipse.get_vertices())
+    np.testing.assert_almost_equal((v1 + v2) / 2, ellipse.center)
+    v1, v2 = np.array(ellipse.get_co_vertices())
+    np.testing.assert_almost_equal((v1 + v2) / 2, ellipse.center)
+
+
 def test_rotate_rect():
     loc = np.asarray([1.0, 2.0])
     width = 2

diff --git a/doc/users/next_whats_new/get_vertices_co_vertices.rst b/doc/users/next_whats_new/get_vertices_co_vertices.rst
new file mode 100644
index 0000000000..98254a82ce
--- /dev/null
+++ b/doc/users/next_whats_new/get_vertices_co_vertices.rst
@@ -0,0 +1,7 @@
+``Ellipse.get_vertices()``, ``Ellipse.get_co_vertices()``
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+These methods return the coordinates of ellipse vertices of
+major and minor axis. Additionally, an example gallery demo is added which
+shows how to add an arrow to an ellipse showing a clockwise or counter-clockwise
+rotation of the ellipse. To place the arrow exactly on the ellipse,
+the coordinates of the vertices are used.

If completing this task requires creating new files, classes, fields, or error messages, you may consider using the following suggested entity names:
[{'type': 'file', 'name': 'galleries/examples/shapes_and_collections/ellipse_arrow.py'}]


diff --git a/doc/users/next_whats_new/multiplelocator_offset.rst b/doc/users/next_whats_new/multiplelocator_offset.rst
new file mode 100644
index 000000000000..863fdb3c4d7e
--- /dev/null
+++ b/doc/users/next_whats_new/multiplelocator_offset.rst
@@ -0,0 +1,17 @@
+``offset`` parameter for MultipleLocator
+----------------------------------------
+
+An *offset* may now be specified to shift all the ticks by the given value.
+
+.. plot::
+    :include-source: true
+
+    import matplotlib.pyplot as plt
+    import matplotlib.ticker as mticker
+
+    _, ax = plt.subplots()
+    ax.plot(range(10))
+    locator = mticker.MultipleLocator(base=3, offset=0.3)
+    ax.xaxis.set_major_locator(locator)
+
+    plt.show()
diff --git a/galleries/examples/ticks/tick-locators.py b/galleries/examples/ticks/tick-locators.py
index e634e022173c..6cf4afaf22d7 100644
--- a/galleries/examples/ticks/tick-locators.py
+++ b/galleries/examples/ticks/tick-locators.py
@@ -37,8 +37,8 @@ def setup(ax, title):
 axs[0].xaxis.set_minor_locator(ticker.NullLocator())

 # Multiple Locator
-setup(axs[1], title="MultipleLocator(0.5)")
-axs[1].xaxis.set_major_locator(ticker.MultipleLocator(0.5))
+setup(axs[1], title="MultipleLocator(0.5, offset=0.2)")
+axs[1].xaxis.set_major_locator(ticker.MultipleLocator(0.5, offset=0.2))
 axs[1].xaxis.set_minor_locator(ticker.MultipleLocator(0.1))

 # Fixed Locator
@@ -53,7 +53,7 @@ def setup(ax, title):

 # Index Locator
 setup(axs[4], title="IndexLocator(base=0.5, offset=0.25)")
-axs[4].plot(range(0, 5), [0]*5, color='white')
+axs[4].plot([0]*5, color='white')
 axs[4].xaxis.set_major_locator(ticker.IndexLocator(base=0.5, offset=0.25))

 # Auto Locator
diff --git a/lib/matplotlib/ticker.py b/lib/matplotlib/ticker.py
index bf059567f5fe..0877e58c5fe7 100644
--- a/lib/matplotlib/ticker.py
+++ b/lib/matplotlib/ticker.py
@@ -1831,17 +1831,41 @@ def view_limits(self, vmin, vmax):

 class MultipleLocator(Locator):
     """
-    Set a tick on each integer multiple of the *base* within the view
-    interval.
+    Set a tick on each integer multiple of the *base* plus an *offset* within
+    the view interval.
     """

-    def __init__(self, base=1.0):
+    def __init__(self, base=1.0, offset=0.0):
+        """
+        Parameters
+        ----------
+        base : float > 0
+            Interval between ticks.
+        offset : float
+            Value added to each multiple of *base*.
+
+            .. versionadded:: 3.8
+        """
         self._edge = _Edge_integer(base, 0)
+        self._offset = offset

-    def set_params(self, base):
-        """Set parameters within this locator."""
+    def set_params(self, base=None, offset=None):
+        """
+        Set parameters within this locator.
+
+        Parameters
+        ----------
+        base : float > 0
+            Interval between ticks.
+        offset : float
+            Value added to each multiple of *base*.
+
+            .. versionadded:: 3.8
+        """
         if base is not None:
             self._edge = _Edge_integer(base, 0)
+        if offset is not None:
+            self._offset = offset

     def __call__(self):
         """Return the locations of the ticks."""
@@ -1852,19 +1876,20 @@ def tick_values(self, vmin, vmax):
         if vmax < vmin:
             vmin, vmax = vmax, vmin
         step = self._edge.step
+        vmin -= self._offset
+        vmax -= self._offset
         vmin = self._edge.ge(vmin) * step
         n = (vmax - vmin + 0.001 * step) // step
-        locs = vmin - step + np.arange(n + 3) * step
+        locs = vmin - step + np.arange(n + 3) * step + self._offset
         return self.raise_if_exceeds(locs)

     def view_limits(self, dmin, dmax):
         """
-        Set the view limits to the nearest multiples of *base* that
-        contain the data.
+        Set the view limits to the nearest tick values that contain the data.
         """
         if mpl.rcParams['axes.autolimit_mode'] == 'round_numbers':
-            vmin = self._edge.le(dmin) * self._edge.step
-            vmax = self._edge.ge(dmax) * self._edge.step
+            vmin = self._edge.le(dmin - self._offset) * self._edge.step + self._offset
+            vmax = self._edge.ge(dmax - self._offset) * self._edge.step + self._offset
             if vmin == vmax:
                 vmin -= 1
                 vmax += 1
diff --git a/lib/matplotlib/ticker.pyi b/lib/matplotlib/ticker.pyi
index e53a665432e2..1f4239ef2718 100644
--- a/lib/matplotlib/ticker.pyi
+++ b/lib/matplotlib/ticker.pyi
@@ -212,9 +212,8 @@ class LinearLocator(Locator):
     ) -> None: ...

 class MultipleLocator(Locator):
-    def __init__(self, base: float = ...) -> None: ...
-    # Makes set_params `base` argument mandatory
-    def set_params(self, base: float | None) -> None: ...  # type: ignore[override]
+    def __init__(self, base: float = ..., offset: float = ...) -> None: ...
+    def set_params(self, base: float | None = ..., offset: float | None = ...) -> None: ...
     def view_limits(self, dmin: float, dmax: float) -> tuple[float, float]: ...

 class _Edge_integer:

diff --git a/lib/matplotlib/tests/test_ticker.py b/lib/matplotlib/tests/test_ticker.py
index 3d38df575f09..53224d373f80 100644
--- a/lib/matplotlib/tests/test_ticker.py
+++ b/lib/matplotlib/tests/test_ticker.py
@@ -63,6 +63,12 @@ def test_basic(self):
                                9.441, 12.588])
         assert_almost_equal(loc.tick_values(-7, 10), test_value)

+    def test_basic_with_offset(self):
+        loc = mticker.MultipleLocator(base=3.147, offset=1.2)
+        test_value = np.array([-8.241, -5.094, -1.947, 1.2, 4.347, 7.494,
+                               10.641])
+        assert_almost_equal(loc.tick_values(-7, 10), test_value)
+
     def test_view_limits(self):
         """
         Test basic behavior of view limits.
@@ -80,6 +86,15 @@ def test_view_limits_round_numbers(self):
             loc = mticker.MultipleLocator(base=3.147)
             assert_almost_equal(loc.view_limits(-4, 4), (-6.294, 6.294))

+    def test_view_limits_round_numbers_with_offset(self):
+        """
+        Test that everything works properly with 'round_numbers' for auto
+        limit.
+        """
+        with mpl.rc_context({'axes.autolimit_mode': 'round_numbers'}):
+            loc = mticker.MultipleLocator(base=3.147, offset=1.3)
+            assert_almost_equal(loc.view_limits(-4, 4), (-4.994, 4.447))
+
     def test_set_params(self):
         """
         Create multiple locator with 0.7 base, and change it to something else.
@@ -88,6 +103,8 @@ def test_set_params(self):
         mult = mticker.MultipleLocator(base=0.7)
         mult.set_params(base=1.7)
         assert mult._edge.step == 1.7
+        mult.set_params(offset=3)
+        assert mult._offset == 3

 class TestAutoMinorLocator:

diff --git a/doc/users/next_whats_new/multiplelocator_offset.rst b/doc/users/next_whats_new/multiplelocator_offset.rst
new file mode 100644
index 0000000000..863fdb3c4d
--- /dev/null
+++ b/doc/users/next_whats_new/multiplelocator_offset.rst
@@ -0,0 +1,17 @@
+``offset`` parameter for MultipleLocator
+----------------------------------------
+
+An *offset* may now be specified to shift all the ticks by the given value.
+
+.. plot::
+    :include-source: true
+
+    import matplotlib.pyplot as plt
+    import matplotlib.ticker as mticker
+
+    _, ax = plt.subplots()
+    ax.plot(range(10))
+    locator = mticker.MultipleLocator(base=3, offset=0.3)
+    ax.xaxis.set_major_locator(locator)
+
+    plt.show()


diff --git a/doc/api/next_api_changes/development/24257-AL.rst b/doc/api/next_api_changes/development/24257-AL.rst
new file mode 100644
index 000000000000..584420df8fd7
--- /dev/null
+++ b/doc/api/next_api_changes/development/24257-AL.rst
@@ -0,0 +1,2 @@
+importlib_resources>=2.3.0 is now required on Python<3.10
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
diff --git a/doc/devel/dependencies.rst b/doc/devel/dependencies.rst
index fb76857898a4..365c062364e2 100644
--- a/doc/devel/dependencies.rst
+++ b/doc/devel/dependencies.rst
@@ -26,6 +26,9 @@ reference.
 * `Pillow <https://pillow.readthedocs.io/en/latest/>`_ (>= 6.2)
 * `pyparsing <https://pypi.org/project/pyparsing/>`_ (>= 2.3.1)
 * `setuptools <https://setuptools.readthedocs.io/en/latest/>`_
+* `pyparsing <https://pypi.org/project/pyparsing/>`_ (>= 2.3.1)
+* `importlib-resources <https://pypi.org/project/importlib-resources/>`_
+  (>= 3.2.0; only required on Python < 3.10)

 .. _optional_dependencies:
diff --git a/doc/users/next_whats_new/styles_from_packages.rst b/doc/users/next_whats_new/styles_from_packages.rst
new file mode 100644
index 000000000000..d129bb356fa7
--- /dev/null
+++ b/doc/users/next_whats_new/styles_from_packages.rst
@@ -0,0 +1,11 @@
+Style files can be imported from third-party packages
+-----------------------------------------------------
+
+Third-party packages can now distribute style files that are globally available
+as follows.  Assume that a package is importable as ``import mypackage``, with
+a ``mypackage/__init__.py`` module.  Then a ``mypackage/presentation.mplstyle``
+style sheet can be used as ``plt.style.use("mypackage.presentation")``.
+
+The implementation does not actually import ``mypackage``, making this process
+safe against possible import-time side effects.  Subpackages (e.g.
+``dotted.package.name``) are also supported.
diff --git a/environment.yml b/environment.yml
index 28ff3a1b2c34..c9b7aa610720 100644
--- a/environment.yml
+++ b/environment.yml
@@ -12,6 +12,7 @@ dependencies:
   - contourpy>=1.0.1
   - cycler>=0.10.0
   - fonttools>=4.22.0
+  - importlib-resources>=3.2.0
   - kiwisolver>=1.0.1
   - numpy>=1.19
   - pillow>=6.2
diff --git a/lib/matplotlib/style/core.py b/lib/matplotlib/style/core.py
index 646b39dcc5df..ed5cd4f63bc5 100644
--- a/lib/matplotlib/style/core.py
+++ b/lib/matplotlib/style/core.py
@@ -15,10 +15,18 @@
 import logging
 import os
 from pathlib import Path
+import sys
 import warnings

+if sys.version_info >= (3, 10):
+    import importlib.resources as importlib_resources
+else:
+    # Even though Py3.9 has importlib.resources, it doesn't properly handle
+    # modules added in sys.path.
+    import importlib_resources
+
 import matplotlib as mpl
-from matplotlib import _api, _docstring, rc_params_from_file, rcParamsDefault
+from matplotlib import _api, _docstring, _rc_params_in_file, rcParamsDefault

 _log = logging.getLogger(__name__)

@@ -64,23 +72,6 @@
     "directly use the seaborn API instead.")

-def _remove_blacklisted_style_params(d, warn=True):
-    o = {}
-    for key in d:  # prevent triggering RcParams.__getitem__('backend')
-        if key in STYLE_BLACKLIST:
-            if warn:
-                _api.warn_external(
-                    f"Style includes a parameter, {key!r}, that is not "
-                    "related to style.  Ignoring this parameter.")
-        else:
-            o[key] = d[key]
-    return o
-
-
-def _apply_style(d, warn=True):
-    mpl.rcParams.update(_remove_blacklisted_style_params(d, warn=warn))
-
-
 @_docstring.Substitution(
     "\n".join(map("- {}".format, sorted(STYLE_BLACKLIST, key=str.lower)))
 )
@@ -99,20 +90,28 @@ def use(style):
     Parameters
     ----------
     style : str, dict, Path or list
-        A style specification. Valid options are:

-        +------+-------------------------------------------------------------+
-        | str  | The name of a style or a path/URL to a style file. For a    |
-        |      | list of available style names, see `.style.available`.      |
-        +------+-------------------------------------------------------------+
-        | dict | Dictionary with valid key/value pairs for                   |
-        |      | `matplotlib.rcParams`.                                      |
-        +------+-------------------------------------------------------------+
-        | Path | A path-like object which is a path to a style file.         |
-        +------+-------------------------------------------------------------+
-        | list | A list of style specifiers (str, Path or dict) applied from |
-        |      | first to last in the list.                                  |
-        +------+-------------------------------------------------------------+
+        A style specification.
+
+        - If a str, this can be one of the style names in `.style.available`
+          (a builtin style or a style installed in the user library path).
+
+          This can also be a dotted name of the form "package.style_name"; in
+          that case, "package" should be an importable Python package name,
+          e.g. at ``/path/to/package/__init__.py``; the loaded style file is
+          ``/path/to/package/style_name.mplstyle``.  (Style files in
+          subpackages are likewise supported.)
+
+          This can also be the path or URL to a style file, which gets loaded
+          by `.rc_params_from_file`.
+
+        - If a dict, this is a mapping of key/value pairs for `.rcParams`.
+
+        - If a Path, this is the path to a style file, which gets loaded by
+          `.rc_params_from_file`.
+
+        - If a list, this is a list of style specifiers (str, Path or dict),
+          which get applied from first to last in the list.

     Notes
     -----
@@ -129,33 +128,52 @@ def use(style):

     style_alias = {'mpl20': 'default', 'mpl15': 'classic'}

-    def fix_style(s):
-        if isinstance(s, str):
-            s = style_alias.get(s, s)
-            if s in _DEPRECATED_SEABORN_STYLES:
+    for style in styles:
+        if isinstance(style, str):
+            style = style_alias.get(style, style)
+            if style in _DEPRECATED_SEABORN_STYLES:
                 _api.warn_deprecated("3.6", message=_DEPRECATED_SEABORN_MSG)
-                s = _DEPRECATED_SEABORN_STYLES[s]
-        return s
-
-    for style in map(fix_style, styles):
-        if not isinstance(style, (str, Path)):
-            _apply_style(style)
-        elif style == 'default':
-            # Deprecation warnings were already handled when creating
-            # rcParamsDefault, no need to reemit them here.
-            with _api.suppress_matplotlib_deprecation_warning():
-                _apply_style(rcParamsDefault, warn=False)
-        elif style in library:
-            _apply_style(library[style])
-        else:
+                style = _DEPRECATED_SEABORN_STYLES[style]
+            if style == "default":
+                # Deprecation warnings were already handled when creating
+                # rcParamsDefault, no need to reemit them here.
+                with _api.suppress_matplotlib_deprecation_warning():
+                    # don't trigger RcParams.__getitem__('backend')
+                    style = {k: rcParamsDefault[k] for k in rcParamsDefault
+                             if k not in STYLE_BLACKLIST}
+            elif style in library:
+                style = library[style]
+            elif "." in style:
+                pkg, _, name = style.rpartition(".")
+                try:
+                    path = (importlib_resources.files(pkg)
+                            / f"{name}.{STYLE_EXTENSION}")
+                    style = _rc_params_in_file(path)
+                except (ModuleNotFoundError, IOError) as exc:
+                    # There is an ambiguity whether a dotted name refers to a
+                    # package.style_name or to a dotted file path.  Currently,
+                    # we silently try the first form and then the second one;
+                    # in the future, we may consider forcing file paths to
+                    # either use Path objects or be prepended with "./" and use
+                    # the slash as marker for file paths.
+                    pass
+        if isinstance(style, (str, Path)):
             try:
-                rc = rc_params_from_file(style, use_default_template=False)
-                _apply_style(rc)
+                style = _rc_params_in_file(style)
             except IOError as err:
                 raise IOError(
-                    "{!r} not found in the style library and input is not a "
-                    "valid URL or path; see `style.available` for list of "
-                    "available styles".format(style)) from err
+                    f"{style!r} is not a valid package style, path of style "
+                    f"file, URL of style file, or library style name (library "
+                    f"styles are listed in `style.available`)") from err
+        filtered = {}
+        for k in style:  # don't trigger RcParams.__getitem__('backend')
+            if k in STYLE_BLACKLIST:
+                _api.warn_external(
+                    f"Style includes a parameter, {k!r}, that is not "
+                    f"related to style.  Ignoring this parameter.")
+            else:
+                filtered[k] = style[k]
+        mpl.rcParams.update(filtered)

 @contextlib.contextmanager
@@ -205,8 +223,7 @@ def read_style_directory(style_dir):
     styles = dict()
     for path in Path(style_dir).glob(f"*.{STYLE_EXTENSION}"):
         with warnings.catch_warnings(record=True) as warns:
-            styles[path.stem] = rc_params_from_file(
-                path, use_default_template=False)
+            styles[path.stem] = _rc_params_in_file(path)
         for w in warns:
             _log.warning('In %s: %s', path, w.message)
     return styles
diff --git a/setup.py b/setup.py
index 365de0c0b5a2..2f1fb84f8cc6 100644
--- a/setup.py
+++ b/setup.py
@@ -334,6 +334,11 @@ def make_release_tree(self, base_dir, files):
             os.environ.get("CIBUILDWHEEL", "0") != "1"
         ) else []
     ),
+    extras_require={
+        ':python_version<"3.10"': [
+            "importlib-resources>=3.2.0",
+        ],
+    },
     use_scm_version={
         "version_scheme": "release-branch-semver",
         "local_scheme": "node-and-date",
diff --git a/tutorials/introductory/customizing.py b/tutorials/introductory/customizing.py
index ea6b501e99ea..10fc21d2187b 100644
--- a/tutorials/introductory/customizing.py
+++ b/tutorials/introductory/customizing.py
@@ -9,9 +9,9 @@

 There are three ways to customize Matplotlib:

-    1. :ref:`Setting rcParams at runtime<customizing-with-dynamic-rc-settings>`.
-    2. :ref:`Using style sheets<customizing-with-style-sheets>`.
-    3. :ref:`Changing your matplotlibrc file<customizing-with-matplotlibrc-files>`.
+1. :ref:`Setting rcParams at runtime<customizing-with-dynamic-rc-settings>`.
+2. :ref:`Using style sheets<customizing-with-style-sheets>`.
+3. :ref:`Changing your matplotlibrc file<customizing-with-matplotlibrc-files>`.

 Setting rcParams at runtime takes precedence over style sheets, style
 sheets take precedence over :file:`matplotlibrc` files.
@@ -137,6 +137,17 @@ def plotting_function():
 #    >>> import matplotlib.pyplot as plt
 #    >>> plt.style.use('./images/presentation.mplstyle')
 #
+#
+# Distributing styles
+# -------------------
+#
+# You can include style sheets into standard importable Python packages (which
+# can be e.g. distributed on PyPI).  If your package is importable as
+# ``import mypackage``, with a ``mypackage/__init__.py`` module, and you add
+# a ``mypackage/presentation.mplstyle`` style sheet, then it can be used as
+# ``plt.style.use("mypackage.presentation")``.  Subpackages (e.g.
+# ``dotted.package.name``) are also supported.
+#
 # Alternatively, you can make your style known to Matplotlib by placing
 # your ``<style-name>.mplstyle`` file into ``mpl_configdir/stylelib``.  You
 # can then load your custom style sheet with a call to

diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index 020f09df4010..eddb14be0bc6 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -162,8 +162,8 @@ jobs:

           # Install dependencies from PyPI.
           python -m pip install --upgrade $PRE \
-            'contourpy>=1.0.1' cycler fonttools kiwisolver numpy packaging \
-            pillow pyparsing python-dateutil setuptools-scm \
+            'contourpy>=1.0.1' cycler fonttools kiwisolver importlib_resources \
+            numpy packaging pillow pyparsing python-dateutil setuptools-scm \
             -r requirements/testing/all.txt \
             ${{ matrix.extra-requirements }}

diff --git a/lib/matplotlib/tests/test_style.py b/lib/matplotlib/tests/test_style.py
index c788c45920ae..7d1ed94ea236 100644
--- a/lib/matplotlib/tests/test_style.py
+++ b/lib/matplotlib/tests/test_style.py
@@ -190,3 +190,18 @@ def test_deprecated_seaborn_styles():

 def test_up_to_date_blacklist():
     assert mpl.style.core.STYLE_BLACKLIST <= {*mpl.rcsetup._validators}
+
+
+def test_style_from_module(tmp_path, monkeypatch):
+    monkeypatch.syspath_prepend(tmp_path)
+    monkeypatch.chdir(tmp_path)
+    pkg_path = tmp_path / "mpl_test_style_pkg"
+    pkg_path.mkdir()
+    (pkg_path / "test_style.mplstyle").write_text(
+        "lines.linewidth: 42", encoding="utf-8")
+    pkg_path.with_suffix(".mplstyle").write_text(
+        "lines.linewidth: 84", encoding="utf-8")
+    mpl.style.use("mpl_test_style_pkg.test_style")
+    assert mpl.rcParams["lines.linewidth"] == 42
+    mpl.style.use("mpl_test_style_pkg.mplstyle")
+    assert mpl.rcParams["lines.linewidth"] == 84
diff --git a/requirements/testing/minver.txt b/requirements/testing/minver.txt
index d932b0aa34e7..82301e900f52 100644
--- a/requirements/testing/minver.txt
+++ b/requirements/testing/minver.txt
@@ -3,6 +3,7 @@
 contourpy==1.0.1
 cycler==0.10
 kiwisolver==1.0.1
+importlib-resources==3.2.0
 numpy==1.19.0
 packaging==20.0
 pillow==6.2.1

diff --git a/doc/api/next_api_changes/development/#####-AL.rst b/doc/api/next_api_changes/development/#####-AL.rst
new file mode 100644
index 0000000000..584420df8f
--- /dev/null
+++ b/doc/api/next_api_changes/development/#####-AL.rst
@@ -0,0 +1,2 @@
+importlib_resources>=2.3.0 is now required on Python<3.10
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

diff --git a/doc/devel/dependencies.rst b/doc/devel/dependencies.rst
index fb76857898..365c062364 100644
--- a/doc/devel/dependencies.rst
+++ b/doc/devel/dependencies.rst
@@ -26,6 +26,9 @@ reference.
 * `Pillow <https://pillow.readthedocs.io/en/latest/>`_ (>= 6.2)
 * `pyparsing <https://pypi.org/project/pyparsing/>`_ (>= 2.3.1)
 * `setuptools <https://setuptools.readthedocs.io/en/latest/>`_
+* `pyparsing <https://pypi.org/project/pyparsing/>`_ (>= 2.3.1)
+* `importlib-resources <https://pypi.org/project/importlib-resources/>`_
+  (>= 3.2.0; only required on Python < 3.10)

 .. _optional_dependencies:

diff --git a/doc/users/next_whats_new/styles_from_packages.rst b/doc/users/next_whats_new/styles_from_packages.rst
new file mode 100644
index 0000000000..d129bb356f
--- /dev/null
+++ b/doc/users/next_whats_new/styles_from_packages.rst
@@ -0,0 +1,11 @@
+Style files can be imported from third-party packages
+-----------------------------------------------------
+
+Third-party packages can now distribute style files that are globally available
+as follows.  Assume that a package is importable as ``import mypackage``, with
+a ``mypackage/__init__.py`` module.  Then a ``mypackage/presentation.mplstyle``
+style sheet can be used as ``plt.style.use("mypackage.presentation")``.
+
+The implementation does not actually import ``mypackage``, making this process
+safe against possible import-time side effects.  Subpackages (e.g.
+``dotted.package.name``) are also supported.


diff --git a/doc/users/next_whats_new/contour_clip_path.rst b/doc/users/next_whats_new/contour_clip_path.rst
new file mode 100644
index 000000000000..db4039a4fd70
--- /dev/null
+++ b/doc/users/next_whats_new/contour_clip_path.rst
@@ -0,0 +1,24 @@
+Clipping for contour plots
+--------------------------
+
+`~.Axes.contour` and `~.Axes.contourf` now accept the *clip_path* parameter.
+
+.. plot::
+    :include-source: true
+
+    import numpy as np
+    import matplotlib.pyplot as plt
+    import matplotlib.patches as mpatches
+
+    x = y = np.arange(-3.0, 3.01, 0.025)
+    X, Y = np.meshgrid(x, y)
+    Z1 = np.exp(-X**2 - Y**2)
+    Z2 = np.exp(-(X - 1)**2 - (Y - 1)**2)
+    Z = (Z1 - Z2) * 2
+
+    fig, ax = plt.subplots()
+    patch = mpatches.RegularPolygon((0, 0), 5, radius=2,
+                                    transform=ax.transData)
+    ax.contourf(X, Y, Z, clip_path=patch)
+
+    plt.show()
diff --git a/lib/matplotlib/contour.py b/lib/matplotlib/contour.py
index 625c3524bfcb..6f97c5646ab0 100644
--- a/lib/matplotlib/contour.py
+++ b/lib/matplotlib/contour.py
@@ -751,7 +751,7 @@ def __init__(self, ax, *args,
                  hatches=(None,), alpha=None, origin=None, extent=None,
                  cmap=None, colors=None, norm=None, vmin=None, vmax=None,
                  extend='neither', antialiased=None, nchunk=0, locator=None,
-                 transform=None, negative_linestyles=None,
+                 transform=None, negative_linestyles=None, clip_path=None,
                  **kwargs):
         """
         Draw contour lines or filled regions, depending on
@@ -805,6 +805,7 @@ def __init__(self, ax, *args,
         super().__init__(
             antialiaseds=antialiased,
             alpha=alpha,
+            clip_path=clip_path,
             transform=transform,
         )
         self.axes = ax
@@ -1870,6 +1871,11 @@ def _initialize_x_y(self, z):

     The default is taken from :rc:`contour.algorithm`.

+clip_path : `~matplotlib.patches.Patch` or `.Path` or `.TransformedPath`
+    Set the clip path.  See `~matplotlib.artist.Artist.set_clip_path`.
+
+    .. versionadded:: 3.8
+
 data : indexable object, optional
     DATA_PARAMETER_PLACEHOLDER

diff --git a/lib/matplotlib/contour.pyi b/lib/matplotlib/contour.pyi
index c2190577169d..c7179637a1e1 100644
--- a/lib/matplotlib/contour.pyi
+++ b/lib/matplotlib/contour.pyi
@@ -4,8 +4,10 @@ from matplotlib.axes import Axes
 from matplotlib.collections import Collection, PathCollection
 from matplotlib.colors import Colormap, Normalize
 from matplotlib.font_manager import FontProperties
+from matplotlib.path import Path
+from matplotlib.patches import Patch
 from matplotlib.text import Text
-from matplotlib.transforms import Transform
+from matplotlib.transforms import Transform, TransformedPatchPath, TransformedPath
 from matplotlib.ticker import Locator, Formatter

 from numpy.typing import ArrayLike
@@ -99,6 +101,7 @@ class ContourSet(ContourLabeler, Collection):
     negative_linestyles: None | Literal[
         "solid", "dashed", "dashdot", "dotted"
     ] | Iterable[Literal["solid", "dashed", "dashdot", "dotted"]]
+    clip_path: Patch | Path | TransformedPath | TransformedPatchPath | None
     labelTexts: list[Text]
     labelCValues: list[ColorType]
     allkinds: list[np.ndarray]
@@ -145,6 +148,7 @@ class ContourSet(ContourLabeler, Collection):
         negative_linestyles: Literal["solid", "dashed", "dashdot", "dotted"]
         | Iterable[Literal["solid", "dashed", "dashdot", "dotted"]]
         | None = ...,
+        clip_path: Patch | Path | TransformedPath | TransformedPatchPath | None = ...,
         **kwargs
     ) -> None: ...
     def legend_elements(

diff --git a/lib/matplotlib/tests/test_contour.py b/lib/matplotlib/tests/test_contour.py
index c730c8ea332d..7a4c4570580c 100644
--- a/lib/matplotlib/tests/test_contour.py
+++ b/lib/matplotlib/tests/test_contour.py
@@ -9,6 +9,7 @@
 import matplotlib as mpl
 from matplotlib import pyplot as plt, rc_context, ticker
 from matplotlib.colors import LogNorm, same_color
+import matplotlib.patches as mpatches
 from matplotlib.testing.decorators import image_comparison
 import pytest

@@ -752,6 +753,14 @@ def test_contour_no_args():
         ax.contour(Z=data)

+def test_contour_clip_path():
+    fig, ax = plt.subplots()
+    data = [[0, 1], [1, 0]]
+    circle = mpatches.Circle([0.5, 0.5], 0.5, transform=ax.transAxes)
+    cs = ax.contour(data, clip_path=circle)
+    assert cs.get_clip_path() is not None
+
+
 def test_bool_autolevel():
     x, y = np.random.rand(2, 9)
     z = (np.arange(9) % 2).reshape((3, 3)).astype(bool)

diff --git a/doc/users/next_whats_new/contour_clip_path.rst b/doc/users/next_whats_new/contour_clip_path.rst
new file mode 100644
index 0000000000..db4039a4fd
--- /dev/null
+++ b/doc/users/next_whats_new/contour_clip_path.rst
@@ -0,0 +1,24 @@
+Clipping for contour plots
+--------------------------
+
+`~.Axes.contour` and `~.Axes.contourf` now accept the *clip_path* parameter.
+
+.. plot::
+    :include-source: true
+
+    import numpy as np
+    import matplotlib.pyplot as plt
+    import matplotlib.patches as mpatches
+
+    x = y = np.arange(-3.0, 3.01, 0.025)
+    X, Y = np.meshgrid(x, y)
+    Z1 = np.exp(-X**2 - Y**2)
+    Z2 = np.exp(-(X - 1)**2 - (Y - 1)**2)
+    Z = (Z1 - Z2) * 2
+
+    fig, ax = plt.subplots()
+    patch = mpatches.RegularPolygon((0, 0), 5, radius=2,
+                                    transform=ax.transData)
+    ax.contourf(X, Y, Z, clip_path=patch)
+
+    plt.show()


diff --git a/doc/users/next_whats_new/spinesproxyset.rst b/doc/users/next_whats_new/spinesproxyset.rst
new file mode 100644
index 000000000000..cfd8d2908ec7
--- /dev/null
+++ b/doc/users/next_whats_new/spinesproxyset.rst
@@ -0,0 +1,3 @@
+``SpinesProxy`` now supports calling the ``set()`` method
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+One can now call e.g. ``ax.spines[:].set(visible=False)``.
diff --git a/lib/matplotlib/spines.py b/lib/matplotlib/spines.py
index 8560f7b6e4e2..eacb3e7e9d5c 100644
--- a/lib/matplotlib/spines.py
+++ b/lib/matplotlib/spines.py
@@ -479,7 +479,7 @@ def set_color(self, c):

 class SpinesProxy:
     """
-    A proxy to broadcast ``set_*`` method calls to all contained `.Spines`.
+    A proxy to broadcast ``set_*()`` and ``set()`` method calls to contained `.Spines`.

     The proxy cannot be used for any other operations on its members.

@@ -493,7 +493,7 @@ def __init__(self, spine_dict):
     def __getattr__(self, name):
         broadcast_targets = [spine for spine in self._spine_dict.values()
                              if hasattr(spine, name)]
-        if not name.startswith('set_') or not broadcast_targets:
+        if (name != 'set' and not name.startswith('set_')) or not broadcast_targets:
             raise AttributeError(
                 f"'SpinesProxy' object has no attribute '{name}'")

@@ -531,8 +531,8 @@ class Spines(MutableMapping):

         spines[:].set_visible(False)

-    The latter two indexing methods will return a `SpinesProxy` that broadcasts
-    all ``set_*`` calls to its members, but cannot be used for any other
+    The latter two indexing methods will return a `SpinesProxy` that broadcasts all
+    ``set_*()`` and ``set()`` calls to its members, but cannot be used for any other
     operation.
     """
     def __init__(self, **kwargs):

diff --git a/lib/matplotlib/tests/test_spines.py b/lib/matplotlib/tests/test_spines.py
index 89bc0c872de5..9ce16fb39227 100644
--- a/lib/matplotlib/tests/test_spines.py
+++ b/lib/matplotlib/tests/test_spines.py
@@ -12,6 +12,9 @@ class SpineMock:
         def __init__(self):
             self.val = None

+        def set(self, **kwargs):
+            vars(self).update(kwargs)
+
         def set_val(self, val):
             self.val = val

@@ -35,6 +38,9 @@ def set_val(self, val):
     spines[:].set_val('y')
     assert all(spine.val == 'y' for spine in spines.values())

+    spines[:].set(foo='bar')
+    assert all(spine.foo == 'bar' for spine in spines.values())
+
     with pytest.raises(AttributeError, match='foo'):
         spines.foo
     with pytest.raises(KeyError, match='foo'):

diff --git a/doc/users/next_whats_new/spinesproxyset.rst b/doc/users/next_whats_new/spinesproxyset.rst
new file mode 100644
index 0000000000..cfd8d2908e
--- /dev/null
+++ b/doc/users/next_whats_new/spinesproxyset.rst
@@ -0,0 +1,3 @@
+``SpinesProxy`` now supports calling the ``set()`` method
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+One can now call e.g. ``ax.spines[:].set(visible=False)``.