From ac054b9d4923b10818e9c151259f4b5f154179a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edouard=20Choini=C3=A8re?= <27212526+echoix@users.noreply.github.com> Date: Wed, 28 Oct 2026 19:30:16 +0000 Subject: [PATCH 1/3] grass.script: Add tests for utils helpers Cover legalize_vector_name, append_random, append_uuid, append_node_pid, basename, try_remove, try_rmdir, diff_files, split, naturally_sorted, get_num_suffix, parse_key_val, get_lib_path and set_path. Gaps were found by running Pynguin on grass.script.utils. The tests are written by hand, with AI assistance, and the expected values were checked against the behavior of the functions. Several mutations of utils.py were used to confirm that the tests fail when the code changes. --- python/grass/script/tests/utils_test.py | 262 ++++++++++++++++++++++++ 1 file changed, 262 insertions(+) diff --git a/python/grass/script/tests/utils_test.py b/python/grass/script/tests/utils_test.py index 153a0daba5c..568218b25ce 100644 --- a/python/grass/script/tests/utils_test.py +++ b/python/grass/script/tests/utils_test.py @@ -1,6 +1,9 @@ """Test functions in grass.script.utils""" import multiprocessing +import operator +import os +import re import sys import pytest @@ -103,3 +106,262 @@ def test_get_multiprocessing_context(): assert ctx.get_start_method() == "fork" else: assert ctx.get_start_method() == multiprocessing.get_start_method() + + +@pytest.mark.parametrize( + ("name", "expected"), + [ + ("elevation", "elevation"), + ("with space-and.dot", "with_space_and_dot"), + ("1st", "x1st"), + ("_hidden", "x_hidden"), + # Only ASCII letters are legal, so accented letters are replaced. + ("na\u00efve", "na_ve"), + # SQL keywords get a suffix to be usable as column names. + ("and", "and_"), + ("or", "or_"), + ("not", "not_"), + ], +) +def test_legalize_vector_name(name, expected): + """Check that invalid characters, leading digits and keywords are fixed""" + assert gutils.legalize_vector_name(name) == expected + + +def test_legalize_vector_name_fallback_prefix(): + """Check that the prefix is configurable and can be empty""" + assert gutils.legalize_vector_name("1st", fallback_prefix="map") == "map1st" + # An empty prefix is for names used as a suffix of an already valid name. + assert gutils.legalize_vector_name("1st", fallback_prefix="") == "1st" + + +def test_legalize_vector_name_invalid_arguments(): + """Check that an empty name and a prefix not starting with a letter fail""" + with pytest.raises(ValueError, match="cannot be empty"): + gutils.legalize_vector_name("") + with pytest.raises(ValueError, match="must start with an ASCII letter"): + gutils.legalize_vector_name("1st", fallback_prefix="1") + + +def test_append_random_suffix_length(): + """Check that the suffix has the requested length and legal characters""" + result = gutils.append_random("tmp", suffix_length=8) + assert re.fullmatch(r"tmp_[a-z0-9]{8}", result) + + +def test_append_random_total_length(): + """Check that the total length includes the name and the underscore""" + result = gutils.append_random("tmp", total_length=16) + assert len(result) == 16 + assert re.fullmatch(r"tmp_[a-z0-9]{12}", result) + + +@pytest.mark.parametrize( + ("kwargs", "message"), + [ + ({}, "has to be provided"), + ({"suffix_length": 4, "total_length": 16}, "not both"), + # No room for the underscore plus at least one random character. + ({"total_length": 4}, "No characters left"), + ], +) +def test_append_random_invalid_arguments(kwargs, message): + """Check that inconsistent length arguments are rejected""" + with pytest.raises(ValueError, match=message): + gutils.append_random("tmp", **kwargs) + + +def test_append_uuid(): + """Check that a full UUID4 hex string is appended and differs between calls""" + first = gutils.append_uuid("tmp") + assert re.fullmatch(r"tmp_[0-9a-f]{32}", first) + assert first != gutils.append_uuid("tmp") + + +def test_append_node_pid(): + """Check that the name is followed by a legal node name and the process ID""" + result = gutils.append_node_pid("tmp") + assert re.fullmatch(rf"tmp_[A-Za-z0-9_]+_{os.getpid()}", result) + assert gutils.legalize_vector_name(result) == result + + +def test_append_node_pid_legalizes_node_name(monkeypatch): + """Check that characters of the host name which are not legal are replaced""" + monkeypatch.setattr(gutils.platform, "node", lambda: "my-host.example.org") + assert gutils.append_node_pid("tmp") == f"tmp_my_host_example_org_{os.getpid()}" + + +@pytest.mark.parametrize( + ("path", "ext", "expected"), + [ + ("/data/map.txt", None, "map.txt"), + ("/data/map.txt", "txt", "map"), + # The extension is compared ignoring the case of the path. + ("/data/map.TXT", "txt", "map"), + # Other extensions are kept, and only the last extension is removed. + ("/data/map.txt", "csv", "map.txt"), + ("/data/map.tar.gz", "gz", "map.tar"), + ("/data/noext", "txt", "noext"), + ], +) +def test_basename(path, ext, expected): + """Check that directories and a matching extension are removed""" + assert gutils.basename(path, ext) == expected + + +def test_try_remove(tmp_path): + """Check that a file is removed and that failures are silent""" + path = tmp_path / "file.txt" + path.write_text("content") + gutils.try_remove(path) + assert not path.exists() + # Already removed, no exception expected. + gutils.try_remove(path) + # A directory cannot be removed by os.remove(), no exception expected. + gutils.try_remove(tmp_path) + assert tmp_path.is_dir() + + +def test_try_rmdir(tmp_path): + """Check that empty and non-empty directories are removed silently""" + empty = tmp_path / "empty" + empty.mkdir() + gutils.try_rmdir(empty) + assert not empty.exists() + + full = tmp_path / "full" + (full / "nested").mkdir(parents=True) + (full / "nested" / "file.txt").write_text("content") + gutils.try_rmdir(full) + assert not full.exists() + + # Missing directory, no exception expected. + gutils.try_rmdir(full) + + +def test_diff_files(tmp_path): + """Check that the differ output marks changed and unchanged lines""" + first = tmp_path / "first.txt" + second = tmp_path / "second.txt" + first.write_text("same\nold\n") + second.write_text("same\nnew\n") + assert gutils.diff_files(first, second) == [ + " same\n", + "- old\n", + "+ new\n", + ] + assert gutils.diff_files(first, first) == [" same\n", " old\n"] + + +def test_split_keeps_quoted_values_together(): + """Check that a quoted value with spaces is a single list item""" + assert gutils.split('d.vect map=roads where="cat < 10"') == [ + "d.vect", + "map=roads", + "where=cat < 10", + ] + + +def test_naturally_sorted(): + """Check that numbers are compared by value and letters ignoring case""" + items = ["map10", "map2", "Map1", "map1a"] + assert gutils.naturally_sorted(items) == ["Map1", "map1a", "map2", "map10"] + # The input is left untouched, unlike with naturally_sort(). + assert items == ["map10", "map2", "Map1", "map1a"] + gutils.naturally_sort(items) + assert items == ["Map1", "map1a", "map2", "map10"] + + +def test_naturally_sorted_key(): + """Check that the key function selects the text used for sorting""" + items = [("b", "file10"), ("a", "file9")] + assert gutils.naturally_sorted(items, key=operator.itemgetter(1)) == [ + ("a", "file9"), + ("b", "file10"), + ] + + +@pytest.mark.parametrize( + ("number", "max_number", "expected"), + [(10, 1000, "0010"), (10, 10, "10"), (0, 9, "0"), (7, 100, "007")], +) +def test_get_num_suffix(number, max_number, expected): + """Check that the padding depends on the digits of the maximum number""" + assert gutils.get_num_suffix(number, max_number) == expected + + +def test_parse_key_val_bytes_and_separators(): + """Check parsing of bytes input and custom separators""" + assert gutils.parse_key_val(b"min=20\nmax=50") == {"min": "20", "max": "50"} + assert gutils.parse_key_val("min:20|max:50", sep=":", vsep="|") == { + "min": "20", + "max": "50", + } + + +def test_parse_key_val_missing_value_uses_default(): + """Check that keys without a separator get the default value""" + assert gutils.parse_key_val("a=1\nb", dflt="none") == {"a": "1", "b": "none"} + assert gutils.parse_key_val("a=1\nb", val_type=int, dflt=0) == {"a": 1, "b": 0} + assert gutils.parse_key_val("a=1\nb") == {"a": "1", "b": None} + + +def test_parse_key_val_splits_on_first_separator_only(): + """Check that a value may contain the separator itself""" + assert gutils.parse_key_val("expr=a=b") == {"expr": "a=b"} + assert gutils.parse_key_val("expr=a=b", val_type=str.upper) == {"expr": "A=B"} + + +def test_parse_key_val_empty_input(): + """Check that empty input results in an empty dictionary""" + assert gutils.parse_key_val("") == {} + assert gutils.parse_key_val(b"") == {} + + +def test_get_lib_path_from_grass_etc(monkeypatch, tmp_path): + """Check that the module directory in GRASS_ETCDIR is preferred""" + (tmp_path / "etc" / "mymod").mkdir(parents=True) + (tmp_path / "addon" / "etc" / "mymod").mkdir(parents=True) + monkeypatch.setenv("GRASS_ETCDIR", str(tmp_path / "etc")) + monkeypatch.setenv("GRASS_ADDON_BASE", str(tmp_path / "addon")) + assert gutils.get_lib_path("mymod") == str(tmp_path / "etc" / "mymod") + + +def test_get_lib_path_from_addon_base(monkeypatch, tmp_path): + """Check the addon locations used when the module is not in GRASS_ETCDIR""" + (tmp_path / "etc").mkdir() + monkeypatch.setenv("GRASS_ETCDIR", str(tmp_path / "etc")) + monkeypatch.setenv("GRASS_ADDON_BASE", str(tmp_path / "addon")) + + (tmp_path / "addon" / "etc" / "mymod").mkdir(parents=True) + assert gutils.get_lib_path("mymod") == str(tmp_path / "addon" / "etc" / "mymod") + + # Without the etc directory, the module directory inside the addon is used. + (tmp_path / "addon" / "etc" / "mymod").rmdir() + (tmp_path / "addon" / "mymod" / "mymod").mkdir(parents=True) + assert gutils.get_lib_path("mymod") == str(tmp_path / "addon" / "mymod" / "mymod") + + +def test_get_lib_path_not_found(monkeypatch, tmp_path): + """Check that None is returned when no location and no build directory match""" + monkeypatch.setenv("GRASS_ETCDIR", str(tmp_path)) + monkeypatch.delenv("GRASS_ADDON_BASE", raising=False) + monkeypatch.chdir(tmp_path) + assert gutils.get_lib_path("module_which_does_not_exist") is None + + +def test_set_path_local_directory(monkeypatch, tmp_path): + """Check that an existing local directory makes its parent importable""" + (tmp_path / "libmymod").mkdir() + monkeypatch.setattr(sys, "path", list(sys.path)) + gutils.set_path("mymod", "libmymod", str(tmp_path)) + assert sys.path[-1] == str(tmp_path) + + +def test_set_path_not_found(monkeypatch, tmp_path): + """Check that a library found neither locally nor in GRASS fails to import""" + monkeypatch.setenv("GRASS_ETCDIR", str(tmp_path)) + monkeypatch.delenv("GRASS_ADDON_BASE", raising=False) + monkeypatch.chdir(tmp_path) + with pytest.raises(ImportError, match="Not able to find"): + gutils.set_path("module_which_does_not_exist", "lib") From 535ecd4201a81b4dfb7cdc16b16528d04dc9e8de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edouard=20Choini=C3=A8re?= <27212526+echoix@users.noreply.github.com> Date: Wed, 28 Oct 2026 19:30:16 +0000 Subject: [PATCH 2/3] man: Add tests for parser_standard_options.py Test the extraction of standard options from lib/gis/parser_standard_options.c (dynamic answers, multiline strings, multiline function calls, escaped quotes), the heading lookup, the Markdown generation for a single option and the CSV, HTML and Markdown output of the option table. The whole real C source file is parsed to check that every standard option is found. Pynguin was run on the module to see what a generator can do. It only produced empty inputs for the nested list and dict parameters, so the tests are written by hand, with AI assistance, with expected values checked against the C source. Mutations of the module were used to confirm that the tests fail when the code changes. --- man/tests/parser_standard_options_test.py | 283 ++++++++++++++++++++++ 1 file changed, 283 insertions(+) create mode 100644 man/tests/parser_standard_options_test.py diff --git a/man/tests/parser_standard_options_test.py b/man/tests/parser_standard_options_test.py new file mode 100644 index 00000000000..755b2942dd3 --- /dev/null +++ b/man/tests/parser_standard_options_test.py @@ -0,0 +1,283 @@ +# SPDX-License-Identifier: GPL-2.0-or-later +"""Tests for the extraction of standard parser options from the C source.""" + +import re +import sys +from pathlib import Path + +import pytest + +MAN_DIR = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(MAN_DIR)) + +import parser_standard_options as pso # noqa: E402 + +STANDARD_OPTIONS_SOURCE = MAN_DIR.parent / "lib" / "gis" / "parser_standard_options.c" + +# Excerpt of lib/gis/parser_standard_options.c with the constructs the parser +# has to handle: a dynamic answer, a description split into two string +# literals, translation macros, and a multiline function call. +C_SOURCE = """\ + /* raster maps */ + case G_OPT_MEMORYMB: + Opt->key = "memory"; + Opt->type = TYPE_INTEGER; + Opt->key_desc = "memory in MB"; + Opt->required = NO; + Opt->multiple = NO; + Opt->answer = "300"; + /* start dynamic answer */ + /* check MEMORYMB in GISRC, set with g.gisenv */ + memstr = G_store(G_getenv_nofatal("MEMORYMB")); + if (memstr && *memstr) + Opt->answer = memstr; + /* end dynamic answer */ + Opt->label = _("Maximum memory to be used (in MB)"); + Opt->description = _("Cache size for raster rows"); + break; + case G_OPT_R_TYPE: + Opt->key = "type"; + Opt->type = TYPE_STRING; + Opt->required = YES; + Opt->multiple = NO; + Opt->label = _("Type of raster map to be created"); + Opt->description = _("Storage type for resultant raster map"); + Opt->options = "CELL,FCELL,DCELL"; + G_asprintf((char **)&(Opt->descriptions), "CELL;%s;FCELL;%s;DCELL;%s", + _("Integer"), _("Single precision floating point"), + _("Double precision floating point")); + break; + case G_OPT_M_NPROCS: + Opt->key = "nprocs"; + Opt->type = TYPE_INTEGER; + Opt->required = NO; + Opt->multiple = NO; + Opt->answer = "0"; + Opt->label = _("Number of threads for parallel computing"); + Opt->description = _("0: use OpenMP default; >0: use nprocs; " + "<0: use MAX-nprocs"); + break; +""" + + +def parse_c_source(): + return dict(pso.parse_options(C_SOURCE.splitlines())) + + +def test_parse_options_names_and_order(): + """Check that each case label is an option, in the order of the source""" + names = [name for name, _ in pso.parse_options(C_SOURCE.splitlines())] + assert names == ["G_OPT_MEMORYMB", "G_OPT_R_TYPE", "G_OPT_M_NPROCS"] + + +def test_parse_options_values_are_cleaned(): + """Check that quotes, translation macros and semicolons are removed""" + option = parse_c_source()["G_OPT_R_TYPE"] + assert option == { + "key": "type", + "type": "TYPE_STRING", + "required": "YES", + "multiple": "NO", + "label": "Type of raster map to be created", + "description": "Storage type for resultant raster map", + "options": "CELL,FCELL,DCELL", + } + + +def test_parse_options_ignores_dynamic_answer(): + """Check that the static answer is used instead of the computed one""" + option = parse_c_source()["G_OPT_MEMORYMB"] + assert option["answer"] == "300" + assert option["key_desc"] == "memory in MB" + + +def test_parse_options_joins_multiline_strings(): + """Check that a description split into several literals is one string""" + option = parse_c_source()["G_OPT_M_NPROCS"] + assert option["description"] == ( + "0: use OpenMP default; >0: use nprocs; <0: use MAX-nprocs" + ) + + +def test_parse_options_skips_multiline_function_calls(): + """Check that the arguments of a multiline call are not taken as options""" + option = parse_c_source()["G_OPT_R_TYPE"] + assert "descriptions" not in option + assert not any("Integer" in value for value in option.values()) + + +def test_parse_options_ignores_lines_outside_cases(): + """Check that nothing is extracted before the first case or without a body""" + assert pso.parse_options([]) == [] + assert pso.parse_options(['Opt->key = "stray";', "break;"]) == [] + assert pso.parse_options(["case G_OPT_EMPTY:", "break;"]) == [] + + +def test_parse_options_unescapes_quotes(): + """Check that escaped double quotes in a string become plain quotes""" + lines = [ + "case G_OPT_QUOTED:", + r'Opt->description = _("Use \"auto\" for default");', + "break;", + ] + assert pso.parse_options(lines) == [ + ("G_OPT_QUOTED", {"description": 'Use "auto" for default'}) + ] + + +def test_parse_options_all_standard_options(): + """Check that every standard option in the real source file is extracted""" + source = STANDARD_OPTIONS_SOURCE.read_text(encoding="utf-8") + expected = re.findall(r"^\s*case (G_OPT_\w+):", source, flags=re.MULTILINE) + options = pso.parse_options(source.splitlines()) + assert [name for name, _ in options] == expected + + +def test_parse_options_real_raster_input(): + """Check a well known option from the real source file""" + source = STANDARD_OPTIONS_SOURCE.read_text(encoding="utf-8") + option = dict(pso.parse_options(source.splitlines()))["G_OPT_R_INPUT"] + assert option["key"] == "input" + assert option["required"] == "YES" + assert option["gisprompt"] == "old,cell,raster" + + +@pytest.mark.parametrize( + ("name", "heading"), + [ + ("G_OPT_R_INPUT", "Raster"), + ("G_OPT_R3_INPUT", "Raster 3D"), + ("G_OPT_V_OUTPUT", "Vector"), + ("G_OPT_V3_TYPE", "Vector"), + ("G_OPT_STRDS_INPUT", "Temporal"), + ("G_OPT_CN", "Color"), + ("G_OPT_MEMORYMB", "Other"), + ("G_OPT_UNKNOWN_PREFIX", "Other"), + ], +) +def test_get_heading(name, heading): + """Check that the heading is selected by the part after G_OPT_""" + assert pso.get_heading(name) == heading + + +def test_get_heading_invalid_identifier(): + """Check that an identifier not starting with G_OPT_ is reported""" + with pytest.raises(ValueError, match="Strangely formatted"): + pso.get_heading("R_INPUT") + + +def test_write_markdown_option_string_option(): + """Check the usage examples generated for an option of type string""" + md = [] + option = { + "key": "input", + "type": "TYPE_STRING", + "key_desc": "name", + "required": "YES", + "multiple": "NO", + } + pso.write_markdown_option( + md, "G_OPT_R_INPUT", option, ["key", "type", "required", "multiple"] + ) + text = "\n".join(md) + assert "| key | input |" in md + assert "| type | TYPE_STRING (`type: string`) |" in md + assert "| required | YES (`required: yes`) |" in md + assert "| multiple | NO (`multiple: no`) |" in md + assert "# %option G_OPT_R_INPUT" in text + assert 'input = options["input"]' in text + # A string is quoted and the key description is the example without answer. + assert 'input="name"' in md + + +def test_write_markdown_option_answer_example(): + """Check that the answer is the example unless it is a constant""" + md = [] + pso.write_markdown_option( + md, + "G_OPT_M_NPROCS", + {"key": "nprocs", "type": "TYPE_INTEGER", "answer": "0"}, + ["key", "answer"], + ) + assert "nprocs=0" in md + + md = [] + pso.write_markdown_option( + md, + "G_OPT_C", + { + "key": "color", + "type": "TYPE_STRING", + "answer": "DEFAULT_FG_COLOR", + "key_desc": "name", + }, + ["key", "answer"], + ) + assert 'color="name"' in md + + +def test_write_markdown_option_dynamic_value(): + """Check that a computed value is described as such""" + md = [] + pso.write_markdown_option( + md, + "G_OPT_EXAMPLE", + {"key": "example", "answer": "G_getenv_nofatal(NAME)"}, + ["key", "answer"], + ) + assert "| answer | Computed dynamically by *G_getenv_nofatal(NAME)* |" in md + + +def test_write_markdown_option_without_key(): + """Check that no Python usage examples are generated without a key""" + md = [] + pso.write_markdown_option(md, "G_OPT_NOKEY", {"type": "TYPE_STRING"}, ["type"]) + assert "To use the value in Python:" not in "\n".join(md) + + +def test_option_table_columns_are_sorted_union(): + """Check that the columns are all keys used by any option, sorted""" + table = pso.OptTable([("A", {"type": "t", "key": "k"}), ("B", {"required": "YES"})]) + assert table.columns == ["key", "required", "type"] + + +def test_option_table_csv_rows(): + """Check that missing values are empty and the delimiter is configurable""" + table = pso.OptTable([("A", {"key": "k", "type": "t"}), ("B", {"key": "l"})]) + rows = table.csv(delimiter="|").splitlines()[1:] + assert rows == ["A|k|t", "B|l|"] + + +def test_option_table_html(): + """Check the table structure, the header and the empty cells""" + table = pso.OptTable([("A", {"key": "k"}), ("B", {})]) + html = table.html(toptions=None).splitlines() + assert html[0] == "" + assert " " in html + assert " " in html + assert " " in html + assert " " in html + assert " " in html + assert html[-1] == "
optionkeyAk
" + assert pso.OptTable([]).html().splitlines()[0] == "" + + +def test_option_table_markdown_groups_options_by_heading(): + """Check that options are listed in a section of their heading""" + table = pso.OptTable( + [ + ("G_OPT_V_INPUT", {"key": "map"}), + ("G_OPT_R_INPUT", {"key": "input"}), + ("G_OPT_MEMORYMB", {"key": "memory"}), + ] + ) + lines = table.markdown().splitlines() + assert pso.MARKDOWN_INTRO_TEXT.splitlines()[0] in lines + raster = lines.index("## Raster") + vector = lines.index("## Vector") + other = lines.index("## Other") + assert lines.index("### G_OPT_R_INPUT") > raster + assert lines.index("### G_OPT_V_INPUT") > vector + assert lines.index("### G_OPT_MEMORYMB") > other + # Headings are sorted, with the catch-all one last. + assert raster < vector < other From b06c22e77088c763219a356114f228bcb0016132 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edouard=20Choini=C3=A8re?= <27212526+echoix@users.noreply.github.com> Date: Wed, 28 Oct 2026 19:30:16 +0000 Subject: [PATCH 3/3] man: Fix parenthesis counting after multiline calls in parse_options A multiline G_ call in a case of lib/gis/parser_standard_options.c, such as G_asprintf(, set the open parenthesis count and the following lines subtracted their net count, so the closing line increased the count instead of bringing it to zero. All lines after the call were then skipped until the end of the case. The standard options currently in the C source have such calls only as the last statement of a case, so the generated documentation is unchanged. Add the continuation lines' net count instead, and add a test with an assignment after a multiline call. The problem was found by checking whether the new tests detect changes of this line, with AI assistance. --- man/parser_standard_options.py | 2 +- man/tests/parser_standard_options_test.py | 16 ++++++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/man/parser_standard_options.py b/man/parser_standard_options.py index febbaa5d781..fb5c8f21aac 100644 --- a/man/parser_standard_options.py +++ b/man/parser_standard_options.py @@ -45,7 +45,7 @@ def count_par_diff(line: str) -> int: elif line.startswith("G_"): diff = count_par_diff(line) elif diff > 0: - diff -= count_par_diff(line) + diff += count_par_diff(line) elif res is not None: res.append(line) diff --git a/man/tests/parser_standard_options_test.py b/man/tests/parser_standard_options_test.py index 755b2942dd3..7684b4a874f 100644 --- a/man/tests/parser_standard_options_test.py +++ b/man/tests/parser_standard_options_test.py @@ -106,6 +106,22 @@ def test_parse_options_skips_multiline_function_calls(): assert not any("Integer" in value for value in option.values()) +def test_parse_options_continues_after_multiline_function_call(): + """Check that an assignment following a multiline call is still extracted""" + lines = [ + "case G_OPT_AFTER_CALL:", + 'Opt->key = "after";', + "G_asprintf(", + '(char **)&(Opt->descriptions), "a;%s",', + '_("A"));', + 'Opt->description = _("After the call");', + "break;", + ] + assert pso.parse_options(lines) == [ + ("G_OPT_AFTER_CALL", {"key": "after", "description": "After the call"}) + ] + + def test_parse_options_ignores_lines_outside_cases(): """Check that nothing is extracted before the first case or without a body""" assert pso.parse_options([]) == []