diff --git a/netjsonconfig/exceptions.py b/netjsonconfig/exceptions.py index 8a38a3439..b4393af00 100644 --- a/netjsonconfig/exceptions.py +++ b/netjsonconfig/exceptions.py @@ -7,6 +7,10 @@ def _list_errors(e): :param e: ``jsonschema.exceptions.ValidationError`` instance """ error_list = [] + if not getattr(e, "context", None): + return error_list + if not getattr(e, "validator_value", None): + return error_list for value, error in zip(e.validator_value, e.context): error_list.append((value, error.message)) if error.context: @@ -25,6 +29,8 @@ def __str__(self): self.details, ) errors = _list_errors(self.details) + if not errors: + return message separator = "\nAgainst schema %s\n%s\n" details = reduce(lambda x, y: x + separator % y, errors, "") return message + details diff --git a/netjsonconfig/utils.py b/netjsonconfig/utils.py index de9a08bf9..617e2d681 100644 --- a/netjsonconfig/utils.py +++ b/netjsonconfig/utils.py @@ -2,6 +2,10 @@ from collections import OrderedDict from copy import deepcopy +from jsonschema import ValidationError as JsonSchemaError + +from .exceptions import ValidationError + def merge_config(template, config, list_identifiers=None): """ @@ -19,14 +23,27 @@ def merge_config(template, config, list_identifiers=None): :param config: config ``dict`` :param list_identifiers: ``list`` or ``None`` :returns: merged ``dict`` + :raises ValidationError: if incompatible types are found """ result = deepcopy(template) for key, value in config.items(): - if isinstance(value, dict): - node = result.get(key, OrderedDict()) - result[key] = merge_config(node, value) - elif isinstance(value, list) and isinstance(result.get(key), list): - result[key] = merge_list(result[key], value, list_identifiers) + existing = result.get(key) + if isinstance(value, dict) and isinstance(existing, dict): + result[key] = merge_config(existing, value, list_identifiers) + elif isinstance(value, list) and isinstance(existing, list): + result[key] = merge_list(existing, value, list_identifiers) + elif ( + existing is not None + and isinstance(existing, (dict, list)) + and isinstance(value, (dict, list)) + and type(value) is not type(existing) + ): + raise ValidationError( + JsonSchemaError( + f"Incompatible type for '{key}': expected {type(existing).__name__}, " + f"got {type(value).__name__}." + ) + ) else: result[key] = value return result diff --git a/tests/openwrt/test_default.py b/tests/openwrt/test_default.py index 61ed40210..a22802b5b 100644 --- a/tests/openwrt/test_default.py +++ b/tests/openwrt/test_default.py @@ -3,6 +3,7 @@ from openwisp_utils.tests import capture_stdout from netjsonconfig import OpenWrt +from netjsonconfig.exceptions import ValidationError from netjsonconfig.utils import _TabsMixin @@ -245,3 +246,28 @@ def test_render_invalid_uci_name(self): option lan '0.0.0.0/24 domain=1' """) self.assertEqual(o.render(), expected) + + def test_merge_invalid_format(self): + invalid = { + "dhcp": { + "lan": { + "interface": "lan", + "start": 100, + "limit": 150, + "leasetime": "12h", + } + } + } + valid = { + "dhcp": [ + { + "dhcpv6": "disabled", + "ignore": True, + "ra": "disabled", + "config_value": "lan", + "config_name": "dhcp", + } + ] + } + with self.assertRaises(ValidationError): + OpenWrt({}, templates=[valid, invalid]) diff --git a/tests/test_utils.py b/tests/test_utils.py index 0d83b5d13..052734b7a 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,5 +1,6 @@ import unittest +from netjsonconfig.exceptions import ValidationError from netjsonconfig.utils import evaluate_vars, get_copy, merge_config, merge_list @@ -55,6 +56,20 @@ def test_merge_list_of_dicts_unchanged(self): result, {"list": [{"a": "original"}, {"b": "changed"}, {"c": "original"}]} ) + def test_merge_config_list_scalar_overrides(self): + template = {"server": ["1.1.1.1"]} + config = {"server": "8.8.8.8"} + result = merge_config(template, config) + self.assertEqual(result, {"server": "8.8.8.8"}) + + def test_merge_config_incompatible_dict_list(self): + template = {"a": {"b": "c"}} + config = {"a": ["x"]} + with self.assertRaises(ValidationError) as context: + merge_config(template, config) + self.assertIn("Incompatible type", str(context.exception)) + self.assertIn("ValidationError", str(context.exception)) + def test_evaluate_vars(self): self.assertEqual(evaluate_vars("{{ tz }}", {"tz": "UTC"}), "UTC") self.assertEqual(evaluate_vars("tz: {{ tz }}", {"tz": "UTC"}), "tz: UTC")