From c93207addb3a09f9133c595b83eb76248f4996f0 Mon Sep 17 00:00:00 2001 From: Qixiang Wan Date: Mon, 20 Feb 2017 22:45:51 +0800 Subject: [PATCH] checks: extend validator with 'alias' When a property has 'alias' defined, and it's not present in instance, if the alias property is present, add the property with value from alias property before remove the alias property from instance. Examples: with schema: { "$schema": "http://json-schema.org/draft-04/schema#", "title": "Pungi Configuration", "type": "object", "properties": { "release_name": {"type": "string", "alias": "product_name"}, }, "required": ["release_name"], "additionalProperties": False, } 1. config = {"release_name": "dummy product"}: validate pass, config not changed after validation. 2. config = {"product_name": "dummy product"}: validate pass, config updated to the following after validation: config: {"release_name": "dummy product"} 3. config = {"name": "dummy product"}: validate fail, errror message is "Failed validation in : 'release_name' is a required property", and warning message is "WARNING: Unrecognized config option: name." 4. config = {"product_name": "dummy product", "release_name": "dummy product"} validate fail, error message is "Failed validation in : product_name is an alias of release_name, only one can be used." Signed-off-by: Qixiang Wan --- pungi/checks.py | 64 +++++++++++++++-- tests/test_checks.py | 165 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 223 insertions(+), 6 deletions(-) diff --git a/pungi/checks.py b/pungi/checks.py index 119973c7..a2e410eb 100644 --- a/pungi/checks.py +++ b/pungi/checks.py @@ -187,7 +187,7 @@ def validate(config): Undefined values for which a default value exists will be filled in. """ schema = _make_schema() - DefaultValidator = _extend_with_default(jsonschema.Draft4Validator) + DefaultValidator = _extend_with_default_and_alias(jsonschema.Draft4Validator) validator = DefaultValidator(schema, {'array': (tuple, list), 'regex': (str, unicode)}) @@ -235,22 +235,72 @@ UNKNOWN = 'WARNING: Unrecognized config option: {0}.' UNKNOWN_SUGGEST = 'WARNING: Unrecognized config option: {0}. Did you mean {1}?' -def _extend_with_default(validator_class): +def _extend_with_default_and_alias(validator_class): validate_properties = validator_class.VALIDATORS["properties"] validate_type = validator_class.VALIDATORS['type'] + validate_required = validator_class.VALIDATORS['required'] + validate_additional_properties = validator_class.VALIDATORS['additionalProperties'] - def set_defaults(validator, properties, instance, schema): + def _replace_alias(properties, instance, schema): + """ + If 'alias' is defined for a property, and the property is not present + in instance, add the property with value from the alias property to + instance before remove the alias property. If both the propery and its + alias are present, it will yield an error. + """ + for property, subschema in properties.iteritems(): + if "alias" in subschema: + if property in instance and subschema['alias'] in instance: + # the order of validators is in random, so we remove the alias + # property at the first time when it's found, then validators + # won't raise the same error later. + instance.pop(subschema['alias']) + yield jsonschema.ValidationError( + "%s is an alias of %s, only one can be used." % ( + subschema['alias'], property) + ) + + if property not in instance and subschema['alias'] in instance: + instance.setdefault(property, instance.pop(subschema['alias'])) + + def set_defaults_and_aliases(validator, properties, instance, schema): """ Assign default values to options that have them defined and are not - specified. + specified. And if a property has 'alias' defined and the property is + not specified, look for the alias property and copy alias property's + value to that property before remove the alias property. """ for property, subschema in properties.iteritems(): if "default" in subschema and property not in instance: instance.setdefault(property, subschema["default"]) + for error in _replace_alias(properties, instance, schema): + yield error + for error in validate_properties(validator, properties, instance, schema): yield error + def ignore_alias_properties(validator, aP, instance, schema): + """ + If there is a property has alias defined in schema, and the property is not + present in instance, set the property with the value of alias property, + remove alias property from instance. + """ + properties = schema.get("properties", {}) + for error in _replace_alias(properties, instance, schema): + yield error + + for error in validate_additional_properties(validator, aP, instance, schema): + yield error + + def validate_required_with_alias(validator, required, instance, schema): + properties = schema.get("properties", {}) + for error in _replace_alias(properties, instance, schema): + yield error + + for error in validate_required(validator, required, instance, schema): + yield error + def error_on_deprecated(validator, properties, instance, schema): """Unconditionally raise deprecation error if encountered.""" yield ConfigDeprecation(properties) @@ -273,9 +323,11 @@ def _extend_with_default(validator_class): yield error return jsonschema.validators.extend( - validator_class, {"properties": set_defaults, + validator_class, {"properties": set_defaults_and_aliases, "deprecated": error_on_deprecated, - "type": validate_regex_type}, + "type": validate_regex_type, + "required": validate_required_with_alias, + "additionalProperties": ignore_alias_properties}, ) diff --git a/tests/test_checks.py b/tests/test_checks.py index ea64577b..a8a55ae7 100644 --- a/tests/test_checks.py +++ b/tests/test_checks.py @@ -10,6 +10,8 @@ import os import sys import StringIO +import kobo.conf + sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..')) from pungi import checks @@ -188,6 +190,169 @@ class CheckDependenciesTestCase(unittest.TestCase): self.assertTrue(result) +class TestSchemaValidator(unittest.TestCase): + def _load_conf_from_string(self, string): + conf = kobo.conf.PyConfigParser() + conf.load_from_string(string) + return conf + + @mock.patch('pungi.checks._make_schema') + def test_property(self, make_schema): + schema = { + "$schema": "http://json-schema.org/draft-04/schema#", + "title": "Pungi Configuration", + "type": "object", + "properties": { + "release_name": {"type": "string", "alias": "product_name"}, + }, + "additionalProperties": False, + "required": ["release_name"], + } + make_schema.return_value = schema + + string = """ + release_name = "dummy product" + """ + config = self._load_conf_from_string(string) + errors, warnings = checks.validate(config) + self.assertEqual(len(errors), 0) + self.assertEqual(len(warnings), 0) + self.assertEqual(config.get("release_name", None), "dummy product") + + @mock.patch('pungi.checks._make_schema') + def test_alias_property(self, make_schema): + schema = { + "$schema": "http://json-schema.org/draft-04/schema#", + "title": "Pungi Configuration", + "type": "object", + "properties": { + "release_name": {"type": "string", "alias": "product_name"}, + }, + "additionalProperties": False, + } + make_schema.return_value = schema + + string = """ + product_name = "dummy product" + """ + config = self._load_conf_from_string(string) + errors, warnings = checks.validate(config) + self.assertEqual(len(errors), 0) + self.assertEqual(len(warnings), 0) + self.assertEqual(config.get("release_name", None), "dummy product") + + @mock.patch('pungi.checks._make_schema') + def test_required_is_missing(self, make_schema): + schema = { + "$schema": "http://json-schema.org/draft-04/schema#", + "title": "Pungi Configuration", + "type": "object", + "properties": { + "release_name": {"type": "string", "alias": "product_name"}, + }, + "additionalProperties": False, + "required": ["release_name"], + } + make_schema.return_value = schema + + string = """ + name = "dummy product" + """ + config = self._load_conf_from_string(string) + errors, warnings = checks.validate(config) + self.assertEqual(len(errors), 1) + self.assertIn("Failed validation in : 'release_name' is a required property", errors) + self.assertEqual(len(warnings), 1) + self.assertIn("WARNING: Unrecognized config option: name.", warnings) + + @mock.patch('pungi.checks._make_schema') + def test_required_is_in_alias(self, make_schema): + schema = { + "$schema": "http://json-schema.org/draft-04/schema#", + "title": "Pungi Configuration", + "type": "object", + "properties": { + "release_name": {"type": "string", "alias": "product_name"}, + }, + "additionalProperties": False, + "required": ["release_name"], + } + make_schema.return_value = schema + + string = """ + product_name = "dummy product" + """ + config = self._load_conf_from_string(string) + errors, warnings = checks.validate(config) + self.assertEqual(len(errors), 0) + self.assertEqual(len(warnings), 0) + self.assertEqual(config.get("release_name", None), "dummy product") + + @mock.patch('pungi.checks._make_schema') + def test_redundant_alias(self, make_schema): + schema = { + "$schema": "http://json-schema.org/draft-04/schema#", + "title": "Pungi Configuration", + "type": "object", + "properties": { + "release_name": {"type": "string", "alias": "product_name"}, + }, + "additionalProperties": False, + "required": ["release_name"], + } + make_schema.return_value = schema + + string = """ + product_name = "dummy product" + release_name = "dummy product" + """ + config = self._load_conf_from_string(string) + errors, warnings = checks.validate(config) + self.assertEqual(len(errors), 1) + self.assertIn('Failed validation in : product_name is an alias of release_name, only one can be used.', errors) + self.assertEqual(len(warnings), 0) + self.assertEqual(config.get("release_name", None), "dummy product") + + @mock.patch('pungi.checks._make_schema') + def test_properties_in_deep(self, make_schema): + schema = { + "$schema": "http://json-schema.org/draft-04/schema#", + "title": "Pungi Configuration", + "type": "object", + "properties": { + "release_name": {"type": "string", "alias": "product_name"}, + "keys": { + "type": "array", + "items": {"type": "string"}, + }, + "foophase": { + "type": "object", + "properties": { + "repo": {"type": "string", "alias": "tree"}, + }, + "additionalProperties": False, + "required": ["repo"], + }, + }, + "additionalProperties": False, + "required": ["release_name"], + } + make_schema.return_value = schema + + string = """ + product_name = "dummy product" + foophase = { + "tree": "http://www.exampe.com/os" + } + """ + config = self._load_conf_from_string(string) + errors, warnings = checks.validate(config) + self.assertEqual(len(errors), 0) + self.assertEqual(len(warnings), 0) + self.assertEqual(config.get("release_name", None), "dummy product") + self.assertEqual(config.get("foophase", {}).get("repo", None), "http://www.exampe.com/os") + + class TestUmask(unittest.TestCase): def setUp(self): self.orig_umask = os.umask(0)