Skip to content

Commit 93c051b

Browse files
committed
Merge branch 'main' into alias-system
2 parents 2058373 + fb718b2 commit 93c051b

9 files changed

+238
-45
lines changed

pygmt/accessors.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,7 @@ class GMTDataArrayAccessor:
126126
(<GridRegistration.GRIDLINE: 0>, <GridType.GEOGRAPHIC: 1>)
127127
"""
128128

129-
def __init__(self, xarray_obj):
129+
def __init__(self, xarray_obj: xr.DataArray):
130130
self._obj = xarray_obj
131131

132132
# Default to Gridline registration and Cartesian grid type
@@ -137,19 +137,19 @@ def __init__(self, xarray_obj):
137137
# two columns of the shortened summary information of grdinfo.
138138
if (_source := self._obj.encoding.get("source")) and Path(_source).exists():
139139
with contextlib.suppress(ValueError):
140-
self._registration, self._gtype = map(
140+
self._registration, self._gtype = map( # type: ignore[assignment]
141141
int, grdinfo(_source, per_column="n").split()[-2:]
142142
)
143143

144144
@property
145-
def registration(self):
145+
def registration(self) -> GridRegistration:
146146
"""
147147
Grid registration type :class:`pygmt.enums.GridRegistration`.
148148
"""
149149
return self._registration
150150

151151
@registration.setter
152-
def registration(self, value):
152+
def registration(self, value: GridRegistration | int):
153153
# TODO(Python>=3.12): Simplify to `if value not in GridRegistration`.
154154
if value not in GridRegistration.__members__.values():
155155
msg = (
@@ -160,14 +160,14 @@ def registration(self, value):
160160
self._registration = GridRegistration(value)
161161

162162
@property
163-
def gtype(self):
163+
def gtype(self) -> GridType:
164164
"""
165165
Grid coordinate system type :class:`pygmt.enums.GridType`.
166166
"""
167167
return self._gtype
168168

169169
@gtype.setter
170-
def gtype(self, value):
170+
def gtype(self, value: GridType | int):
171171
# TODO(Python>=3.12): Simplify to `if value not in GridType`.
172172
if value not in GridType.__members__.values():
173173
msg = (

pygmt/helpers/utils.py

Lines changed: 56 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,41 @@ def _is_printable_ascii(argstr: str) -> bool:
174174
return all(32 <= ord(c) <= 126 for c in argstr)
175175

176176

177+
def _contains_apostrophe_or_backtick(argstr: str) -> bool:
178+
"""
179+
Check if a string contains apostrophe (') or backtick (`).
180+
181+
For typographical reasons, apostrophe (') and backtick (`) are mapped to left and
182+
right single quotation marks (‘ and ’) in Adobe ISOLatin1+ encoding. To ensure that
183+
what you type is what you get (issue #3476), they need special handling in the
184+
``_check_encoding`` and ``non_ascii_to_octal`` functions. More specifically, a
185+
string containing printable ASCII characters with apostrophe (') and backtick (`)
186+
will not be considered as "ascii" encoding.
187+
188+
Parameters
189+
----------
190+
argstr
191+
The string to be checked.
192+
193+
Returns
194+
-------
195+
``True`` if the string contains apostrophe (') or backtick (`). Otherwise, return
196+
``False``.
197+
198+
Examples
199+
--------
200+
>>> _contains_apostrophe_or_backtick("12AB±β①②")
201+
False
202+
>>> _contains_apostrophe_or_backtick("12AB`")
203+
True
204+
>>> _contains_apostrophe_or_backtick("12AB'")
205+
True
206+
>>> _contains_apostrophe_or_backtick("12AB'`")
207+
True
208+
""" # noqa: RUF002
209+
return "'" in argstr or "`" in argstr
210+
211+
177212
def _check_encoding(argstr: str) -> Encoding:
178213
"""
179214
Check the charset encoding of a string.
@@ -206,8 +241,9 @@ def _check_encoding(argstr: str) -> Encoding:
206241
>>> _check_encoding("123AB中文") # Characters not in any charset encoding
207242
'ISOLatin1+'
208243
"""
209-
# Return "ascii" if the string only contains printable ASCII characters.
210-
if _is_printable_ascii(argstr):
244+
# Return "ascii" if the string only contains printable ASCII characters, excluding
245+
# apostrophe (') and backtick (`).
246+
if _is_printable_ascii(argstr) and not _contains_apostrophe_or_backtick(argstr):
211247
return "ascii"
212248
# Loop through all supported encodings and check if all characters in the string
213249
# are in the charset of the encoding. If all characters are in the charset, return
@@ -402,9 +438,14 @@ def non_ascii_to_octal(argstr: str, encoding: Encoding = "ISOLatin1+") -> str:
402438
'ABC \\261120\\260 DEF @~\\141@~ @%34%\\252@%%'
403439
>>> non_ascii_to_octal("12ABāáâãäåβ①②", encoding="ISO-8859-4")
404440
'12AB\\340\\341\\342\\343\\344\\345@~\\142@~@%34%\\254@%%@%34%\\255@%%'
441+
>>> non_ascii_to_octal("'‘’\"“”")
442+
'\\234\\140\\047"\\216\\217'
405443
""" # noqa: RUF002
406-
# Return the input string if it only contains printable ASCII characters.
407-
if encoding == "ascii" or _is_printable_ascii(argstr):
444+
# Return the input string if it only contains printable ASCII characters, excluding
445+
# apostrophe (') and backtick (`).
446+
if encoding == "ascii" or (
447+
_is_printable_ascii(argstr) and not _contains_apostrophe_or_backtick(argstr)
448+
):
408449
return argstr
409450

410451
# Dictionary mapping non-ASCII characters to octal codes
@@ -420,6 +461,11 @@ def non_ascii_to_octal(argstr: str, encoding: Encoding = "ISOLatin1+") -> str:
420461

421462
# Remove any printable characters.
422463
mapping = {k: v for k, v in mapping.items() if k not in string.printable}
464+
465+
if encoding == "ISOLatin1+":
466+
# Map apostrophe (') and backtick (`) to correct octal codes.
467+
# See _contains_apostrophe_or_backtick() for explanations.
468+
mapping.update({"'": "\\234", "`": "\\221"})
423469
return argstr.translate(str.maketrans(mapping))
424470

425471

@@ -465,16 +511,12 @@ def build_arg_list( # noqa: PLR0912
465511
['-A', '-D0', '-E200', '-F', '-G1/2/3/4']
466512
>>> build_arg_list(dict(A="1/2/3/4", B=["xaf", "yaf", "WSen"], C=("1p", "2p")))
467513
['-A1/2/3/4', '-BWSen', '-Bxaf', '-Byaf', '-C1p', '-C2p']
468-
>>> print(
469-
... build_arg_list(
470-
... dict(
471-
... B=["af", "WSne+tBlank Space"],
472-
... F='+t"Empty Spaces"',
473-
... l="'Void Space'",
474-
... )
475-
... )
476-
... )
477-
['-BWSne+tBlank Space', '-Baf', '-F+t"Empty Spaces"', "-l'Void Space'"]
514+
>>> build_arg_list(dict(B=["af", "WSne+tBlank Space"]))
515+
['-BWSne+tBlank Space', '-Baf']
516+
>>> build_arg_list(dict(F='+t"Empty Spaces"'))
517+
['-F+t"Empty Spaces"']
518+
>>> build_arg_list(dict(l="'Void Space'"))
519+
['-l\\234Void Space\\234', '--PS_CHAR_ENCODING=ISOLatin1+']
478520
>>> print(
479521
... build_arg_list(
480522
... dict(A="0", B=True, C="rainbow"),

pygmt/src/shift_origin.py

Lines changed: 79 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -2,20 +2,41 @@
22
shift_origin - Shift plot origin in x and/or y directions.
33
"""
44

5+
import contextlib
6+
57
from pygmt.clib import Session
8+
from pygmt.helpers import build_arg_list
69

710

811
def shift_origin(
912
self, xshift: float | str | None = None, yshift: float | str | None = None
1013
):
1114
r"""
12-
Shift plot origin in x and/or y directions.
15+
Shift the plot origin in x and/or y directions.
16+
17+
The shifts can be permanent or temporary. If used as a standalone method, the shifts
18+
are permanent and apply to all subsequent plots. If used as a context manager, the
19+
shifts are temporary and only apply to the block of code within the context manager.
20+
21+
1. Use as a standalone method to shift the plot origin permanently:
22+
23+
.. code-block:: python
24+
25+
fig.shift_origin(...)
26+
... # Other plot commands
27+
28+
2. Use as a context manager to shift the plot origin temporarily:
29+
30+
.. code-block:: python
31+
32+
with fig.shift_origin(...):
33+
... # Other plot commands
34+
...
1335
14-
This method shifts the plot origin relative to the current origin by *xshift* and
15-
*yshift* in x and y directions, respectively. Optionally, append the length unit
36+
The shifts *xshift* and *yshift* in x and y directions are relative to the current
37+
plot origin. The default unit for shifts is centimeters (**c**) but can be changed
38+
to other units via :gmt-term:`PROJ_LENGTH_UNIT`. Optionally, append the length unit
1639
(**c** for centimeters, **i** for inches, or **p** for points) to the shifts.
17-
Default unit if not explicitly given is **c**, but can be changed to other units via
18-
:gmt-term:`PROJ_LENGTH_UNIT`.
1940
2041
For *xshift*, a special character **w** can also be used, which represents the
2142
bounding box **width** of the previous plot. The full syntax is
@@ -44,23 +65,63 @@ def shift_origin(
4465
4566
Examples
4667
--------
68+
69+
Shifting the plot origin permanently:
70+
4771
>>> import pygmt
4872
>>> fig = pygmt.Figure()
49-
>>> fig.basemap(region=[0, 10, 0, 10], projection="X10c/10c", frame=True)
50-
>>> # Shift the plot origin in x direction by 12 cm
51-
>>> fig.shift_origin(xshift=12)
52-
>>> fig.basemap(region=[0, 10, 0, 10], projection="X14c/10c", frame=True)
53-
>>> # Shift the plot origin in x direction based on the previous plot width
54-
>>> # Here, the width is 14 cm, and xshift is 16 cm
55-
>>> fig.shift_origin(xshift="w+2c")
73+
>>> fig.basemap(region=[0, 5, 0, 5], projection="X5c/5c", frame=True)
74+
>>> # Shift the plot origin in x direction by 6 cm
75+
>>> fig.shift_origin(xshift=6)
76+
<contextlib._GeneratorContextManager object at ...>
77+
>>> fig.basemap(region=[0, 7, 0, 5], projection="X7c/5c", frame=True)
78+
>>> # Shift the plot origin in x direction based on the previous plot width.
79+
>>> # Here, the width is 7 cm, and xshift is 8 cm.
80+
>>> fig.shift_origin(xshift="w+1c")
81+
<contextlib._GeneratorContextManager object at ...>
82+
>>> fig.basemap(region=[0, 10, 0, 5], projection="X10c/5c", frame=True)
83+
>>> fig.show()
84+
85+
Shifting the plot origin temporarily:
86+
87+
>>> fig = pygmt.Figure()
88+
>>> fig.basemap(region=[0, 5, 0, 5], projection="X5c/5c", frame=True)
89+
>>> # Shift the plot origin in x direction by 6 cm temporarily. The plot origin will
90+
>>> # revert back to the original plot origin after the block of code is executed.
91+
>>> with fig.shift_origin(xshift=6):
92+
... fig.basemap(region=[0, 5, 0, 5], projection="X5c/5c", frame=True)
93+
>>> # Shift the plot origin in y direction by 6 cm temporarily.
94+
>>> with fig.shift_origin(yshift=6):
95+
... fig.basemap(region=[0, 5, 0, 5], projection="X5c/5c", frame=True)
96+
>>> # Shift the plot origin in x and y directions by 6 cm temporarily.
97+
>>> with fig.shift_origin(xshift=6, yshift=6):
98+
... fig.basemap(region=[0, 5, 0, 5], projection="X5c/5c", frame=True)
5699
>>> fig.show()
57100
"""
58101
self._preprocess()
59-
args = ["-T"]
60-
if xshift:
61-
args.append(f"-X{xshift}")
62-
if yshift:
63-
args.append(f"-Y{yshift}")
102+
kwdict = {"T": True, "X": xshift, "Y": yshift}
64103

65104
with Session() as lib:
66-
lib.call_module(module="plot", args=args)
105+
lib.call_module(module="plot", args=build_arg_list(kwdict))
106+
_xshift = lib.get_common("X") # False or xshift in inches
107+
_yshift = lib.get_common("Y") # False or yshift in inches
108+
109+
@contextlib.contextmanager
110+
def _shift_origin_context():
111+
"""
112+
An internal context manager to shift the plot origin temporarily.
113+
"""
114+
try:
115+
yield
116+
finally:
117+
# Revert the plot origin to the original plot origin by shifting it by
118+
# -xshift and -yshift in inches.
119+
kwdict = {
120+
"T": True,
121+
"X": f"{-1.0 * _xshift}i" if _xshift else None,
122+
"Y": f"{-1.0 * _yshift}i" if _yshift else None,
123+
}
124+
with Session() as lib:
125+
lib.call_module(module="plot", args=build_arg_list(kwdict))
126+
127+
return _shift_origin_context()
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
outs:
2+
- md5: 6c63b9935618f607fe22608f7561be22
3+
size: 4869
4+
hash: md5
5+
path: test_shift_origin_context_manager.png
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
outs:
2+
- md5: 3a31df374874ae00920ada0b311b4266
3+
size: 5678
4+
hash: md5
5+
path: test_shift_origin_mixed_modes.png
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
outs:
2+
- md5: cd83745f2657ff7daeaba368143db72f
3+
size: 4876
4+
hash: md5
5+
path: test_shift_origin_nested_context_manager.png
Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
outs:
2-
- md5: 90d08c5a11c606abed51b84eafcdea04
3-
size: 1662
2+
- md5: f3ddc9b50f3da1facdbcd32261db3bd6
3+
size: 2965
44
hash: md5
55
path: test_text_quotation_marks.png

pygmt/tests/test_shift_origin.py

Lines changed: 72 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,16 @@
33
"""
44

55
import pytest
6+
from pygmt import Figure
67
from pygmt.exceptions import GMTInvalidInput
7-
from pygmt.figure import Figure
8+
9+
10+
def _numbered_basemap(fig, number, size=3):
11+
"""
12+
A utility function to create a basemap with a number in the center.
13+
"""
14+
fig.basemap(region=[0, 1, 0, 1], projection=f"X{size}c", frame=0)
15+
fig.text(position="MC", text=number, font="24p")
816

917

1018
@pytest.mark.mpl_image_compare
@@ -27,6 +35,69 @@ def test_shift_origin():
2735
return fig
2836

2937

38+
@pytest.mark.mpl_image_compare
39+
def test_shift_origin_context_manager():
40+
"""
41+
Test if Figure.shift_origin as a context manager shifts origin temporarily.
42+
43+
Expected output is:
44+
| 3 | 4 |
45+
| 1 | 2 |
46+
"""
47+
fig = Figure()
48+
_numbered_basemap(fig, 1, size=2.5)
49+
with fig.shift_origin(xshift=3):
50+
_numbered_basemap(fig, 2, size=2.5)
51+
with fig.shift_origin(yshift=3):
52+
_numbered_basemap(fig, 3, size=2.5)
53+
with fig.shift_origin(xshift=3, yshift=3):
54+
_numbered_basemap(fig, 4, size=2.5)
55+
return fig
56+
57+
58+
@pytest.mark.mpl_image_compare
59+
def test_shift_origin_nested_context_manager():
60+
"""
61+
Test if Figure.shift_origin shifts origin correctly when used in a nested context
62+
manager.
63+
64+
Expected output is:
65+
| 4 | 3 |
66+
| 1 | 2 |
67+
"""
68+
fig = Figure()
69+
_numbered_basemap(fig, 1, size=2.5)
70+
with fig.shift_origin(xshift=3):
71+
_numbered_basemap(fig, 2, size=2.5)
72+
with fig.shift_origin(yshift=3):
73+
_numbered_basemap(fig, 3, size=2.5)
74+
with fig.shift_origin(yshift=3):
75+
_numbered_basemap(fig, 4, size=2.5)
76+
return fig
77+
78+
79+
@pytest.mark.mpl_image_compare
80+
def test_shift_origin_mixed_modes():
81+
"""
82+
Test if Figure.shift_origin works when used as a context manager and as a
83+
method at the same time.
84+
85+
Expected output is:
86+
| | 3 | 4 |
87+
| 1 | 2 | |
88+
"""
89+
fig = Figure()
90+
_numbered_basemap(fig, 1, size=2.5)
91+
with fig.shift_origin(xshift=3):
92+
_numbered_basemap(fig, 2, size=2.5)
93+
fig.shift_origin(xshift=3)
94+
with fig.shift_origin(yshift=3):
95+
_numbered_basemap(fig, 3, size=2.5)
96+
fig.shift_origin(xshift=3, yshift=3)
97+
_numbered_basemap(fig, 4, size=2.5)
98+
return fig
99+
100+
30101
def test_shift_origin_unsupported_xshift_yshift():
31102
"""
32103
Raise an exception if X/Y/xshift/yshift is used.

0 commit comments

Comments
 (0)