diff --git a/pygmt/src/inset.py b/pygmt/src/inset.py index 18f33efe4d0..460364a60da 100644 --- a/pygmt/src/inset.py +++ b/pygmt/src/inset.py @@ -8,21 +8,25 @@ from pygmt.alias import Alias, AliasSystem from pygmt.clib import Session +from pygmt.exceptions import GMTInvalidInput from pygmt.helpers import build_arg_list, fmt_docstring, kwargs_to_strings, use_alias -from pygmt.params import Box +from pygmt.params import Box, Position __doctest_skip__ = ["inset"] @fmt_docstring @contextlib.contextmanager -@use_alias(D="position", M="margin") -@kwargs_to_strings(D="sequence", M="sequence") +@use_alias(M="margin") +@kwargs_to_strings(M="sequence") def inset( self, + position: Position | None = None, + width: float | str | None = None, + height: float | str | None = None, + box: Box | bool = False, projection: str | None = None, region: Sequence[float | str] | str | None = None, - box: Box | bool = False, no_clip: bool = False, verbose: Literal["quiet", "error", "warning", "timing", "info", "compat", "debug"] | bool = False, @@ -31,13 +35,15 @@ def inset( r""" Manage figure inset setup and completion. - This method sets the position, frame, and margins for a smaller figure - inside of the larger figure. Plotting methods that are called within the + + This method carves out a sub-region of the current plot canvas and restrict further + plotting to that section of the canvas. Plotting methods that are called within the context manager are added to the inset figure. Full GMT docs at :gmt-docs:`inset.html`. $aliases + - D = position, **+w**: width/height - F = box - J = projection - N = no_clip @@ -46,45 +52,12 @@ def inset( Parameters ---------- - position : str or list - *xmin/xmax/ymin/ymax*\ [**+r**][**+u**\ *unit*]] \ - | [**g**\|\ **j**\|\ **J**\|\ **n**\|\ **x**]\ *refpoint*\ - **+w**\ *width*\ [/*height*][**+j**\ *justify*]\ - [**+o**\ *dx*\ [/*dy*]]. - - *This is the only required parameter.* - Define the map inset rectangle on the map. Specify the rectangle - in one of three ways: - - Append **g**\ *lon*/*lat* for map (user) coordinates, - **j**\ *code* or **J**\ *code* for setting the *refpoint* via a - :doc:`2-character justification code ` - that refers to the (invisible) - projected map bounding box, **n**\ *xn*/*yn* for normalized (0-1) - bounding box coordinates, or **x**\ *x*/*y* for plot - coordinates (inches, centimeters, points, append unit). - All but **x** requires both ``region`` and ``projection`` to be - specified. You can offset the reference point via - **+o**\ *dx*/*dy* in the direction implied by *code* or - **+j**\ *justify*. - - Alternatively, give *west/east/south/north* of geographic - rectangle bounded by parallels and meridians; append **+r** if the - coordinates instead are the lower left and upper right corners of - the desired rectangle. (Or, give *xmin/xmax/ymin/ymax* of bounding - rectangle in projected coordinates and optionally - append **+u**\ *unit* [Default coordinate unit is meters (**e**)]. - - Append **+w**\ *width*\ [/*height*] of bounding rectangle or box - in plot coordinates (inches, centimeters, etc.). By default, the - anchor point on the scale is assumed to be the bottom left corner - (**BL**), but this can be changed by appending **+j** followed by a - :doc:`2-character justification code ` - *justify*. - **Note**: If **j** is used then *justify* defaults to the same - as *refpoint*, if **J** is used then *justify* defaults to the - mirror opposite of *refpoint*. Specify inset box attributes via - the ``box`` parameter [Default is outline only]. + position + Specify the position of the inset on the map. See :class:`pygmt.params.Position` + for details. + width + height + Width and height of the inset. *height* is optional. box Draw a background box behind the inset. If set to ``True``, a simple rectangular box is drawn using :gmt-term:`MAP_FRAME_PEN`. To customize the box appearance, @@ -109,31 +82,59 @@ def inset( Examples -------- >>> import pygmt - >>> from pygmt.params import Box + >>> from pygmt.params import Box, Position >>> - >>> # Create the larger figure >>> fig = pygmt.Figure() >>> fig.coast(region="MG+r2", water="lightblue", shorelines="thin") - >>> # Use a "with" statement to initialize the inset context manager + >>> # Use a "with" statement to initialize the inset context manager. >>> # Setting the position to Top Left and a width of 3.5 centimeters - >>> with fig.inset(position="jTL+w3.5c+o0.2c", margin=0, box=Box(pen="green")): - ... # Map elements under the "with" statement are plotted in the inset + >>> with fig.inset( + ... position=Position("TL", offset=0.2), + ... width="3.5c", + ... margin=0, + ... box=Box(pen="green"), + ... ): # Map elements under the "with" statement are plotted in the inset ... fig.coast( ... region="g", - ... projection="G47/-20/3.5c", + ... projection="G47/-20/?", ... land="gray", ... water="white", ... dcw="MG+gred", ... ) - ... - >>> # Map elements outside the "with" statement are plotted in the main - >>> # figure + >>> # Map elements outside the "with" statement are plotted in the main figure. >>> fig.logo(position="jBR+o0.2c+w3c") >>> fig.show() """ self._activate_figure() + if position is None: + msg = "Parameter 'position' must be specified." + raise GMTInvalidInput(msg) + + # Prior PyGMT v0.17.0, 'position' can accept a raw GMT CLI string. Check for + # conflicts with other parameters. + _old_position_syntax = isinstance(position, str) + if _old_position_syntax and any(v is not None for v in (width, height)): + msg = ( + "Parameter 'position' is given with a raw GMT command string, and conflicts " + "with parameters 'height', and 'width'. " + ) + raise GMTInvalidInput(msg) + + if not _old_position_syntax and width is None: + msg = "Parameter 'width' must be specified." + raise GMTInvalidInput(msg) + + if height is not None and width is None: + msg = "'width' must be specified if 'height' is given." + raise GMTInvalidInput(msg) + aliasdict = AliasSystem( + D=[ + Alias(position, name="position"), + Alias(width, name="width", prefix="+w"), # +wwidth/height + Alias(height, name="height", prefix="/"), + ], F=Alias(box, name="box"), N=Alias(no_clip, name="no_clip"), ).add_common( diff --git a/pygmt/tests/test_inset.py b/pygmt/tests/test_inset.py index ca5b411fe68..f1677605a41 100644 --- a/pygmt/tests/test_inset.py +++ b/pygmt/tests/test_inset.py @@ -4,7 +4,8 @@ import pytest from pygmt import Figure -from pygmt.params import Box +from pygmt.exceptions import GMTInvalidInput +from pygmt.params import Box, Position @pytest.mark.benchmark @@ -15,7 +16,12 @@ def test_inset_aliases(): """ fig = Figure() fig.basemap(region="MG+r2", frame="afg") - with fig.inset(position="jTL+w3.5c+o0.2c", margin=0, box=Box(pen="green")): + with fig.inset( + position=Position("TL", offset=0.2), + width="3.5c", + margin=0, + box=Box(pen="green"), + ): fig.basemap(region="g", projection="G47/-20/4c", frame="afg") return fig @@ -28,7 +34,49 @@ def test_inset_context_manager(): """ fig = Figure() fig.basemap(region=[-74, -69.5, 41, 43], projection="M9c", frame=True) - with fig.inset(position="jBL+w3c+o0.2c", margin=0, box=Box(pen="black")): + with fig.inset( + position=Position("BL", offset=0.2), width="3c", margin=0, box=Box(pen="black") + ): fig.basemap(region=[-80, -65, 35, 50], projection="M3c", frame="afg") fig.basemap(rose="jTR+w3c") # Pass rose argument with basemap after the inset return fig + + +@pytest.mark.mpl_image_compare(filename="test_inset_aliases.png") +def test_inset_deprecated_position(): + """ + Test that the deprecated raw GMT CLI string for position still works. + """ + fig = Figure() + fig.basemap(region="MG+r2", frame="afg") + with fig.inset(position="jTL+w3.5c+o0.2c", margin=0, box=Box(pen="green")): + fig.basemap(region="g", projection="G47/-20/4c", frame="afg") + return fig + + +def test_inset_invalid_inputs(): + """ + Test that an error is raised when invalid inputs are provided. + """ + fig = Figure() + fig.basemap(region="MG+r2", frame="afg") + + # Position is not given + with pytest.raises(GMTInvalidInput): + with fig.inset(width="3.5c"): + pass + + # Width is not given + with pytest.raises(GMTInvalidInput): + with fig.inset(position=Position("TL")): + pass + + # Height is given but width is not given + with pytest.raises(GMTInvalidInput): + with fig.inset(position=Position("TL"), height="5c"): + pass + + # Old position syntax conflicts with width/height + with pytest.raises(GMTInvalidInput): + with fig.inset(position="jTL+w3.5c", width="3.5c"): + pass