diff --git a/README.md b/README.md index d8bdd2cb..eb06ce1e 100644 --- a/README.md +++ b/README.md @@ -131,6 +131,22 @@ Windows users are recommended to execute LISFLOOD with a Docker image. The users are recommended to download the [reference settings xml](https://github.com/ec-jrc/lisflood-code/tree/master/src/lisfloodSettings_reference.xml) file and adapt it by inserting their own paths and modelling choices. +## Settings Tool + +This package also installs `lisflood-settings`, a CLI utility to parse, lint and update LISFLOOD settings XML files while preserving comments. + +Examples: + +```bash +# Validate only (no output file is written) +lisflood-settings check -i in.xml + +# Clean/lint a settings file (no updates, writes formatted copy) +lisflood-settings set -i in.xml -o out.xml + +# Update from file plus explicit overrides +lisflood-settings set -i in.xml -o out.xml -f updates.yaml --lfoptions wateruse=1 TemperatureInKelvin=0 --lfuser PathRoot=/data/project NetCDFTimeChunks=10 +``` ## Collaborate diff --git a/setup.py b/setup.py index f69f1e17..43aba326 100755 --- a/setup.py +++ b/setup.py @@ -159,6 +159,11 @@ def _get_gdal_version(): ], install_requires=requirements, scripts=['bin/lisflood'], + entry_points={ + 'console_scripts': [ + 'lisflood-settings=lisflood.settings_tool:main', + ], + }, zip_safe=True, classifiers=[ # complete classifier list: http://pypi.python.org/pypi?%3Aaction=list_classifiers diff --git a/src/lisflood/settings_tool.py b/src/lisflood/settings_tool.py new file mode 100644 index 00000000..0c55e366 --- /dev/null +++ b/src/lisflood/settings_tool.py @@ -0,0 +1,202 @@ +""" +LISFLOOD settings XML formatter and updater. +""" + +from __future__ import absolute_import + +import argparse +import sys + +import yaml +from lxml import etree + + +class SettingsToolError(Exception): + """Raised when the settings tool cannot complete requested operations.""" + + +def _parse_bool(value): + if isinstance(value, bool): + return 1 if value else 0 + sval = str(value).strip().lower() + if sval in ("1", "true", "yes", "on"): + return 1 + if sval in ("0", "false", "no", "off"): + return 0 + raise SettingsToolError("Invalid lfoptions value '{}'. Use 0/1 or true/false.".format(value)) + + +def _get_required_child(root, names): + for name in names: + node = root.find(name) + if node is not None: + return node + raise SettingsToolError("Missing XML section(s): {}".format(", ".join(names))) + + +def _index_options(lfoptions_elem): + indexed = {} + for node in lfoptions_elem.findall(".//setoption"): + name = node.get("name") + if name: + indexed[name] = node + return indexed + + +def _index_textvars(section_elem): + indexed = {} + for node in section_elem.findall(".//textvar"): + name = node.get("name") + if name: + indexed[name] = node + return indexed + + +def _key_value_arg(expression): + if "=" not in expression: + raise argparse.ArgumentTypeError( + "Invalid entry '{}' (expected KEY=VALUE).".format(expression) + ) + name, value = expression.split("=", 1) + if not name.strip(): + raise argparse.ArgumentTypeError( + "Invalid entry '{}' (empty key in KEY=VALUE).".format(expression) + ) + return name.strip(), value + + +def _flatten_pairs(raw_values): + if not raw_values: + return [] + # raw_values shape with nargs='+' and action='append': [[(k,v), ...], ...] + flattened = [] + for values in raw_values: + flattened.extend(values) + return flattened + + +def run_tool(input_path, output_path=None, file_path=None, lfoptions_values=None, lfuser_values=None, check=False): + parser = etree.XMLParser(remove_blank_text=True, remove_comments=False) + tree = etree.parse(input_path, parser) + root = tree.getroot() + if root.tag != "lfsettings": + raise SettingsToolError("Root element must be , found <{}>.".format(root.tag)) + + lfoptions_elem = _get_required_child(root, ("lfoptions",)) + lfuser_elem = _get_required_child(root, ("lfuser",)) + _ = _get_required_child(root, ("lfbinding", "lfbindings")) + + if check: + return 0 + + if not output_path: + raise SettingsToolError("Output path is required for the 'set' subcommand.") + + options_index = _index_options(lfoptions_elem) + user_index = _index_textvars(lfuser_elem) + + merged_lfoptions = {} + merged_lfuser = {} + + if file_path: + with open(file_path, "r") as stream: + payload = yaml.safe_load(stream) or {} + yaml_options = payload.get("lfoptions") or payload.get("options") or {} + yaml_user = payload.get("lfuser") or payload.get("user") or {} + if not isinstance(yaml_options, dict) or not isinstance(yaml_user, dict): + raise SettingsToolError("Update file must use mapping values for lfoptions/lfuser.") + merged_lfoptions.update(yaml_options) + merged_lfuser.update(yaml_user) + + for name, value in lfoptions_values or []: + merged_lfoptions[name] = value + for name, value in lfuser_values or []: + merged_lfuser[name] = value + + for name, value in merged_lfoptions.items(): + node = options_index.get(name) + if node is None: + raise SettingsToolError("lfoptions variable '{}' not found in XML.".format(name)) + node.set("choice", str(_parse_bool(value))) + + for name, value in merged_lfuser.items(): + node = user_index.get(name) + if node is None: + raise SettingsToolError("lfuser variable '{}' not found in XML.".format(name)) + node.set("value", str(value)) + + tree.write(output_path, encoding="utf-8", pretty_print=True) + return 0 + + +def build_parser(): + parser = argparse.ArgumentParser( + description="Parse, validate, lint and update LISFLOOD settings XML files." + ) + subparsers = parser.add_subparsers(dest="command", required=True) + + check_parser = subparsers.add_parser( + "check", + help="Validate XML structure/sections without writing output.", + ) + check_parser.add_argument("-i", "--input", required=True, help="Input LISFLOOD settings XML file.") + + set_parser = subparsers.add_parser( + "set", + help="Apply updates and write output XML.", + ) + set_parser.add_argument("-i", "--input", required=True, help="Input LISFLOOD settings XML file.") + set_parser.add_argument("-o", "--output", required=True, help="Output XML path.") + set_parser.add_argument( + "-f", + "--file", + dest="file", + help="YAML file with updates. Format: {lfoptions: {name: 0/1}, lfuser: {name: value}}.", + ) + set_parser.add_argument( + "--lfoptions", + action="append", + nargs="+", + type=_key_value_arg, + metavar="KEY=VALUE", + help="One or more lfoptions updates. Example: --lfoptions TemperatureInKelvin=1 wateruse=0", + ) + set_parser.add_argument( + "--lfuser", + action="append", + nargs="+", + type=_key_value_arg, + metavar="KEY=VALUE", + help="One or more lfuser updates. Example: --lfuser PathRoot=/data NetCDFTimeChunks=10", + ) + + return parser + + +def main(argv=None): + parser = build_parser() + args = parser.parse_args(argv) + + try: + if args.command == "check": + run_tool(input_path=args.input, check=True) + return 0 + + lfoptions_values = _flatten_pairs(args.lfoptions) + lfuser_values = _flatten_pairs(args.lfuser) + run_tool( + input_path=args.input, + output_path=args.output, + file_path=args.file, + lfoptions_values=lfoptions_values, + lfuser_values=lfuser_values, + check=False, + ) + except (SettingsToolError, etree.XMLSyntaxError, OSError, yaml.YAMLError) as exc: + print("Error: {}".format(exc), file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_settings_tool.py b/tests/test_settings_tool.py new file mode 100644 index 00000000..472cb86e --- /dev/null +++ b/tests/test_settings_tool.py @@ -0,0 +1,166 @@ +import pytest +from lxml import etree + +from lisflood.settings_tool import main + + +SAMPLE_XML = """\ + + + + # option note + + + + + + + + Keep me + + + + + + + + + + + +""" + + +def _write(path, content): + path.write_text(content, encoding="utf-8") + + +def _parse(path): + return etree.parse(str(path)) + + +class TestSettingsTool: + def test_set_clean_rewrite_and_comment_propagation(self, tmp_path): + src = tmp_path / "settings.xml" + dst = tmp_path / "clean.xml" + _write(src, SAMPLE_XML) + + rc = main(["set", "-i", str(src), "-o", str(dst)]) + assert rc == 0 + assert dst.exists() + + output = dst.read_text(encoding="utf-8") + assert "top-level comment" in output + assert "Keep me" in output + + tree = _parse(dst) + assert tree.getroot().tag == "lfsettings" + + def test_set_file_and_cli_updates_with_list_values(self, tmp_path): + src = tmp_path / "settings.xml" + dst = tmp_path / "updated.xml" + yml = tmp_path / "updates.yaml" + _write(src, SAMPLE_XML) + _write( + yml, + "lfoptions:\n TemperatureInKelvin: 1\nlfuser:\n SomeUserVar: from-yaml\n", + ) + + rc = main( + [ + "set", + "-i", + str(src), + "-o", + str(dst), + "--file", + str(yml), + "--lfoptions", + "wateruse=0", + "TemperatureInKelvin=1", + "--lfuser", + "SomeUserVar=from-cli", + "PathRoot=/data/root", + ] + ) + assert rc == 0 + + tree = _parse(dst) + options = {n.get("name"): n.get("choice") for n in tree.findall(".//lfoptions//setoption")} + users = {n.get("name"): n.get("value") for n in tree.findall(".//lfuser//textvar")} + + assert options["TemperatureInKelvin"] == "1" + assert options["wateruse"] == "0" + assert users["SomeUserVar"] == "from-cli" + assert users["PathRoot"] == "/data/root" + + def test_set_allows_repeating_lfoptions_and_lfuser_flags(self, tmp_path): + src = tmp_path / "settings.xml" + dst = tmp_path / "updated.xml" + _write(src, SAMPLE_XML) + + rc = main( + [ + "set", + "-i", + str(src), + "-o", + str(dst), + "--lfoptions", + "TemperatureInKelvin=1", + "--lfoptions", + "wateruse=0", + "--lfuser", + "SomeUserVar=from-cli", + "--lfuser", + "PathRoot=/alternate/root", + ] + ) + assert rc == 0 + + tree = _parse(dst) + options = {n.get("name"): n.get("choice") for n in tree.findall(".//lfoptions//setoption")} + users = {n.get("name"): n.get("value") for n in tree.findall(".//lfuser//textvar")} + assert options["TemperatureInKelvin"] == "1" + assert options["wateruse"] == "0" + assert users["SomeUserVar"] == "from-cli" + assert users["PathRoot"] == "/alternate/root" + + def test_check_mode_no_output_written(self, tmp_path): + src = tmp_path / "settings.xml" + dst = tmp_path / "check-output.xml" + _write(src, SAMPLE_XML) + + rc = main(["check", "-i", str(src)]) + assert rc == 0 + assert not dst.exists() + + def test_rejects_binding_edits_from_file_inputs(self, tmp_path): + src = tmp_path / "settings.xml" + dst = tmp_path / "updated.xml" + yml = tmp_path / "updates.yaml" + _write(src, SAMPLE_XML) + _write(yml, "lfbinding:\n MaskMap: /tmp/new-mask.nc\n") + + rc = main(["set", "-i", str(src), "-o", str(dst), "--file", str(yml)]) + assert rc == 0 + + tree = _parse(dst) + bindings = {n.get("name"): n.get("value") for n in tree.findall(".//lfbinding//textvar")} + assert bindings["MaskMap"] == "$(PathRoot)/mask.nc" + + def test_removed_set_option_is_rejected(self, tmp_path): + src = tmp_path / "settings.xml" + dst = tmp_path / "updated.xml" + _write(src, SAMPLE_XML) + + with pytest.raises(SystemExit): + main(["set", "-i", str(src), "-o", str(dst), "--set", "lfuser.SomeUserVar=abc"]) + + def test_lfoptions_rejects_non_key_value_token(self, tmp_path): + src = tmp_path / "settings.xml" + dst = tmp_path / "updated.xml" + _write(src, SAMPLE_XML) + + with pytest.raises(SystemExit): + main(["set", "-i", str(src), "-o", str(dst), "--lfoptions", "wateruse=1", "not-a-pair"])