From 51dc4e2218d9f086960ffc5f72ffc974ceed00cc Mon Sep 17 00:00:00 2001 From: Zane Bitter Date: Fri, 9 Aug 2013 20:19:50 +0200 Subject: [PATCH] Define a Schema format for properties Define a Schema class for properties and their constraints that is easily serialisable to a JSON schema format that matches the proposed Parameter input syntax for HOT. Since resource properties have not been exposed to the user before, this format will make the most sense to a user in the long term. A future patch will switch the Property validation code over to using Schema objects instead of the dictionary-based schemata currently in use. blueprint resource-properties-schema Change-Id: I79fa5927bdffeaf103a1c2ba58b709b4ba72b551 --- heat/engine/properties.py | 333 +++++++++++++++++++++++++++++++ heat/tests/test_properties.py | 361 ++++++++++++++++++++++++++++++++++ 2 files changed, 694 insertions(+) diff --git a/heat/engine/properties.py b/heat/engine/properties.py index efad495b..a1564132 100644 --- a/heat/engine/properties.py +++ b/heat/engine/properties.py @@ -40,6 +40,339 @@ SCHEMA_TYPES = ( ) +class InvalidPropertySchemaError(Exception): + pass + + +class Schema(collections.Mapping): + """ + A Schema for a resource Property. + + Schema objects are serialisable to dictionaries following a superset of + the HOT input Parameter schema using dict(). + + Serialises to JSON in the form: + { + 'type': 'list', + 'required': False + 'constraints': [ + { + 'length': {'min': 1}, + 'description': 'List must not be empty' + } + ], + 'schema': { + '*': { + 'type': 'string' + } + }, + 'description': 'An example list property.' + } + """ + + KEYS = ( + TYPE, DESCRIPTION, DEFAULT, SCHEMA, REQUIRED, CONSTRAINTS, + ) = ( + 'type', 'description', 'default', 'schema', 'required', 'constraints', + ) + + def __init__(self, data_type, description=None, + default=None, schema=None, + required=False, constraints=[], + implemented=True): + self._len = None + self.type = data_type + if self.type not in SCHEMA_TYPES: + raise InvalidPropertySchemaError('Invalid type (%s)' % self.type) + + self.description = description + self.required = required + self.implemented = implemented + + if isinstance(schema, type(self)): + if self.type != LIST: + msg = 'Single schema valid only for %s, not %s' % (LIST, + self.type) + raise InvalidPropertySchemaError(msg) + + self.schema = AnyIndexDict(schema) + else: + self.schema = schema + if self.schema is not None and self.type not in (LIST, MAP): + msg = 'Schema valid only for %s or %s, not %s' % (LIST, MAP, + self.type) + raise InvalidPropertySchemaError(msg) + + self.constraints = constraints + for c in constraints: + if self.type not in c.valid_types: + err_msg = '%s constraint invalid for %s' % (type(c).__name__, + self.type) + raise InvalidPropertySchemaError(err_msg) + + self.default = default + + @classmethod + def from_legacy(cls, schema_dict): + """ + Return a new Schema object from a legacy schema dictionary. + """ + + # Check for fully-fledged Schema objects + if isinstance(schema_dict, cls): + return schema_dict + + unknown = [k for k in schema_dict if k not in SCHEMA_KEYS] + if unknown: + raise InvalidPropertySchemaError('Unknown key(s) %s' % unknown) + + def constraints(): + def get_num(key): + val = schema_dict.get(key) + if val is not None: + val = Property.str_to_num(val) + return val + + if MIN_VALUE in schema_dict or MAX_VALUE in schema_dict: + yield Range(get_num(MIN_VALUE), + get_num(MAX_VALUE)) + if MIN_LENGTH in schema_dict or MAX_LENGTH in schema_dict: + yield Length(get_num(MIN_LENGTH), + get_num(MAX_LENGTH)) + if ALLOWED_VALUES in schema_dict: + yield AllowedValues(schema_dict[ALLOWED_VALUES]) + if ALLOWED_PATTERN in schema_dict: + yield AllowedPattern(schema_dict[ALLOWED_PATTERN]) + + try: + data_type = schema_dict[TYPE] + except KeyError: + raise InvalidPropertySchemaError('No %s specified' % TYPE) + + if SCHEMA in schema_dict: + if data_type == LIST: + ss = cls.from_legacy(schema_dict[SCHEMA]) + elif data_type == MAP: + schema_dicts = schema_dict[SCHEMA].items() + ss = dict((n, cls.from_legacy(sd)) for n, sd in schema_dicts) + else: + raise InvalidPropertySchemaError('%s supplied for %s %s' % + (SCHEMA, TYPE, data_type)) + else: + ss = None + + return cls(data_type, + default=schema_dict.get(DEFAULT), + schema=ss, + required=schema_dict.get(REQUIRED, False), + constraints=list(constraints()), + implemented=schema_dict.get(IMPLEMENTED, True)) + + def __getitem__(self, key): + if key == self.TYPE: + return self.type.lower() + elif key == self.DESCRIPTION: + if self.description is not None: + return self.description + elif key == self.DEFAULT: + if self.default is not None: + return self.default + elif key == self.SCHEMA: + if self.schema is not None: + return dict((n, dict(s)) for n, s in self.schema.items()) + elif key == self.REQUIRED: + return self.required + elif key == self.CONSTRAINTS: + if self.constraints: + return [dict(c) for c in self.constraints] + + raise KeyError(key) + + def __iter__(self): + for k in self.KEYS: + try: + self[k] + except KeyError: + pass + else: + yield k + + def __len__(self): + if self._len is None: + self._len = len(list(iter(self))) + return self._len + + +class AnyIndexDict(collections.Mapping): + """ + A Mapping that returns the same value for any integer index. + + Used for storing the schema for a list. When converted to a dictionary, + it contains a single item with the key '*'. + """ + + ANYTHING = '*' + + def __init__(self, value): + self.value = value + + def __getitem__(self, key): + if key != self.ANYTHING and not isinstance(key, (int, long)): + raise KeyError('Invalid key %s' % str(key)) + + return self.value + + def __iter__(self): + yield self.ANYTHING + + def __len__(self): + return 1 + + +class Constraint(collections.Mapping): + """ + Parent class for constraints on allowable values for a Property. + + Constraints are serialisable to dictionaries following the HOT input + Parameter constraints schema using dict(). + """ + + (DESCRIPTION,) = ('description',) + + def __init__(self, description=None): + self.description = description + + @classmethod + def _name(cls): + return '_'.join(w.lower() for w in re.findall('[A-Z]?[a-z]+', + cls.__name__)) + + def __getitem__(self, key): + if key == self.DESCRIPTION: + if self.description is None: + raise KeyError(key) + return self.description + + if key == self._name(): + return self._constraint() + + raise KeyError(key) + + def __iter__(self): + if self.description is not None: + yield self.DESCRIPTION + + yield self._name() + + def __len__(self): + return 2 if self.description is not None else 1 + + +class Range(Constraint): + """ + Constrain values within a range. + + Serialises to JSON as: + + { + 'range': {'min': , 'max': }, + 'description': + } + """ + + (MIN, MAX) = ('min', 'max') + + valid_types = (INTEGER, NUMBER) + + def __init__(self, min=None, max=None, description=None): + super(Range, self).__init__(description) + self.min = min + self.max = max + + for param in (min, max): + if not isinstance(param, (float, int, long, type(None))): + raise InvalidPropertySchemaError('min/max must be numeric') + + def _constraint(self): + def constraints(): + if self.min is not None: + yield self.MIN, self.min + if self.max is not None: + yield self.MAX, self.max + + return dict(constraints()) + + +class Length(Range): + """ + Constrain the length of values within a range. + + Serialises to JSON as: + + { + 'length': {'min': , 'max': }, + 'description': + } + """ + + valid_types = (STRING, LIST) + + def __init__(self, min=None, max=None, description=None): + super(Length, self).__init__(min, max, description) + + for param in (min, max): + if not isinstance(param, (int, long, type(None))): + msg = 'min/max length must be integral' + raise InvalidPropertySchemaError(msg) + + +class AllowedValues(Constraint): + """ + Constrain values to a predefined set. + + Serialises to JSON as: + + { + 'allowed_values': [, , ...], + 'description': + } + """ + + valid_types = (STRING, INTEGER, NUMBER, BOOLEAN) + + def __init__(self, allowed, description=None): + super(AllowedValues, self).__init__(description) + if (not isinstance(allowed, collections.Sequence) or + isinstance(allowed, basestring)): + raise InvalidPropertySchemaError('AllowedValues must be a list') + self.allowed = tuple(allowed) + + def _constraint(self): + return list(self.allowed) + + +class AllowedPattern(Constraint): + """ + Constrain values to a predefined regular expression pattern. + + Serialises to JSON as: + + { + 'allowed_pattern': , + 'description': + } + """ + + valid_types = (STRING,) + + def __init__(self, pattern, description=None): + super(AllowedPattern, self).__init__(description) + self.pattern = pattern + + def _constraint(self): + return self.pattern + + class Property(object): __param_type_map = { diff --git a/heat/tests/test_properties.py b/heat/tests/test_properties.py index c4788081..542635a8 100644 --- a/heat/tests/test_properties.py +++ b/heat/tests/test_properties.py @@ -16,9 +16,370 @@ import testtools from heat.engine import properties +from heat.engine import resource from heat.common import exception +class SchemaTest(testtools.TestCase): + def test_range_schema(self): + d = {'range': {'min': 5, 'max': 10}, 'description': 'a range'} + r = properties.Range(5, 10, description='a range') + self.assertEqual(d, dict(r)) + + def test_range_min_schema(self): + d = {'range': {'min': 5}, 'description': 'a range'} + r = properties.Range(min=5, description='a range') + self.assertEqual(d, dict(r)) + + def test_range_max_schema(self): + d = {'range': {'max': 10}, 'description': 'a range'} + r = properties.Range(max=10, description='a range') + self.assertEqual(d, dict(r)) + + def test_length_schema(self): + d = {'length': {'min': 5, 'max': 10}, 'description': 'a length range'} + r = properties.Length(5, 10, description='a length range') + self.assertEqual(d, dict(r)) + + def test_length_min_schema(self): + d = {'length': {'min': 5}, 'description': 'a length range'} + r = properties.Length(min=5, description='a length range') + self.assertEqual(d, dict(r)) + + def test_length_max_schema(self): + d = {'length': {'max': 10}, 'description': 'a length range'} + r = properties.Length(max=10, description='a length range') + self.assertEqual(d, dict(r)) + + def test_allowed_values_schema(self): + d = {'allowed_values': ['foo', 'bar'], 'description': 'allowed values'} + r = properties.AllowedValues(['foo', 'bar'], + description='allowed values') + self.assertEqual(d, dict(r)) + + def test_allowed_pattern_schema(self): + d = {'allowed_pattern': '[A-Za-z0-9]', 'description': 'alphanumeric'} + r = properties.AllowedPattern('[A-Za-z0-9]', + description='alphanumeric') + self.assertEqual(d, dict(r)) + + def test_schema_all(self): + d = { + 'type': 'string', + 'description': 'A string', + 'default': 'wibble', + 'required': True, + 'constraints': [ + {'length': {'min': 4, 'max': 8}}, + ] + } + s = properties.Schema(properties.STRING, 'A string', + default='wibble', required=True, + constraints=[properties.Length(4, 8)]) + self.assertEqual(d, dict(s)) + + def test_schema_list_schema(self): + d = { + 'type': 'list', + 'description': 'A list', + 'schema': { + '*': { + 'type': 'string', + 'description': 'A string', + 'default': 'wibble', + 'required': True, + 'constraints': [ + {'length': {'min': 4, 'max': 8}}, + ] + } + }, + 'required': False, + } + s = properties.Schema(properties.STRING, 'A string', + default='wibble', required=True, + constraints=[properties.Length(4, 8)]) + l = properties.Schema(properties.LIST, 'A list', + schema=s) + self.assertEqual(d, dict(l)) + + def test_schema_map_schema(self): + d = { + 'type': 'map', + 'description': 'A map', + 'schema': { + 'Foo': { + 'type': 'string', + 'description': 'A string', + 'default': 'wibble', + 'required': True, + 'constraints': [ + {'length': {'min': 4, 'max': 8}}, + ] + } + }, + 'required': False, + } + s = properties.Schema(properties.STRING, 'A string', + default='wibble', required=True, + constraints=[properties.Length(4, 8)]) + m = properties.Schema(properties.MAP, 'A map', + schema={'Foo': s}) + self.assertEqual(d, dict(m)) + + def test_schema_nested_schema(self): + d = { + 'type': 'list', + 'description': 'A list', + 'schema': { + '*': { + 'type': 'map', + 'description': 'A map', + 'schema': { + 'Foo': { + 'type': 'string', + 'description': 'A string', + 'default': 'wibble', + 'required': True, + 'constraints': [ + {'length': {'min': 4, 'max': 8}}, + ] + } + }, + 'required': False, + } + }, + 'required': False, + } + s = properties.Schema(properties.STRING, 'A string', + default='wibble', required=True, + constraints=[properties.Length(4, 8)]) + m = properties.Schema(properties.MAP, 'A map', + schema={'Foo': s}) + l = properties.Schema(properties.LIST, 'A list', + schema=m) + self.assertEqual(d, dict(l)) + + def test_all_resource_schemata(self): + for resource_type in resource._resource_classes.itervalues(): + for schema in getattr(resource_type, + 'properties_schema', + {}).itervalues(): + properties.Schema.from_legacy(schema) + + def test_invalid_type(self): + self.assertRaises(properties.InvalidPropertySchemaError, + properties.Schema, + 'Fish') + + def test_schema_invalid_type(self): + self.assertRaises(properties.InvalidPropertySchemaError, + properties.Schema, + 'String', + schema=properties.Schema('String')) + + def test_range_invalid_type(self): + self.assertRaises(properties.InvalidPropertySchemaError, + properties.Schema, + 'String', + constraints=[properties.Range(1, 10)]) + + def test_length_invalid_type(self): + self.assertRaises(properties.InvalidPropertySchemaError, + properties.Schema, + 'Integer', + constraints=[properties.Length(1, 10)]) + + def test_allowed_pattern_invalid_type(self): + self.assertRaises(properties.InvalidPropertySchemaError, + properties.Schema, + 'Integer', + constraints=[properties.AllowedPattern('[0-9]*')]) + + def test_range_vals_invalid_type(self): + self.assertRaises(properties.InvalidPropertySchemaError, + properties.Range, '1', 10) + self.assertRaises(properties.InvalidPropertySchemaError, + properties.Range, 1, '10') + + def test_length_vals_invalid_type(self): + self.assertRaises(properties.InvalidPropertySchemaError, + properties.Length, '1', 10) + self.assertRaises(properties.InvalidPropertySchemaError, + properties.Length, 1, '10') + + def test_from_legacy_idempotency(self): + s = properties.Schema(properties.STRING) + self.assertTrue(properties.Schema.from_legacy(s) is s) + + def test_from_legacy_string(self): + s = properties.Schema.from_legacy({ + 'Type': 'String', + 'Default': 'wibble', + 'Required': True, + 'Implemented': False, + 'MinLength': 4, + 'MaxLength': 8, + 'AllowedValues': ['blarg', 'wibble'], + 'AllowedPattern': '[a-z]*', + }) + self.assertEqual(properties.STRING, s.type) + self.assertEqual(None, s.description) + self.assertEqual('wibble', s.default) + self.assertTrue(s.required) + self.assertEqual(3, len(s.constraints)) + self.assertFalse(s.implemented) + + def test_from_legacy_min_length(self): + s = properties.Schema.from_legacy({ + 'Type': 'String', + 'MinLength': 4, + }) + self.assertEqual(1, len(s.constraints)) + c = s.constraints[0] + self.assertEqual(properties.Length, type(c)) + self.assertEqual(4, c.min) + self.assertEqual(None, c.max) + + def test_from_legacy_max_length(self): + s = properties.Schema.from_legacy({ + 'Type': 'String', + 'MaxLength': 8, + }) + self.assertEqual(1, len(s.constraints)) + c = s.constraints[0] + self.assertEqual(properties.Length, type(c)) + self.assertEqual(None, c.min) + self.assertEqual(8, c.max) + + def test_from_legacy_minmax_length(self): + s = properties.Schema.from_legacy({ + 'Type': 'String', + 'MinLength': 4, + 'MaxLength': 8, + }) + self.assertEqual(1, len(s.constraints)) + c = s.constraints[0] + self.assertEqual(properties.Length, type(c)) + self.assertEqual(4, c.min) + self.assertEqual(8, c.max) + + def test_from_legacy_minmax_string_length(self): + s = properties.Schema.from_legacy({ + 'Type': 'String', + 'MinLength': '4', + 'MaxLength': '8', + }) + self.assertEqual(1, len(s.constraints)) + c = s.constraints[0] + self.assertEqual(properties.Length, type(c)) + self.assertEqual(4, c.min) + self.assertEqual(8, c.max) + + def test_from_legacy_min_value(self): + s = properties.Schema.from_legacy({ + 'Type': 'Integer', + 'MinValue': 4, + }) + self.assertEqual(1, len(s.constraints)) + c = s.constraints[0] + self.assertEqual(properties.Range, type(c)) + self.assertEqual(4, c.min) + self.assertEqual(None, c.max) + + def test_from_legacy_max_value(self): + s = properties.Schema.from_legacy({ + 'Type': 'Integer', + 'MaxValue': 8, + }) + self.assertEqual(1, len(s.constraints)) + c = s.constraints[0] + self.assertEqual(properties.Range, type(c)) + self.assertEqual(None, c.min) + self.assertEqual(8, c.max) + + def test_from_legacy_minmax_value(self): + s = properties.Schema.from_legacy({ + 'Type': 'Integer', + 'MinValue': 4, + 'MaxValue': 8, + }) + self.assertEqual(1, len(s.constraints)) + c = s.constraints[0] + self.assertEqual(properties.Range, type(c)) + self.assertEqual(4, c.min) + self.assertEqual(8, c.max) + + def test_from_legacy_minmax_string_value(self): + s = properties.Schema.from_legacy({ + 'Type': 'Integer', + 'MinValue': '4', + 'MaxValue': '8', + }) + self.assertEqual(1, len(s.constraints)) + c = s.constraints[0] + self.assertEqual(properties.Range, type(c)) + self.assertEqual(4, c.min) + self.assertEqual(8, c.max) + + def test_from_legacy_allowed_values(self): + s = properties.Schema.from_legacy({ + 'Type': 'String', + 'AllowedValues': ['blarg', 'wibble'], + }) + self.assertEqual(1, len(s.constraints)) + c = s.constraints[0] + self.assertEqual(properties.AllowedValues, type(c)) + self.assertEqual(('blarg', 'wibble'), c.allowed) + + def test_from_legacy_allowed_pattern(self): + s = properties.Schema.from_legacy({ + 'Type': 'String', + 'AllowedPattern': '[a-z]*', + }) + self.assertEqual(1, len(s.constraints)) + c = s.constraints[0] + self.assertEqual(properties.AllowedPattern, type(c)) + self.assertEqual('[a-z]*', c.pattern) + + def test_from_legacy_list(self): + l = properties.Schema.from_legacy({ + 'Type': 'List', + 'Default': ['wibble'], + 'Schema': { + 'Type': 'String', + 'Default': 'wibble', + 'MaxLength': 8, + } + }) + self.assertEqual(properties.LIST, l.type) + self.assertEqual(['wibble'], l.default) + + ss = l.schema[0] + self.assertEqual(properties.STRING, ss.type) + self.assertEqual('wibble', ss.default) + + def test_from_legacy_map(self): + l = properties.Schema.from_legacy({ + 'Type': 'Map', + 'Schema': { + 'foo': { + 'Type': 'String', + 'Default': 'wibble', + } + } + }) + self.assertEqual(properties.MAP, l.type) + + ss = l.schema['foo'] + self.assertEqual(properties.STRING, ss.type) + self.assertEqual('wibble', ss.default) + + def test_from_legacy_invalid_key(self): + self.assertRaises(properties.InvalidPropertySchemaError, + properties.Schema.from_legacy, + {'Type': 'String', 'Foo': 'Bar'}) + + class PropertyTest(testtools.TestCase): def test_required_default(self): p = properties.Property({'Type': 'String'}) -- 2.45.2