Repository: sunpy/sunpy

Issue:
map.draw_rectangle API is inconsistent with submap
submap uses top right and bottom left and draw rectangle uses bottom right width and height. This is stupid.

This is especially stupid as it prevents you from plotting a rectangle in the coordinates of one image over another.


Discussion:
This will need careful API deprecation :frowning_face: 
What's the best way to go about this? Do we support an extra kwarg (`top_right`???) and ignore `width` and `height` if `top_right` is given? 
Or do we add a new method altogether with this new syntax and then add a deprecation warning to `draw_rectangle`?
> What's the best way to go about this? Do we support an extra kwarg (top_right???) and ignore width and height if top_right is given?

I was thinking this one.

I quite like the name `draw_rectangle` so I don't really know what I would call the new one.
So, from what i get, we need to add a parameter ``top_right`` and use it and ``bottom_left`` to figure out ``height`` and ``width`` as ``plt.Rectangle`` works with ``height`` and ``width`` ? 
I assume we would want to unify the API for both functions using submap API's.
In other words, create the rectangle as is done in ``submap`` and use the same for ``draw_rectangle``?
Yeah but raise warnings if they use the old keywords that they will be removed in future. 

Resolving patch:
diff --git a/sunpy/coordinates/utils.py b/sunpy/coordinates/utils.py
--- a/sunpy/coordinates/utils.py
+++ b/sunpy/coordinates/utils.py
@@ -1,13 +1,15 @@
 """
 Miscellaneous utilities related to coordinates
 """
+import warnings
+
 import numpy as np
 
 import astropy.units as u
 from astropy.coordinates import SkyCoord, BaseCoordinateFrame
 
 from sunpy.coordinates import Heliocentric
-
+from sunpy.util.exceptions import SunpyUserWarning
 
 __all__ = ['GreatArc', 'get_rectangle_coordinates']
 
@@ -339,6 +341,18 @@ def get_rectangle_coordinates(bottom_left, *, top_right = None, width: u.deg = N
         raise ValueError("Invalid input, either bottom_left and top_right "
                          "or bottom_left and height and width should be provided.")
 
+    if width is not None:
+        if width < 0*u.deg:
+            raise ValueError("The specified width cannot be negative.")
+        if width > 360*u.deg:
+            raise ValueError("The specified width cannot be greater than 360 degrees.")
+
+    if height is not None:
+        if height < 0*u.deg:
+            raise ValueError("The specified height cannot be negative.")
+        if bottom_left.spherical.lat + height > 90*u.deg:
+            raise ValueError("The specified height exceeds the maximum latitude.")
+
     if bottom_left.shape == (2,):
         top_right = bottom_left[1]
         bottom_left = bottom_left[0]
@@ -359,4 +373,13 @@ def get_rectangle_coordinates(bottom_left, *, top_right = None, width: u.deg = N
         if isinstance(bottom_left, BaseCoordinateFrame):
             top_right = top_right.frame
 
+    if top_right.spherical.lon < bottom_left.spherical.lon:
+        warnings.warn("The rectangle is inverted in the left/right (longitude) direction, possibly "
+                      f"due to the longitude wrap angle ({bottom_left.spherical.lon.wrap_angle}), "
+                      "and may lead to unintended behavior.", SunpyUserWarning)
+
+    if top_right.spherical.lat < bottom_left.spherical.lat:
+        warnings.warn("The rectangle is inverted in the bottom/top (latitude) direction "
+                      "and may lead to unintended behavior.", SunpyUserWarning)
+
     return bottom_left, top_right
diff --git a/sunpy/map/mapbase.py b/sunpy/map/mapbase.py
--- a/sunpy/map/mapbase.py
+++ b/sunpy/map/mapbase.py
@@ -21,7 +21,7 @@
 import astropy.units as u
 from astropy.visualization import AsymmetricPercentileInterval, HistEqStretch, ImageNormalize
 from astropy.visualization.wcsaxes import WCSAxes
-from astropy.coordinates import SkyCoord, UnitSphericalRepresentation
+from astropy.coordinates import SkyCoord, UnitSphericalRepresentation, Longitude, Latitude
 
 import sunpy.io as io
 # The next two are not used but are called to register functions with external modules
@@ -35,6 +35,8 @@
 from sunpy.image.resample import reshape_image_to_4d_superpixel
 from sunpy.image.resample import resample as sunpy_image_resample
 from sunpy.coordinates import get_earth
+from sunpy.coordinates.utils import get_rectangle_coordinates
+from sunpy.util.decorators import deprecate_positional_args_since
 from sunpy.util import expand_list
 from sunpy.util.exceptions import SunpyUserWarning
 
@@ -1374,10 +1376,11 @@ def rotate(self, angle: u.deg = None, rmatrix=None, order=4, scale=1.0,
 
         return new_map
 
-    def submap(self, bottom_left, top_right=None):
+    @deprecate_positional_args_since(since='2.0', keyword_only=('width', 'height'))
+    @u.quantity_input
+    def submap(self, bottom_left, *, top_right=None, width: (u.deg, u.pix)=None, height: (u.deg, u.pix)=None):
         """
-        Returns a submap of the map defined by the rectangle given by the
-        ``[bottom_left, top_right]`` coordinates.
+        Returns a submap defined by a rectangle.
 
         Any pixels which have at least part of their area inside the rectangle
         are returned.
@@ -1385,13 +1388,19 @@ def submap(self, bottom_left, top_right=None):
         Parameters
         ----------
         bottom_left : `astropy.units.Quantity` or `~astropy.coordinates.SkyCoord`
-            The bottom_left coordinate of the rectangle. If a `SkyCoord` it can
-            have shape ``(2,)`` and also define ``top_right``. If specifying
+            The bottom-left coordinate of the rectangle. If a `SkyCoord` it can
+            have shape ``(2,)`` and simultaneously define ``top_right``. If specifying
             pixel coordinates it must be given as an `~astropy.units.Quantity`
             object with units of `~astropy.units.pixel`.
-        top_right : `astropy.units.Quantity` or `~astropy.coordinates.SkyCoord`
-            The top_right coordinate of the rectangle. Can only be omitted if
-            ``bottom_left`` has shape ``(2,)``.
+        top_right : `astropy.units.Quantity` or `~astropy.coordinates.SkyCoord`, optional
+            The top-right coordinate of the rectangle. If ``top_right`` is
+            specified ``width`` and ``height`` must be omitted.
+            Passing this as a positional argument is deprecated, you must pass
+            it as ``top_right=...``.
+        width : `astropy.units.Quantity`, optional
+            The width of the rectangle. Required if ``top_right`` is omitted.
+        height : `astropy.units.Quantity`
+            The height of the rectangle. Required if ``top_right`` is omitted.
 
         Returns
         -------
@@ -1408,7 +1417,7 @@ def submap(self, bottom_left, top_right=None):
         >>> aia = sunpy.map.Map(sunpy.data.sample.AIA_171_IMAGE)  # doctest: +REMOTE_DATA
         >>> bl = SkyCoord(-300*u.arcsec, -300*u.arcsec, frame=aia.coordinate_frame)  # doctest: +REMOTE_DATA
         >>> tr = SkyCoord(500*u.arcsec, 500*u.arcsec, frame=aia.coordinate_frame)  # doctest: +REMOTE_DATA
-        >>> aia.submap(bl, tr)   # doctest: +REMOTE_DATA
+        >>> aia.submap(bl, top_right=tr)   # doctest: +REMOTE_DATA
         <sunpy.map.sources.sdo.AIAMap object at 0x...>
         SunPy Map
         ---------
@@ -1464,23 +1473,77 @@ def submap(self, bottom_left, top_right=None):
                 -0.9746264 ],
             [-95.92475   ,   6.028058  ,  -4.9797    ,  -1.0483578 ,
                 -3.9313421 ]], dtype=float32)
-        """
-        # Convert coordinates to pixels
-        if isinstance(bottom_left, (astropy.coordinates.SkyCoord,
-                                    astropy.coordinates.BaseCoordinateFrame)):
-            if not top_right:
-                if bottom_left.shape[0] != 2:
-                    raise ValueError("If top_right is not specified bottom_left must have length two.")
-                else:
-                    top_right = bottom_left[1]
-                    bottom_left = bottom_left[0]
+
+        >>> width = 10 * u.arcsec
+        >>> height = 10 * u.arcsec
+        >>> aia.submap(bl, width=width, height=height)   # doctest: +REMOTE_DATA
+        <sunpy.map.sources.sdo.AIAMap object at 0x7f91aecc5438>
+        SunPy Map
+        ---------
+        Observatory:		 SDO
+        Instrument:		 AIA 3
+        Detector:		 AIA
+        Measurement:		 171.0 Angstrom
+        Wavelength:		 171.0 Angstrom
+        Observation Date:	 2011-06-07 06:33:02
+        Exposure Time:		 0.234256 s
+        Dimension:		 [4. 4.] pix
+        Coordinate System:	 helioprojective
+        Scale:			 [2.402792 2.402792] arcsec / pix
+        Reference Pixel:	 [126.5 126.5] pix
+        Reference Coord:	 [3.22309951 1.38578135] arcsec
+        array([[565.81494, 585.0416 , 656.4552 , 670.18854],
+            [516.1865 , 555.7032 , 634.7365 , 661.90424],
+            [620.9256 , 620.9256 , 654.8825 , 596.6707 ],
+            [667.5083 , 560.52094, 651.22766, 530.28534]], dtype=float32)
+
+        >>> bottom_left_vector = SkyCoord([0, 10]  * u.arcsec, [0, 10] * u.arcsec, frame='heliographic_stonyhurst')
+        >>> aia.submap(bottom_left_vector)   # doctest: +REMOTE_DATA
+        <sunpy.map.sources.sdo.AIAMap object at 0x7f91aece8be0>
+        SunPy Map
+        ---------
+        Observatory:		 SDO
+        Instrument:		 AIA 3
+        Detector:		 AIA
+        Measurement:		 171.0 Angstrom
+        Wavelength:		 171.0 Angstrom
+        Observation Date:	 2011-06-07 06:33:02
+        Exposure Time:		 0.234256 s
+        Dimension:		 [4. 5.] pix
+        Coordinate System:	 helioprojective
+        Scale:			 [2.402792 2.402792] arcsec / pix
+        Reference Pixel:	 [1.5 1.5] pix
+        Reference Coord:	 [3.22309951 1.38578135] arcsec
+        array([[213.9748 , 256.76974, 244.54262, 356.62466],
+            [223.74321, 258.0102 , 292.27716, 340.65408],
+            [219.53459, 242.31648, 308.5911 , 331.373  ],
+            [268.24377, 254.83157, 268.24377, 321.89252],
+            [249.99167, 265.14267, 274.61206, 240.5223 ]], dtype=float32)
+        """
+        if not isinstance(bottom_left, u.Quantity):
+            bottom_left, top_right = get_rectangle_coordinates(bottom_left,
+                                                               top_right=top_right,
+                                                               width=width,
+                                                               height=height)
 
             bottom_left = self.world_to_pixel(bottom_left)
             top_right = self.world_to_pixel(top_right)
 
-        elif not (isinstance(bottom_left, u.Quantity) and bottom_left.unit.is_equivalent(u.pix) and
-                  isinstance(top_right, u.Quantity) and top_right.unit.is_equivalent(u.pix)):
-            raise ValueError("Invalid input, bottom_left and top_right must either be SkyCoord or Quantity in pixels.")
+        elif ([arg is not None for arg in (top_right, width, height)]
+              not in [[True, False, False], [False, True, True]]):
+            raise ValueError("If bottom_left is not a SkyCoord either top_right alone or "
+                             "both width and height must be specified.")
+
+        elif (not all([arg is None or (isinstance(arg, u.Quantity) and arg.unit.is_equivalent(u.pix))
+                       for arg in (bottom_left, top_right, width, height)])):
+            raise TypeError("When bottom_left is not a SkyCoord, any values of top_right, "
+                             "width or height specified must be Quantity objects in units of pixels.")
+
+        elif bottom_left.shape != (2,) or (top_right is not None and top_right.shape != (2,)):
+            raise ValueError("Both bottom_left and top_right when specified as Quantity objects must have shape (2,)")
+
+        elif height is not None and width is not None:
+            top_right = u.Quantity([bottom_left[0] + width, bottom_left[1] + height])
 
         x_pixels = u.Quantity([bottom_left[0], top_right[0]]).to_value(u.pix)
         y_pixels = u.Quantity([top_right[1], bottom_left[1]]).to_value(u.pix)
@@ -1714,66 +1777,74 @@ def draw_limb(self, axes=None, **kwargs):
 
         return [circ]
 
+    @deprecate_positional_args_since(since='2.0')
     @u.quantity_input
-    def draw_rectangle(self, bottom_left, width: u.deg, height: u.deg, axes=None, **kwargs):
+    def draw_rectangle(self, bottom_left, *, width: u.deg=None, height: u.deg=None,
+                       axes=None, top_right=None, **kwargs):
         """
         Draw a rectangle defined in world coordinates on the plot.
 
         Parameters
         ----------
-
-        bottom_left : `~astropy.coordinates.SkyCoord` or `~astropy.coordinates.BaseCoordinateFrame`
-            The bottom left corner of the rectangle.
-
-        width : `astropy.units.Quantity`
-            The width of the rectangle.
-
+        bottom_left : `~astropy.coordinates.SkyCoord`
+            The bottom-left coordinate of the rectangle. It can
+            have shape ``(2,)`` to simultaneously define ``top_right``.
+        top_right : `~astropy.coordinates.SkyCoord`
+            The top-right coordinate of the rectangle.
+            Passing this as a positional argument is deprecated.
+        width : `astropy.units.Quantity`, optional
+            The width of the rectangle. Required if ``top_right`` is omitted.
+            Passing this as a positional argument is deprecated.
         height : `astropy.units.Quantity`
-            The height of the rectangle.
-
+            The height of the rectangle. Required if ``top_right`` is omitted.
+            Passing this as a positional argument is deprecated.
         axes : `matplotlib.axes.Axes`
             The axes on which to plot the rectangle, defaults to the current
             axes.
 
         Returns
         -------
-
         rect : `list`
             A list containing the `~matplotlib.patches.Rectangle` object, after
             it has been added to ``axes``.
 
         Notes
         -----
-
         Extra keyword arguments to this function are passed through to the
         `~matplotlib.patches.Rectangle` instance.
-
         """
+        if isinstance(top_right, u.Quantity) and isinstance(width, u.Quantity):
+            # The decorator assigns the first positional arg to top_right and so on.
+            height = width
+            width = top_right
+            top_right = None
+
+        bottom_left, top_right = get_rectangle_coordinates(bottom_left,
+                                                           top_right=top_right,
+                                                           width=width,
+                                                           height=height)
+
+        width = Longitude(top_right.spherical.lon - bottom_left.spherical.lon)
+        height = Latitude(top_right.spherical.lat - bottom_left.spherical.lat)
 
         if not axes:
             axes = plt.gca()
-
         if wcsaxes_compat.is_wcsaxes(axes):
             axes_unit = u.deg
         else:
             axes_unit = self.spatial_units[0]
 
         coord = bottom_left.transform_to(self.coordinate_frame)
-        bottom_left = u.Quantity((coord.data.lon, coord.data.lat),
-                                 unit=axes_unit).value
+        bottom_left = u.Quantity((coord.spherical.lon, coord.spherical.lat), unit=axes_unit).value
 
         width = width.to(axes_unit).value
         height = height.to(axes_unit).value
-
         kwergs = {'transform': wcsaxes_compat.get_world_transform(axes),
                   'color': 'white',
                   'fill': False}
         kwergs.update(kwargs)
-
         rect = plt.Rectangle(bottom_left, width, height, **kwergs)
-
         axes.add_artist(rect)
-
         return [rect]
 
     @u.quantity_input
diff --git a/sunpy/util/decorators.py b/sunpy/util/decorators.py
--- a/sunpy/util/decorators.py
+++ b/sunpy/util/decorators.py
@@ -6,6 +6,8 @@
 import textwrap
 import warnings
 import functools
+from inspect import signature, isclass, Parameter
+from functools import wraps
 
 from sunpy.util.exceptions import SunpyDeprecationWarning, SunpyPendingDeprecationWarning
 
@@ -264,3 +266,61 @@ def __call__(self, func):
         if self.kwargs:
             func.__doc__ = func.__doc__.format(**self.kwargs)
         return func
+
+def deprecate_positional_args_since(since, keyword_only=False):
+    """
+    Parameters
+    ----------
+    since: str
+        Parameter denoting last supported version.
+    """
+    def deprecate_positional_args(f):
+        """
+        Decorator for methods that issues warnings for positional arguments
+        Using the keyword-only argument syntax in pep 3102, arguments after the
+        * will issue a warning when passed as a positional argument.
+
+        Parameters
+        ----------
+        f: function
+            Function to check arguments on.
+
+        References
+        ----------
+        Taken from from `scikit-learn <https://github.com/scikit-learn/scikit-learn/blob/master/sklearn/utils/validation.py#L1271>`__.
+        Licensed under the BSD, see "licenses/SCIKIT-LEARN.rst".
+        """
+        nonlocal keyword_only
+
+        sig = signature(f)
+        kwonly_args = []
+        all_args = []
+        keyword_only = keyword_only or tuple()
+
+        for name, param in sig.parameters.items():
+            if param.kind == Parameter.POSITIONAL_OR_KEYWORD:
+                all_args.append(name)
+            elif param.kind == Parameter.KEYWORD_ONLY:
+                kwonly_args.append(name)
+
+        @wraps(f)
+        def inner_f(*args, **kwargs):
+            extra_args = len(args) - len(all_args)
+            if extra_args > 0:
+                for name, arg in zip(kwonly_args[:extra_args], args[-extra_args:]):
+                    if name in keyword_only:
+                        raise TypeError(f"{name} must be specified as a keyword argument.")
+
+                # ignore first 'self' argument for instance methods
+                args_msg = [f'{name}={arg}'
+                            for name, arg in zip(kwonly_args[:extra_args],
+                                                 args[-extra_args:])]
+                last_supported_version = ".".join(map(str, get_removal_version(since)))
+                warnings.warn(f"Pass {', '.join(args_msg)} as keyword args. "
+                              f"From version {last_supported_version} "
+                               "passing these as positional arguments will result in an error.",
+                                SunpyDeprecationWarning)
+            kwargs.update({k: arg for k, arg in zip(sig.parameters, args)})
+            return f(**kwargs)
+        return inner_f
+    return deprecate_positional_args


Test patch:
diff --git a/sunpy/map/tests/test_mapbase.py b/sunpy/map/tests/test_mapbase.py
--- a/sunpy/map/tests/test_mapbase.py
+++ b/sunpy/map/tests/test_mapbase.py
@@ -27,7 +27,7 @@
 import sunpy.coordinates
 from sunpy.coordinates import sun
 from sunpy.time import parse_time
-from sunpy.util import SunpyUserWarning
+from sunpy.util import SunpyUserWarning, SunpyDeprecationWarning
 
 testpath = sunpy.data.test.rootdir
 
@@ -469,8 +469,9 @@ def test_dimensions(simple_map):
 @pytest.mark.parametrize('rect, submap_out', pixel_corners)
 def test_submap_pixel(simple_map, rect, submap_out):
     # Check that result is the same specifying corners either way round
-    for r in [(rect[0], rect[1]), (rect[1], rect[0])]:
-        submap = simple_map.submap(*r)
+    for r in [dict(bottom_left=rect[0], top_right=rect[1]),
+              dict(bottom_left=rect[1], top_right=rect[0])]:
+        submap = simple_map.submap(**r)
         np.testing.assert_equal(submap.data, submap_out)
 
 
@@ -484,11 +485,11 @@ def test_submap_world(simple_map, rect, submap_out):
     corner2 = simple_map.pixel_to_world(*rect[1])
     corners = simple_map.pixel_to_world(u.Quantity([rect[0][0], rect[1][0]]),
                                         u.Quantity([rect[0][1], rect[1][1]]))
-    for r in [(corner1, corner2),
-              (corner2, corner1),
-              (corners, ),
+    for r in [dict(bottom_left=corner1, top_right=corner2),
+              dict(bottom_left=corner2, top_right=corner1),
+              dict(bottom_left=corners, ),
               ]:
-        submap = simple_map.submap(*r)
+        submap = simple_map.submap(**r)
         np.testing.assert_equal(submap.data, submap_out)
 
 
@@ -500,7 +501,7 @@ def test_submap_data_header(generic_map, unit):
     height = generic_map.data.shape[0]
 
     # Create a submap of the top-right quadrant of the image
-    submap = generic_map.submap([width / 2., height / 2.] * unit, [width, height] * unit)
+    submap = generic_map.submap([width / 2., height / 2.] * unit, top_right=[width, height] * unit)
 
     # Check to see if submap properties were updated properly
     assert submap.reference_pixel.x.value == generic_map.meta['crpix1'] - 1 - width / 2.
@@ -921,3 +922,142 @@ def test_quicklook(aia171_test_map):
         html_string = f.read()
 
         assert aia171_test_map._repr_html_() in html_string
+
+@pytest.fixture
+def generic_map2(generic_map):
+    generic_map.meta["CTYPE1"] = "HPLN-TAN"
+    generic_map.meta["CTYPE2"] = "HPLT-TAN"
+    return generic_map
+
+
+@pytest.fixture
+def coords(generic_map2):
+    bl_coord = SkyCoord(20, -10, unit=u.arcsec,
+                        frame=generic_map2.coordinate_frame)
+
+    tr_coord = SkyCoord(0, 10, unit=u.arcsec,
+                        frame=generic_map2.coordinate_frame)
+
+    bl_tr_coord = SkyCoord([20, 0], [-10, 10], unit=u.arcsec,
+                            frame=generic_map2.coordinate_frame)
+
+    return bl_coord, tr_coord, bl_tr_coord
+
+bl_pix = [3, 2] * u.pix
+tr_pix = [5, 4] * u.pix
+width_pix = 2 * u.pix
+height_pix = 2 * u.pix
+width_deg = 20 * u.arcsec
+height_deg = 20 * u.arcsec
+
+def test_deprecated_submap_inputs(generic_map2, coords):
+    bl_coord, tr_coord, bl_tr_coord = coords
+    # deprecated
+    with pytest.warns(SunpyDeprecationWarning):
+        smap = generic_map2.submap(bl_coord, tr_coord)
+    assert u.allclose(smap.dimensions, (3, 3) * u.pix)
+
+    with pytest.warns(SunpyDeprecationWarning):
+        smap = generic_map2.submap(bl_pix, tr_pix)
+    assert u.allclose(smap.dimensions, (3, 3) * u.pix)
+
+    # error
+    with pytest.raises(TypeError, match="width must be specified as a keyword argument"):
+        generic_map2.submap(bl_coord, width_deg, height_deg)
+
+    with pytest.raises(TypeError, match="width must be specified as a keyword argument"):
+        generic_map2.submap(bl_pix, width_pix, height_pix)
+
+    with pytest.warns(SunpyDeprecationWarning):
+        with pytest.raises(TypeError,
+                           match="top_right must be of type SkyCoord or BaseCoordinateFrame."):
+            generic_map2.submap(bl_coord, width_deg, height=height_deg)
+
+    with pytest.warns(SunpyDeprecationWarning):
+        with pytest.raises(ValueError,
+                           match="either top_right alone or both width and height must be specified"):
+            generic_map2.submap(bl_pix, width_pix, height=height_pix)
+
+    with pytest.warns(SunpyDeprecationWarning):
+        with pytest.raises(TypeError,
+                           match="top_right must be of type SkyCoord or BaseCoordinateFrame"):
+            generic_map2.submap(bl_coord, width_deg, height=height_deg)
+
+    with pytest.warns(SunpyDeprecationWarning):
+        with pytest.raises(ValueError,
+                           match="either top_right alone or both width and height must be specified"):
+            generic_map2.submap(bl_pix, width_pix, height=height_pix)
+
+
+@pytest.mark.skip
+def test_submap_kwarg_only_input_errors(generic_map2, coords):
+    """
+    This test replaces the one above when the deprecation period is over.
+    """
+    bl_coord, tr_coord, bl_tr_coord = coords
+    inputs = (
+        ((bl_coord, tr_coord), {}),
+        ((bl_pix, tr_pix), {}),
+        ((bl_coord, width_deg, height_deg), {}),
+        ((bl_pix, width_pix, height_pix), {}),
+        ((bl_coord, width_deg), {'height_deg': height_deg}),
+        ((bl_pix, width_pix), {'height_pix': height_pix}),
+    )
+
+    for args, kwargs in inputs:
+        with pytest.raises(TypeError, match="too many positional arguments"):
+            generic_map2.submap(*args, **kwargs)
+
+
+def test_submap_kwarg_only_input_errors(generic_map2, coords):
+    bl_coord, tr_coord, bl_tr_coord = coords
+
+    inputs = (
+        dict(width=width_pix),
+        dict(top_right=tr_pix, width=width_pix),
+        dict(top_right=tr_pix, height=height_pix),
+        dict(),  # Only post deprecation
+        )
+    for kwargs in inputs:
+        with pytest.raises(ValueError, match="top_right alone or both width and height must be specified."):
+            generic_map2.submap(bl_pix, **kwargs)
+
+    with pytest.raises(TypeError, match="must be Quantity objects in units of pixels"):
+        generic_map2.submap(bl_pix, width=width_deg, height=height_deg)
+
+    with pytest.raises(TypeError,
+                       match="top_right, width or height .* Quantity objects in units of pixels."):
+        generic_map2.submap(10*u.deg, top_right=10*u.deg)
+
+    with pytest.raises(TypeError,
+                       match="top_right, width or height .* Quantity objects in units of pixels."):
+        generic_map2.submap([10, 10, 10]*u.deg, top_right=[10, 10, 10]*u.deg)
+
+    with pytest.raises(ValueError, match=r"must have shape \(2\,\)"):
+        generic_map2.submap(10*u.pix, top_right=10*u.pix)
+
+    with pytest.raises(ValueError, match=r"must have shape \(2\,\)"):
+        generic_map2.submap([10, 10, 10]*u.pix, top_right=[10, 10, 10]*u.pix)
+
+    with pytest.raises(u.UnitsError):
+        generic_map2.submap([10, 10]*u.deg, width=10*u.km, height=10*u.J)
+
+    with pytest.raises(ValueError,
+                       match="bottom_left and top_right or bottom_left and height and width should be provided."):
+        generic_map2.submap(SkyCoord([10, 10, 10]*u.deg, [10, 10, 10]*u.deg,
+                                    frame=generic_map2.coordinate_frame))
+
+def test_submap_inputs(generic_map2, coords):
+    bl_coord, tr_coord, bl_tr_coord = coords
+
+    inputs = (
+        ((bl_coord,), dict(top_right=tr_coord)),
+        ((bl_coord,), dict(width=width_deg, height=height_deg)),
+        ((bl_tr_coord,), {}),
+        ((bl_pix,), dict(top_right=tr_pix)),
+        ((bl_pix,), dict(width=width_pix, height=height_pix)),
+    )
+
+    for args, kwargs in inputs:
+        smap = generic_map2.submap(*args, **kwargs)
+        assert u.allclose(smap.dimensions, (3, 3) * u.pix)
diff --git a/sunpy/map/tests/test_plotting.py b/sunpy/map/tests/test_plotting.py
--- a/sunpy/map/tests/test_plotting.py
+++ b/sunpy/map/tests/test_plotting.py
@@ -17,6 +17,7 @@
 import sunpy.data.test
 from sunpy.tests.helpers import figure_test
 from sunpy.util.exceptions import SunpyUserWarning
+from sunpy.coordinates.utils import get_rectangle_coordinates
 
 testpath = sunpy.data.test.rootdir
 pytestmark = pytest.mark.filterwarnings('ignore:Missing metadata')
@@ -91,13 +92,24 @@ def test_plot_aia171_nowcsaxes(aia171_test_map):
 
 
 @figure_test
-def test_rectangle_aia171(aia171_test_map):
+def test_rectangle_aia171_width_height(aia171_test_map):
     aia171_test_map.plot()
     bottom_left = SkyCoord(
         0 * u.arcsec, 0 * u.arcsec, frame=aia171_test_map.coordinate_frame)
     w = 100 * u.arcsec
     h = 100 * u.arcsec
-    aia171_test_map.draw_rectangle(bottom_left, w, h)
+    aia171_test_map.draw_rectangle(bottom_left, width=w, height=h)
+
+
+@figure_test
+def test_rectangle_aia171_top_right(aia171_test_map):
+    aia171_test_map.plot()
+    bottom_left = SkyCoord(
+        0 * u.arcsec, 0 * u.arcsec, frame=aia171_test_map.coordinate_frame)
+    w = 100 * u.arcsec
+    h = 100 * u.arcsec
+    bottom_left, top_right = get_rectangle_coordinates(bottom_left, width=w, height=h)
+    aia171_test_map.draw_rectangle(bottom_left, top_right=top_right)
 
 
 @figure_test
@@ -151,13 +163,24 @@ def test_heliographic_peek(heliographic_test_map):
 
 
 @figure_test
-def test_heliographic_rectangle(heliographic_test_map):
+def test_heliographic_rectangle_width_height(heliographic_test_map):
     heliographic_test_map.plot()
-    bottom = SkyCoord(
+    bottom_left = SkyCoord(
+        60 * u.deg, 50 * u.deg, frame=heliographic_test_map.coordinate_frame)
+    w = 13 * u.deg
+    h = 13 * u.deg
+    heliographic_test_map.draw_rectangle(bottom_left, width=w, height=h, color='cyan')
+
+
+@figure_test
+def test_heliographic_rectangle_top_right(heliographic_test_map):
+    heliographic_test_map.plot()
+    bottom_left = SkyCoord(
         60 * u.deg, 50 * u.deg, frame=heliographic_test_map.coordinate_frame)
     w = 13 * u.deg
     h = 13 * u.deg
-    heliographic_test_map.draw_rectangle(bottom, w, h, color='cyan')
+    bottom_left, top_right = get_rectangle_coordinates(bottom_left, width=w, height=h)
+    heliographic_test_map.draw_rectangle(bottom_left, width=w, height=h, color='cyan')
 
 
 @figure_test
diff --git a/sunpy/tests/figure_hashes_py36.json b/sunpy/tests/figure_hashes_py36.json
--- a/sunpy/tests/figure_hashes_py36.json
+++ b/sunpy/tests/figure_hashes_py36.json
@@ -5,7 +5,8 @@
     "sunpy.map.tests.test_plotting.test_draw_grid_aia171": "79fc8e423bb83d9b0cb164fe91e635f3c1b3e6fd98ca320752888f563f3184ac",
     "sunpy.map.tests.test_plotting.test_heliographic_grid_annotations": "83b3e21c8b421789ed69c7b17175394f740621cf097b028d9e9122db057c3f58",
     "sunpy.map.tests.test_plotting.test_heliographic_peek": "3ad7bada31398662c0852f3819937a6bc0df415a589354ab0703e87780e21d42",
-    "sunpy.map.tests.test_plotting.test_heliographic_rectangle": "13080ad768f6b1e026433889673fe9432a43596dcbb8ff45171d46db8ead4c1b",
+    "sunpy.map.tests.test_plotting.test_heliographic_rectangle_width_height": "13080ad768f6b1e026433889673fe9432a43596dcbb8ff45171d46db8ead4c1b",
+    "sunpy.map.tests.test_plotting.test_heliographic_rectangle_top_right": "13080ad768f6b1e026433889673fe9432a43596dcbb8ff45171d46db8ead4c1b",
     "sunpy.map.tests.test_plotting.test_peek_aia171": "0a9603c94583a0e36ee01f10a7c8d639ec95c786624d9c222cd178d7b8c73818",
     "sunpy.map.tests.test_plotting.test_peek_grid_aia171": "4177e646652a095a0e35d20013a4780f0bceecc58241bbbf990e3ac6325238cb",
     "sunpy.map.tests.test_plotting.test_peek_grid_limb_aia171": "362629b899bc7fcde285e3e127e570f32b41cc3215bc8f0c032d8261e5aa1627",
@@ -17,10 +18,11 @@
     "sunpy.map.tests.test_plotting.test_plot_aia171_superpixel": "7a15bea94ea3398e651066ac41023bd42508a1c54b5383a32e1d6bb46c26d4c3",
     "sunpy.map.tests.test_plotting.test_plot_aia171_superpixel_nowcsaxes": "fbe8dc4e2ca3c1557e30af52b1bf9f3afda7c635432fbb807094b49b7ef91ca0",
     "sunpy.map.tests.test_plotting.test_plot_masked_aia171": "adddbb854800af4a662265d6b8d0969befd7da27fd6393a41e60037b74f383b7",
+    "sunpy.map.tests.test_plotting.test_rectangle_aia171_width_height": "b59915b551c809d66149c7ea3687c01b186138f0c6893d9c308bf06850b089cc",
+    "sunpy.map.tests.test_plotting.test_rectangle_aia171_top_right": "b59915b551c809d66149c7ea3687c01b186138f0c6893d9c308bf06850b089cc",
     "sunpy.map.tests.test_plotting.test_plot_masked_aia171_nowcsaxes": "c9964dedd3e0c7c49b73bc741154f6419ac775b11bf255506fafd8b488be3bf9",
     "sunpy.map.tests.test_plotting.test_plot_masked_aia171_superpixel": "c3c64de66ecf10fd3a648a818527ca03add507f3c9787b7d18126322b8bfe0b4",
     "sunpy.map.tests.test_plotting.test_plot_masked_aia171_superpixel_nowcsaxes": "30205651b4369d27e812881a0819b96899d6d26f347c4b316c7edc329f5530da",
-    "sunpy.map.tests.test_plotting.test_rectangle_aia171": "b59915b551c809d66149c7ea3687c01b186138f0c6893d9c308bf06850b089cc",
     "sunpy.timeseries.tests.test_timeseriesbase.test_esp_peek": "c25e927aaa39d576482a35438acee56fab915aede44e62744eb0bf2cefc889b4",
     "sunpy.timeseries.tests.test_timeseriesbase.test_eve_peek": "d2e7093fa95dc1a4bbde958275bba31d9061967ab5ef003d1af4f9519658d14b",
     "sunpy.timeseries.tests.test_timeseriesbase.test_fermi_gbm_peek": "dda01d15a769a3334e5254a93fd7bf8d16341f0e11cbd069f6738d8680bf6f96",
diff --git a/sunpy/tests/figure_hashes_py37.json b/sunpy/tests/figure_hashes_py37.json
--- a/sunpy/tests/figure_hashes_py37.json
+++ b/sunpy/tests/figure_hashes_py37.json
@@ -5,7 +5,8 @@
     "sunpy.map.tests.test_plotting.test_draw_grid_aia171": "79fc8e423bb83d9b0cb164fe91e635f3c1b3e6fd98ca320752888f563f3184ac",
     "sunpy.map.tests.test_plotting.test_heliographic_grid_annotations": "83b3e21c8b421789ed69c7b17175394f740621cf097b028d9e9122db057c3f58",
     "sunpy.map.tests.test_plotting.test_heliographic_peek": "3ad7bada31398662c0852f3819937a6bc0df415a589354ab0703e87780e21d42",
-    "sunpy.map.tests.test_plotting.test_heliographic_rectangle": "13080ad768f6b1e026433889673fe9432a43596dcbb8ff45171d46db8ead4c1b",
+    "sunpy.map.tests.test_plotting.test_heliographic_rectangle_width_height": "13080ad768f6b1e026433889673fe9432a43596dcbb8ff45171d46db8ead4c1b",
+    "sunpy.map.tests.test_plotting.test_heliographic_rectangle_top_right": "13080ad768f6b1e026433889673fe9432a43596dcbb8ff45171d46db8ead4c1b",
     "sunpy.map.tests.test_plotting.test_peek_aia171": "0a9603c94583a0e36ee01f10a7c8d639ec95c786624d9c222cd178d7b8c73818",
     "sunpy.map.tests.test_plotting.test_peek_grid_aia171": "4177e646652a095a0e35d20013a4780f0bceecc58241bbbf990e3ac6325238cb",
     "sunpy.map.tests.test_plotting.test_peek_grid_limb_aia171": "362629b899bc7fcde285e3e127e570f32b41cc3215bc8f0c032d8261e5aa1627",
@@ -17,10 +18,11 @@
     "sunpy.map.tests.test_plotting.test_plot_aia171_superpixel": "7a15bea94ea3398e651066ac41023bd42508a1c54b5383a32e1d6bb46c26d4c3",
     "sunpy.map.tests.test_plotting.test_plot_aia171_superpixel_nowcsaxes": "fbe8dc4e2ca3c1557e30af52b1bf9f3afda7c635432fbb807094b49b7ef91ca0",
     "sunpy.map.tests.test_plotting.test_plot_masked_aia171": "adddbb854800af4a662265d6b8d0969befd7da27fd6393a41e60037b74f383b7",
+    "sunpy.map.tests.test_plotting.test_rectangle_aia171_width_height": "b59915b551c809d66149c7ea3687c01b186138f0c6893d9c308bf06850b089cc",
+    "sunpy.map.tests.test_plotting.test_rectangle_aia171_top_right": "b59915b551c809d66149c7ea3687c01b186138f0c6893d9c308bf06850b089cc",
     "sunpy.map.tests.test_plotting.test_plot_masked_aia171_nowcsaxes": "c9964dedd3e0c7c49b73bc741154f6419ac775b11bf255506fafd8b488be3bf9",
     "sunpy.map.tests.test_plotting.test_plot_masked_aia171_superpixel": "c3c64de66ecf10fd3a648a818527ca03add507f3c9787b7d18126322b8bfe0b4",
     "sunpy.map.tests.test_plotting.test_plot_masked_aia171_superpixel_nowcsaxes": "30205651b4369d27e812881a0819b96899d6d26f347c4b316c7edc329f5530da",
-    "sunpy.map.tests.test_plotting.test_rectangle_aia171": "b59915b551c809d66149c7ea3687c01b186138f0c6893d9c308bf06850b089cc",
     "sunpy.timeseries.tests.test_timeseriesbase.test_esp_peek": "c25e927aaa39d576482a35438acee56fab915aede44e62744eb0bf2cefc889b4",
     "sunpy.timeseries.tests.test_timeseriesbase.test_eve_peek": "d2e7093fa95dc1a4bbde958275bba31d9061967ab5ef003d1af4f9519658d14b",
     "sunpy.timeseries.tests.test_timeseriesbase.test_fermi_gbm_peek": "dda01d15a769a3334e5254a93fd7bf8d16341f0e11cbd069f6738d8680bf6f96",