Skip to content

Commit 170f82e

Browse files
authored
New Alias System: Add the AliasSystem class (#4000)
1 parent 3418acd commit 170f82e

File tree

2 files changed

+217
-1
lines changed

2 files changed

+217
-1
lines changed

pygmt/alias.py

Lines changed: 120 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,12 @@
22
The PyGMT alias system to convert PyGMT's long-form arguments to GMT's short-form.
33
"""
44

5+
import warnings
6+
from collections import UserDict
57
from collections.abc import Mapping, Sequence
68
from typing import Any, Literal
79

8-
from pygmt.exceptions import GMTValueError
10+
from pygmt.exceptions import GMTInvalidInput, GMTValueError
911
from pygmt.helpers.utils import is_nonstr_iter, sequence_join
1012

1113

@@ -192,3 +194,120 @@ def __init__(
192194
size=size,
193195
ndim=ndim,
194196
)
197+
198+
199+
class AliasSystem(UserDict):
200+
"""
201+
Alias system for mapping PyGMT's long-form parameters to GMT's short-form options.
202+
203+
This class is initialized with keyword arguments, where each key is a GMT option
204+
flag, and the corresponding value is an ``Alias`` object or a list of ``Alias``
205+
objects.
206+
207+
This class inherits from ``UserDict``, which allows it to behave like a dictionary
208+
and can be passed to the ``build_arg_list`` function. It also provides the ``merge``
209+
method to update the alias dictionary with additional keyword arguments.
210+
211+
Examples
212+
--------
213+
>>> from pygmt.alias import Alias, AliasSystem
214+
>>> from pygmt.helpers import build_arg_list
215+
>>>
216+
>>> def func(
217+
... par0, par1=None, par2=None, frame=False, repeat=None, panel=None, **kwargs
218+
... ):
219+
... aliasdict = AliasSystem(
220+
... A=[
221+
... Alias(par1, name="par1"),
222+
... Alias(par2, name="par2", prefix="+o", separator="/"),
223+
... ],
224+
... B=Alias(frame, name="frame"),
225+
... D=Alias(repeat, name="repeat"),
226+
... c=Alias(panel, name="panel", separator=","),
227+
... ).merge(kwargs)
228+
... return build_arg_list(aliasdict)
229+
>>> func(
230+
... "infile",
231+
... par1="mytext",
232+
... par2=(12, 12),
233+
... frame=True,
234+
... repeat=[1, 2, 3],
235+
... panel=(1, 2),
236+
... J="X10c/10c",
237+
... )
238+
['-Amytext+o12/12', '-B', '-D1', '-D2', '-D3', '-JX10c/10c', '-c1,2']
239+
"""
240+
241+
def __init__(self, **kwargs):
242+
"""
243+
Initialize the alias system as a dictionary with current parameter values.
244+
"""
245+
# Store the aliases in a dictionary, to be used in the merge() method.
246+
self.aliasdict = kwargs
247+
248+
# The value of each key in kwargs is an Alias object or a sequence of Alias
249+
# objects. If it is a single Alias object, we will use its _value property. If
250+
# it is a sequence of Alias objects, we will concatenate their _value properties
251+
# into a single string.
252+
#
253+
# Note that alias._value is converted by the _to_string method and can only be
254+
# None, string or sequence of strings.
255+
# - None means the parameter is not specified.
256+
# - Sequence of strings means this is a repeatable option, so it can only have
257+
# one long-form parameter.
258+
kwdict = {}
259+
for option, aliases in kwargs.items():
260+
if isinstance(aliases, Sequence): # A sequence of Alias objects.
261+
values = [alias._value for alias in aliases if alias._value is not None]
262+
if values:
263+
kwdict[option] = "".join(values)
264+
elif aliases._value is not None: # A single Alias object and not None.
265+
kwdict[option] = aliases._value
266+
super().__init__(kwdict)
267+
268+
def merge(self, kwargs: Mapping[str, Any]):
269+
"""
270+
Update the dictionary with additional keyword arguments.
271+
272+
This method is necessary to allow users to use the single-letter parameters for
273+
option flags that are not aliased.
274+
"""
275+
# Loop over short-form parameters passed via kwargs.
276+
for short_param, value in kwargs.items():
277+
# Check if long-form parameters exist and given.
278+
long_param_exists = short_param in self.aliasdict
279+
long_param_given = short_param in self
280+
281+
# Update the dictionary with the short-form parameter anyway.
282+
self[short_param] = value
283+
284+
# Long-form parameters do not exist.
285+
if not long_param_exists:
286+
continue
287+
288+
# Long-form parameters exist.
289+
aliases = self.aliasdict.get(short_param)
290+
if not isinstance(aliases, Sequence): # Single Alias object.
291+
_msg_long = f"Use long-form parameter {aliases.name!r} instead."
292+
else: # Sequence of Alias objects.
293+
_params = [f"{v.name!r}" for v in aliases if not v.prefix]
294+
_modifiers = [f"{v.name!r} ({v.prefix})" for v in aliases if v.prefix]
295+
_msg_long = (
296+
f"Use long-form parameters {', '.join(_params)}, "
297+
f"with optional parameters {', '.join(_modifiers)} instead."
298+
)
299+
300+
# Long-form parameters are already specified.
301+
if long_param_given:
302+
msg = (
303+
f"Short-form parameter {short_param!r} conflicts with long-form "
304+
f"parameters and is not recommended. {_msg_long}"
305+
)
306+
raise GMTInvalidInput(msg)
307+
308+
# Long-form parameters are not specified.
309+
msg = (
310+
f"Short-form parameter {short_param!r} is not recommended. {_msg_long}"
311+
)
312+
warnings.warn(msg, category=SyntaxWarning, stacklevel=2)
313+
return self

pygmt/tests/test_alias_system.py

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
"""
2+
Tests for the alias system.
3+
"""
4+
5+
import pytest
6+
from pygmt.alias import Alias, AliasSystem
7+
from pygmt.exceptions import GMTInvalidInput
8+
from pygmt.helpers import build_arg_list
9+
10+
11+
def func(
12+
projection=None,
13+
region=None,
14+
frame=None,
15+
label=None,
16+
text=None,
17+
offset=None,
18+
**kwargs,
19+
):
20+
"""
21+
A simple function to test the alias system.
22+
"""
23+
aliasdict = AliasSystem(
24+
J=Alias(projection, name="projection"),
25+
R=Alias(region, name="region", separator="/", size=[4, 6]),
26+
B=Alias(frame, name="frame"),
27+
U=[
28+
Alias(label, name="label"),
29+
Alias(text, name="text", prefix="+t"),
30+
Alias(offset, name="offset", prefix="+o", separator="/"),
31+
],
32+
).merge(kwargs)
33+
return build_arg_list(aliasdict)
34+
35+
36+
def test_alias_system_long_form():
37+
"""
38+
Test that the alias system works with long-form parameters.
39+
"""
40+
# One parameter
41+
assert func(projection="X10c") == ["-JX10c"]
42+
# Multiple parameters.
43+
assert func(projection="H10c", region=[0, 10, 0, 20]) == ["-JH10c", "-R0/10/0/20"]
44+
# Repeatable parameters.
45+
assert func(frame=["WSen", "xaf", "yaf"]) == ["-BWSen", "-Bxaf", "-Byaf"]
46+
# Multiple long-form parameters.
47+
assert func(label="abcd", text="efg", offset=(12, 12)) == ["-Uabcd+tefg+o12/12"]
48+
assert func(
49+
projection="H10c",
50+
region=[0, 10, 0, 20],
51+
label="abcd",
52+
text="efg",
53+
offset=(12, 12),
54+
frame=["WSen", "xaf", "yaf"],
55+
) == ["-BWSen", "-Bxaf", "-Byaf", "-JH10c", "-R0/10/0/20", "-Uabcd+tefg+o12/12"]
56+
57+
58+
def test_alias_system_one_alias_short_form():
59+
"""
60+
Test that the alias system works when short-form parameters coexist.
61+
"""
62+
# Long-form does not exist.
63+
assert func(A="abc") == ["-Aabc"]
64+
65+
# Long-form exists but is not given, and short-form is given.
66+
with pytest.warns(
67+
SyntaxWarning,
68+
match="Short-form parameter 'J' is not recommended. Use long-form parameter 'projection' instead.",
69+
):
70+
assert func(J="X10c") == ["-JX10c"]
71+
72+
# Coexistence of long-form and short-form parameters.
73+
with pytest.raises(
74+
GMTInvalidInput,
75+
match="Short-form parameter 'J' conflicts with long-form parameters and is not recommended. Use long-form parameter 'projection' instead.",
76+
):
77+
func(projection="X10c", J="H10c")
78+
79+
80+
def test_alias_system_multiple_aliases_short_form():
81+
"""
82+
Test that the alias system works with multiple aliases when short-form parameters
83+
are used.
84+
"""
85+
_msg_long = r"Use long-form parameters 'label', with optional parameters 'text' \(\+t\), 'offset' \(\+o\) instead."
86+
# Long-form exists but is not given, and short-form is given.
87+
msg = rf"Short-form parameter 'U' is not recommended. {_msg_long}"
88+
with pytest.warns(SyntaxWarning, match=msg):
89+
assert func(U="abcd+tefg") == ["-Uabcd+tefg"]
90+
91+
# Coexistence of long-form and short-form parameters.
92+
msg = rf"Short-form parameter 'U' conflicts with long-form parameters and is not recommended. {_msg_long}"
93+
with pytest.raises(GMTInvalidInput, match=msg):
94+
func(label="abcd", U="efg")
95+
96+
with pytest.raises(GMTInvalidInput, match=msg):
97+
func(text="efg", U="efg")

0 commit comments

Comments
 (0)