diff --git a/pygmt/clib/session.py b/pygmt/clib/session.py index 42d74a815a2..a9ea8c65f27 100644 --- a/pygmt/clib/session.py +++ b/pygmt/clib/session.py @@ -592,25 +592,36 @@ def get_common(self, option): # the function return value (i.e., 'status') return status - def call_module(self, module, args): + def call_module(self, module: str, args: str | list[str]): """ Call a GMT module with the given arguments. - Makes a call to ``GMT_Call_Module`` from the C API using mode - ``GMT_MODULE_CMD`` (arguments passed as a single string). + Wraps ``GMT_Call_Module``. - Most interactions with the C API are done through this function. + The ``GMT_Call_Module`` API function supports passing module arguments in three + different ways: + + 1. Pass a single string that contains whitespace-separated module arguments. + 2. Pass a list of strings and each string contains a module argument. + 3. Pass a list of ``GMT_OPTION`` data structure. + + Both options 1 and 2 are implemented in this function, but option 2 is preferred + because it can correctly handle special characters like whitespaces and + quotation marks in module arguments. Parameters ---------- - module : str - Module name (``'coast'``, ``'basemap'``, etc). - args : str - String with the command line arguments that will be passed to the - module (for example, ``'-R0/5/0/10 -JM'``). + module + The GMT module name to be called (``"coast"``, ``"basemap"``, etc). + args + Module arguments that will be passed to the GMT module. It can be either + a single string (e.g., ``"-R0/5/0/10 -JX10c -BWSen+t'My Title'"``) or a list + of strings (e.g., ``["-R0/5/0/10", "-JX10c", "-BWSEN+tMy Title"]``). Raises ------ + GMTInvalidInput + If the ``args`` argument is not a string or a list of strings. GMTCLibError If the returned status code of the function is non-zero. """ @@ -620,10 +631,29 @@ def call_module(self, module, args): restype=ctp.c_int, ) - mode = self["GMT_MODULE_CMD"] - status = c_call_module( - self.session_pointer, module.encode(), mode, args.encode() - ) + # 'args' can be (1) a single string or (2) a list of strings. + argv: bytes | ctp.Array[ctp.c_char_p] | None + if isinstance(args, str): + # 'args' is a single string that contains whitespace-separated arguments. + # In this way, we need to correctly handle option arguments that contain + # whitespaces or quotation marks. It's used in PyGMT <= v0.11.0 but is no + # longer recommended. + mode = self["GMT_MODULE_CMD"] + argv = args.encode() + elif isinstance(args, list): + # 'args' is a list of strings and each string contains a module argument. + # In this way, GMT can correctly handle option arguments with whitespaces or + # quotation marks. This is the preferred way to pass arguments to the GMT + # API and is used for PyGMT >= v0.12.0. + mode = len(args) # 'mode' is the number of arguments. + # Pass a null pointer if no arguments are specified. + argv = strings_to_ctypes_array(args) if mode != 0 else None + else: + raise GMTInvalidInput( + "'args' must be either a string or a list of strings." + ) + + status = c_call_module(self.session_pointer, module.encode(), mode, argv) if status != 0: raise GMTCLibError( f"Module '{module}' failed with status code {status}:\n{self._error_message}" diff --git a/pygmt/tests/test_clib.py b/pygmt/tests/test_clib.py index 2f655435ee4..201d7d27fb7 100644 --- a/pygmt/tests/test_clib.py +++ b/pygmt/tests/test_clib.py @@ -133,9 +133,20 @@ def test_destroy_session_fails(): @pytest.mark.benchmark def test_call_module(): """ - Run a command to see if call_module works. + Call a GMT module by passing a list of arguments. + """ + with clib.Session() as lib: + with GMTTempFile() as out_fname: + lib.call_module("info", [str(POINTS_DATA), "-C", f"->{out_fname.name}"]) + assert Path(out_fname.name).stat().st_size > 0 + output = out_fname.read().strip() + assert output == "11.5309 61.7074 -2.9289 7.8648 0.1412 0.9338" + + +def test_call_module_argument_string(): + """ + Call a GMT module by passing a single argument string. """ - out_fname = "test_call_module.txt" with clib.Session() as lib: with GMTTempFile() as out_fname: lib.call_module("info", f"{POINTS_DATA} -C ->{out_fname.name}") @@ -144,9 +155,28 @@ def test_call_module(): assert output == "11.5309 61.7074 -2.9289 7.8648 0.1412 0.9338" +def test_call_module_empty_argument(): + """ + call_module should work if an empty string or an empty list is passed as argument. + """ + with clib.Session() as lib: + lib.call_module("defaults", "") + with clib.Session() as lib: + lib.call_module("defaults", []) + + +def test_call_module_invalid_argument_type(): + """ + call_module only accepts a string or a list of strings as module arguments. + """ + with clib.Session() as lib: + with pytest.raises(GMTInvalidInput): + lib.call_module("get", ("FONT_TITLE", "FONT_TAG")) + + def test_call_module_invalid_arguments(): """ - Fails for invalid module arguments. + call_module should fail for invalid module arguments. """ with clib.Session() as lib: with pytest.raises(GMTCLibError): @@ -155,7 +185,7 @@ def test_call_module_invalid_arguments(): def test_call_module_invalid_name(): """ - Fails when given bad input. + call_module should fail when an invalid module name is given. """ with clib.Session() as lib: with pytest.raises(GMTCLibError): @@ -164,7 +194,7 @@ def test_call_module_invalid_name(): def test_call_module_error_message(): """ - Check is the GMT error message was captured. + Check if the GMT error message was captured when calling a module. """ with clib.Session() as lib: with pytest.raises(GMTCLibError) as exc_info: