From 0c3ebfb3194ecb81f57865076c793f1276409010 Mon Sep 17 00:00:00 2001 From: Dmitry Tantsur Date: Mon, 15 May 2017 13:37:35 +0200 Subject: [PATCH] Declarative approach to parsing JSON fields Replaces explicit fields parsing and validation with sqlalchemy-alike class-level declarations. This allows uniform handling of fields in different resources, especially wrt validation. Change-Id: Ia1f2fbb6d2358831bafc7386e4a1cecd235de892 --- sushy/exceptions.py | 5 + sushy/resources/base.py | 183 ++++++++++++++++-- sushy/resources/system/processor.py | 35 ++-- sushy/resources/system/system.py | 176 +++++++---------- .../unit/resources/system/test_system.py | 96 ++++----- sushy/tests/unit/resources/test_base.py | 113 +++++++++++ 6 files changed, 406 insertions(+), 202 deletions(-) diff --git a/sushy/exceptions.py b/sushy/exceptions.py index c3391f34..db07cac5 100644 --- a/sushy/exceptions.py +++ b/sushy/exceptions.py @@ -40,6 +40,11 @@ class MissingAttributeError(SushyError): 'resource %(resource)s') +class MalformedAttributeError(SushyError): + message = ('The attribute %(attribute)s is malformed in the ' + 'resource %(resource)s: %(error)s') + + class MissingActionError(SushyError): message = ('The action %(action)s is missing from the ' 'resource %(resource)s') diff --git a/sushy/resources/base.py b/sushy/resources/base.py index 32c524b9..a6083ba4 100644 --- a/sushy/resources/base.py +++ b/sushy/resources/base.py @@ -14,16 +14,169 @@ # under the License. import abc +import collections +import copy import logging import six +from sushy import exceptions from sushy import utils LOG = logging.getLogger(__name__) +class Field(object): + """Definition for fields fetched from JSON.""" + + def __init__(self, path, required=False, default=None, + adapter=lambda x: x): + """Create a field definition. + + :param path: JSON field to fetch the value from. Either a string, + or a list of strings in case of a nested field. + :param required: whether this field is required. Missing required + fields result in MissingAttributeError. + :param default: the default value to use when the field is missing. + Only has effect when the field is not required. + :param adapter: a function to call to transform and/or validate + the received value. UnicodeError, ValueError or TypeError from + this call are reraised as MalformedAttributeError. + """ + if not callable(adapter): + raise TypeError("Adapter must be callable") + + if isinstance(path, six.string_types): + path = [path] + elif not path: + raise ValueError('Path cannot be empty') + + self._path = path + self._required = required + self._default = default + self._adapter = adapter + + def _load(self, body, resource, nested_in=None): + """Load this field from a JSON object. + + :param body: parsed JSON body. + :param resource: ResourceBase instance for which the field is loaded. + :param nested_in: parent resource path (for error reporting only), + must be a list of strings or None. + :raises: MissingAttributeError if a required field is missing. + :raises: MalformedAttributeError on invalid field value or type. + :returns: loaded and verified value + """ + name = self._path[-1] + for path_item in self._path[:-1]: + body = body.get(path_item, {}) + + if name not in body: + if self._required: + path = (nested_in or []) + self._path + raise exceptions.MissingAttributeError( + attribute='/'.join(path), + resource=resource.path) + else: + # Do not run the adapter on the default value + return self._default + + try: + value = self._adapter(body[name]) + except (UnicodeError, ValueError, TypeError) as exc: + path = (nested_in or []) + self._path + raise exceptions.MalformedAttributeError( + attribute='/'.join(path), + resource=resource.path, + error=exc) + + return value + + +def _collect_fields(resource): + """Collect fields from the JSON. + + :param resource: ResourceBase or CompositeField instance. + :returns: generator of tuples (key, field) + """ + for attr in dir(resource.__class__): + field = getattr(resource.__class__, attr) + if isinstance(field, Field): + yield (attr, field) + + +@six.add_metaclass(abc.ABCMeta) +class CompositeField(collections.Mapping, Field): + """Base class for fields consisting of several sub-fields.""" + + def __init__(self, *args, **kwargs): + super(CompositeField, self).__init__(*args, **kwargs) + self._subfields = dict(_collect_fields(self)) + + def _load(self, body, resource, nested_in=None): + """Load the composite field. + + :param body: parent JSON body. + :param resource: parent resource. + :param nested_in: parent resource name (for error reporting only). + :returns: a new object with sub-fields attached to it. + """ + nested_in = (nested_in or []) + self._path + value = super(CompositeField, self)._load(body, resource) + if value is None: + return None + + # We need a new instance, as this method is called a singleton instance + # that is attached to a class (not instance) of a resource or another + # CompositeField. We don't want to end up modifying this instance. + instance = copy.copy(self) + for attr, field in self._subfields.items(): + # Hide the Field object behind the real value + setattr(instance, attr, field._load(value, resource, nested_in)) + + return instance + + # Satisfy the mapping interface, see + # https://docs.python.org/2/library/collections.html#collections.Mapping. + + def __getitem__(self, key): + if key in self._subfields: + return getattr(self, key) + else: + raise KeyError(key) + + def __len__(self): + return len(self._subfields) + + def __iter__(self): + return iter(self._subfields) + + +class MappedField(Field): + """Field taking real value from a mapping.""" + + def __init__(self, field, mapping, required=False, default=None): + """Create a mapped field definition. + + :param field: JSON field to fetch the value from. This can be either + a string or a list of string. In the latter case, the value will + be fetched from a nested object. + :param mapping: a mapping to take values from. + :param required: whether this field is required. Missing required + fields result in MissingAttributeError. + :param default: the default value to use when the field is missing. + Only has effect when the field is not required. This value is not + matched against the mapping. + """ + if not isinstance(mapping, collections.Mapping): + raise TypeError("The mapping argument must be a mapping") + + super(MappedField, self).__init__( + field, required=required, default=default, + adapter=mapping.get) + + @six.add_metaclass(abc.ABCMeta) class ResourceBase(object): @@ -46,6 +199,12 @@ class ResourceBase(object): self.redfish_version = redfish_version self.refresh() + def _parse_attributes(self): + """Parse the attributes of a resource.""" + for attr, field in _collect_fields(self): + # Hide the Field object behind the real value + setattr(self, attr, field._load(self.json, self)) + def refresh(self): """Refresh the resource @@ -69,22 +228,15 @@ class ResourceBase(object): def path(self): return self._path - @abc.abstractmethod - def _parse_attributes(self): - """Parse the attributes of a resource - - This method should be overwritten and is responsible for parsing - all the attributes of a resource. - """ - @six.add_metaclass(abc.ABCMeta) class ResourceCollectionBase(ResourceBase): - name = None + name = Field('Name') """The name of the collection""" - members_identities = None + members_identities = Field('Members', default=[], + adapter=utils.get_members_identities) """A tuple with the members identities""" def __init__(self, connector, path, redfish_version=None): @@ -99,6 +251,9 @@ class ResourceCollectionBase(ResourceBase): """ super(ResourceCollectionBase, self).__init__(connector, path, redfish_version) + LOG.debug('Received %(count)d member(s) for %(type)s %(path)s', + {'count': len(self.members_identities), + 'type': self.__class__.__name__, 'path': self._path}) @property @abc.abstractmethod @@ -109,14 +264,6 @@ class ResourceCollectionBase(ResourceBase): collection contains. """ - def _parse_attributes(self): - self.name = self.json.get('Name') - self.members_identities = ( - utils.get_members_identities(self.json.get('Members', []))) - LOG.debug('Received %(count)d member(s) for %(type)s %(path)s', - {'count': len(self.members_identities), - 'type': self.__class__.__name__, 'path': self._path}) - def get_member(self, identity): """Given the identity return a ``_resource_type`` object diff --git a/sushy/resources/system/processor.py b/sushy/resources/system/processor.py index 3170285d..36c3b1b4 100644 --- a/sushy/resources/system/processor.py +++ b/sushy/resources/system/processor.py @@ -26,36 +26,37 @@ LOG = logging.getLogger(__name__) class Processor(base.ResourceBase): - identity = None + identity = base.Field('Id', required=True) """The processor identity string""" - socket = None + socket = base.Field('Socket') """The socket or location of the processor""" # TODO(deray): Create mappings for the processor_type - processor_type = None + processor_type = base.Field('ProcessorType') """The type of processor""" - processor_architecture = None + processor_architecture = base.MappedField( + 'ProcessorArchitecture', sys_maps.PROCESSOR_ARCH_VALUE_MAP) """The architecture of the processor""" # TODO(deray): Create mappings for the instruction_set - instruction_set = None + instruction_set = base.Field('InstructionSet') """The instruction set of the processor""" - manufacturer = None + manufacturer = base.Field('Manufacturer') """The processor manufacturer""" - model = None + model = base.Field('Model') """The product model number of this device""" - max_speed_mhz = None + max_speed_mhz = base.Field('MaxSpeedMHz', adapter=int) """The maximum clock speed of the processor in MHz.""" - total_cores = None + total_cores = base.Field('TotalCores', adapter=int) """The total number of cores contained in this processor""" - total_threads = None + total_threads = base.Field('TotalThreads', adapter=int) """The total number of execution threads supported by this processor""" def __init__(self, connector, identity, redfish_version=None): @@ -68,20 +69,6 @@ class Processor(base.ResourceBase): """ super(Processor, self).__init__(connector, identity, redfish_version) - def _parse_attributes(self): - self.identity = self.json.get('Id') - self.socket = self.json.get('Socket') - self.processor_type = self.json.get('ProcessorType') - self.processor_architecture = ( - sys_maps.PROCESSOR_ARCH_VALUE_MAP.get( - self.json.get('ProcessorArchitecture'))) - self.instruction_set = self.json.get('InstructionSet') - self.manufacturer = self.json.get('Manufacturer') - self.model = self.json.get('Model') - self.max_speed_mhz = self.json.get('MaxSpeedMHz') - self.total_cores = self.json.get('TotalCores') - self.total_threads = self.json.get('TotalThreads') - class ProcessorCollection(base.ResourceCollectionBase): diff --git a/sushy/resources/system/system.py b/sushy/resources/system/system.py index ed670623..038586ba 100644 --- a/sushy/resources/system/system.py +++ b/sushy/resources/system/system.py @@ -13,7 +13,6 @@ # License for the specific language governing permissions and limitations # under the License. -import collections import logging from sushy import exceptions @@ -22,74 +21,108 @@ from sushy.resources.system import constants as sys_cons from sushy.resources.system import mappings as sys_maps from sushy.resources.system import processor -# Representation of Memory information summary -MemorySummary = collections.namedtuple('MemorySummary', - ['size_gib', 'health']) + LOG = logging.getLogger(__name__) +class ResetActionField(base.CompositeField): + allowed_values = base.Field('ResetType@Redfish.AllowableValues', + adapter=list) + + target_uri = base.Field('target', required=True) + + +class ActionsField(base.CompositeField): + reset = ResetActionField('#ComputerSystem.Reset') + + +class BootField(base.CompositeField): + allowed_values = base.Field( + 'BootSourceOverrideTarget@Redfish.AllowableValues', + adapter=list) + + enabled = base.MappedField('BootSourceOverrideEnabled', + sys_maps.BOOT_SOURCE_ENABLED_MAP) + + mode = base.MappedField('BootSourceOverrideMode', + sys_maps.BOOT_SOURCE_MODE_MAP) + + target = base.MappedField('BootSourceOverrideTarget', + sys_maps.BOOT_SOURCE_TARGET_MAP) + + +class MemorySummaryField(base.CompositeField): + health = base.Field(['Status', 'HealthRollup']) + """The overall health state of memory. + + This signifies health state of memory along with its dependent resources. + """ + + size_gib = base.Field('TotalSystemMemoryGiB', adapter=int) + """The size of memory of the system in GiB. + + This signifies the total installed, operating system-accessible memory + (RAM), measured in GiB. + """ + + class System(base.ResourceBase): - asset_tag = None + asset_tag = base.Field('AssetTag') """The system asset tag""" - bios_version = None + bios_version = base.Field('BiosVersion') """The system BIOS version""" - boot = None + boot = BootField('Boot', required=True) """A dictionary containg the current boot device, frequency and mode""" - description = None + description = base.Field('Description') """The system description""" - hostname = None + hostname = base.Field('HostName') """The system hostname""" - identity = None + identity = base.Field('Id', required=True) """The system identity string""" # TODO(lucasagomes): Create mappings for the indicator_led - indicator_led = None + indicator_led = base.Field('IndicatorLED') """Whether the indicator LED is lit or off""" - manufacturer = None + manufacturer = base.Field('Manufacturer') """The system manufacturer""" - name = None + name = base.Field('Name') """The system name""" - part_number = None + part_number = base.Field('PartNumber') """The system part number""" - power_state = None + power_state = base.MappedField('PowerState', + sys_maps.SYSTEM_POWER_STATE_MAP) """The system power state""" - serial_number = None + serial_number = base.Field('SerialNumber') """The system serial number""" - sku = None + sku = base.Field('SKU') """The system stock-keeping unit""" # TODO(lucasagomes): Create mappings for the system_type - system_type = None + system_type = base.Field('SystemType') """The system type""" - uuid = None + uuid = base.Field('UUID') """The system UUID""" - memory_summary = None - """The summary info of memory of the system in general detail - - It is a namedtuple containing the following: - size_gib: The size of memory of the system in GiB. This signifies - the total installed, operating system-accessible memory (RAM), - measured in GiB. - health: The overall health state of memory. This signifies - health state of memory along with its dependent resources. - """ + memory_summary = MemorySummaryField('MemorySummary') + """The summary info of memory of the system in general detail""" _processors = None # ref to ProcessorCollection instance + _actions = ActionsField('Actions', required=True) + def __init__(self, connector, identity, redfish_version=None): """A class representing a ComputerSystem @@ -100,56 +133,9 @@ class System(base.ResourceBase): """ super(System, self).__init__(connector, identity, redfish_version) - def _parse_attributes(self): - self.asset_tag = self.json.get('AssetTag') - self.bios_version = self.json.get('BiosVersion') - self.description = self.json.get('Description') - self.hostname = self.json.get('HostName') - self.identity = self.json.get('Id') - self.indicator_led = self.json.get('IndicatorLED') - self.manufacturer = self.json.get('Manufacturer') - self.name = self.json.get('Name') - self.part_number = self.json.get('PartNumber') - self.serial_number = self.json.get('SerialNumber') - self.sku = self.json.get('SKU') - self.system_type = self.json.get('SystemType') - self.uuid = self.json.get('UUID') - self.power_state = sys_maps.SYSTEM_POWER_STATE_MAP.get( - self.json.get('PowerState')) - - # Parse the boot attribute - self.boot = {} - boot_attr = self.json.get('Boot') - if boot_attr is not None: - self.boot['target'] = sys_maps.BOOT_SOURCE_TARGET_MAP.get( - boot_attr.get('BootSourceOverrideTarget')) - self.boot['enabled'] = sys_maps.BOOT_SOURCE_ENABLED_MAP.get( - boot_attr.get('BootSourceOverrideEnabled')) - self.boot['mode'] = sys_maps.BOOT_SOURCE_MODE_MAP.get( - boot_attr.get('BootSourceOverrideMode')) - - # Parse memory_summary attribute - self.memory_summary = None - memory_summary_attr = self.json.get('MemorySummary') - if memory_summary_attr is not None: - memory_size_gib = memory_summary_attr.get('TotalSystemMemoryGiB') - try: - memory_health = memory_summary_attr['Status']['HealthRollup'] - except KeyError: - memory_health = None - self.memory_summary = MemorySummary(size_gib=memory_size_gib, - health=memory_health) - - # Reset processor related attributes - self._processors = None - def _get_reset_action_element(self): - actions = self.json.get('Actions') - if not actions: - raise exceptions.MissingAttributeError(attribute='Actions', - resource=self._path) - - reset_action = actions.get('#ComputerSystem.Reset') + reset_action = self._actions.reset + # TODO(dtantsur): make this check also declarative? if not reset_action: raise exceptions.MissingActionError(action='#ComputerSystem.Reset', resource=self._path) @@ -162,26 +148,14 @@ class System(base.ResourceBase): """ reset_action = self._get_reset_action_element() - allowed_values = reset_action.get('ResetType@Redfish.AllowableValues') - if not allowed_values: + if not reset_action.allowed_values: LOG.warning('Could not figure out the allowed values for the ' 'reset system action for System %s', self.identity) return set(sys_maps.RESET_SYSTEM_VALUE_MAP_REV) return set([sys_maps.RESET_SYSTEM_VALUE_MAP[v] for v in set(sys_maps.RESET_SYSTEM_VALUE_MAP). - intersection(allowed_values)]) - - def _get_reset_system_path(self): - reset_action = self._get_reset_action_element() - - target_uri = reset_action.get('target') - if not target_uri: - raise exceptions.MissingAttributeError( - attribute='Actions/ComputerSystem.Reset/target', - resource=self._path) - - return target_uri + intersection(reset_action.allowed_values)]) def reset_system(self, value): """Reset the system. @@ -196,7 +170,7 @@ class System(base.ResourceBase): parameter='value', value=value, valid_values=valid_resets) value = sys_maps.RESET_SYSTEM_VALUE_MAP_REV[value] - target_uri = self._get_reset_system_path() + target_uri = self._get_reset_action_element().target_uri # TODO(lucasagomes): Check the return code and response body ? # Probably we should call refresh() as well. @@ -207,15 +181,7 @@ class System(base.ResourceBase): :returns: A set with the allowed values. """ - boot = self.json.get('Boot') - if not boot: - raise exceptions.MissingAttributeError(attribute='Boot', - resource=self._path) - - allowed_values = boot.get( - 'BootSourceOverrideTarget@Redfish.AllowableValues') - - if not allowed_values: + if not self.boot.allowed_values: LOG.warning('Could not figure out the allowed values for ' 'configuring the boot source for System %s', self.identity) @@ -223,7 +189,7 @@ class System(base.ResourceBase): return set([sys_maps.BOOT_SOURCE_TARGET_MAP[v] for v in set(sys_maps.BOOT_SOURCE_TARGET_MAP). - intersection(allowed_values)]) + intersection(self.boot.allowed_values)]) def set_system_boot_source(self, target, enabled=sys_cons.BOOT_SOURCE_ENABLED_ONCE, @@ -300,6 +266,10 @@ class System(base.ResourceBase): return self._processors + def refresh(self): + super(System, self).refresh() + self._processors = None + class SystemCollection(base.ResourceCollectionBase): diff --git a/sushy/tests/unit/resources/system/test_system.py b/sushy/tests/unit/resources/system/test_system.py index 5bba2725..2bf879e0 100644 --- a/sushy/tests/unit/resources/system/test_system.py +++ b/sushy/tests/unit/resources/system/test_system.py @@ -55,37 +55,49 @@ class SystemTestCase(base.TestCase): self.sys_inst.uuid) self.assertEqual(sushy.SYSTEM_POWER_STATE_ON, self.sys_inst.power_state) - self.assertEqual((96, "OK"), - self.sys_inst.memory_summary) + self.assertEqual(96, self.sys_inst.memory_summary.size_gib) + self.assertEqual("OK", self.sys_inst.memory_summary.health) self.assertIsNone(self.sys_inst._processors) + def test__parse_attributes_missing_actions(self): + self.sys_inst.json.pop('Actions') + self.assertRaisesRegex( + exceptions.MissingAttributeError, 'attribute Actions', + self.sys_inst._parse_attributes) + + def test__parse_attributes_missing_boot(self): + self.sys_inst.json.pop('Boot') + self.assertRaisesRegex( + exceptions.MissingAttributeError, 'attribute Boot', + self.sys_inst._parse_attributes) + + def test__parse_attributes_missing_reset_target(self): + self.sys_inst.json['Actions']['#ComputerSystem.Reset'].pop( + 'target') + self.assertRaisesRegex( + exceptions.MissingAttributeError, + 'attribute Actions/#ComputerSystem.Reset/target', + self.sys_inst._parse_attributes) + def test_get__reset_action_element(self): value = self.sys_inst._get_reset_action_element() - expected = { - "target": "/redfish/v1/Systems/437XR1138R2/Actions/" - "ComputerSystem.Reset", - "ResetType@Redfish.AllowableValues": [ - "On", - "ForceOff", - "GracefulShutdown", - "GracefulRestart", - "ForceRestart", - "Nmi", - "ForceOn" - ]} - self.assertEqual(expected, value) - - def test_get__reset_action_element_missing_actions_attr(self): - self.sys_inst._json.pop('Actions') - self.assertRaisesRegex( - exceptions.MissingAttributeError, 'attribute Actions', - self.sys_inst._get_reset_action_element) + self.assertEqual("/redfish/v1/Systems/437XR1138R2/Actions/" + "ComputerSystem.Reset", + value.target_uri) + self.assertEqual(["On", + "ForceOff", + "GracefulShutdown", + "GracefulRestart", + "ForceRestart", + "Nmi", + "ForceOn" + ], + value.allowed_values) def test_get__reset_action_element_missing_reset_action(self): - action = '#ComputerSystem.Reset' - self.sys_inst._json['Actions'].pop(action) + self.sys_inst._actions.reset = None self.assertRaisesRegex( - exceptions.MissingActionError, 'action %s' % action, + exceptions.MissingActionError, 'action #ComputerSystem.Reset', self.sys_inst._get_reset_action_element) def test_get_allowed_reset_system_values(self): @@ -101,11 +113,9 @@ class SystemTestCase(base.TestCase): self.assertIsInstance(values, set) @mock.patch.object(system.LOG, 'warning', autospec=True) - @mock.patch.object(system.System, '_get_reset_action_element', - autospec=True) def test_get_allowed_reset_system_values_no_values_specified( - self, mock_get_reset_action, mock_log): - mock_get_reset_action.return_value = {} + self, mock_log): + self.sys_inst._actions.reset.allowed_values = {} values = self.sys_inst.get_allowed_reset_system_values() # Assert it returns all values if it can't get the specific ones expected = set([sushy.RESET_GRACEFUL_SHUTDOWN, @@ -120,22 +130,6 @@ class SystemTestCase(base.TestCase): self.assertIsInstance(values, set) self.assertEqual(1, mock_log.call_count) - def test__get_reset_system_path(self): - value = self.sys_inst._get_reset_system_path() - expected = ( - '/redfish/v1/Systems/437XR1138R2/Actions/ComputerSystem.Reset') - self.assertEqual(expected, value) - - @mock.patch.object(system.System, '_get_reset_action_element', - autospec=True) - def test__get_reset_system_path_missing_target_attr( - self, mock_get_reset_action): - mock_get_reset_action.return_value = {} - self.assertRaisesRegex( - exceptions.MissingAttributeError, - 'attribute Actions/ComputerSystem.Reset/target', - self.sys_inst._get_reset_system_path) - def test_reset_system(self): self.sys_inst.reset_system(sushy.RESET_FORCE_OFF) self.sys_inst._conn.post.assert_called_once_with( @@ -161,17 +155,10 @@ class SystemTestCase(base.TestCase): self.assertEqual(expected, values) self.assertIsInstance(values, set) - def test_get_allowed_system_boot_source_values_missing_boot_attr(self): - self.sys_inst._json.pop('Boot') - self.assertRaisesRegex( - exceptions.MissingAttributeError, 'attribute Boot', - self.sys_inst.get_allowed_system_boot_source_values) - @mock.patch.object(system.LOG, 'warning', autospec=True) def test_get_allowed_system_boot_source_values_no_values_specified( self, mock_log): - self.sys_inst._json['Boot'].pop( - 'BootSourceOverrideTarget@Redfish.AllowableValues') + self.sys_inst.boot.allowed_values = None values = self.sys_inst.get_allowed_system_boot_source_values() # Assert it returns all values if it can't get the specific ones expected = set([sushy.BOOT_SOURCE_TARGET_NONE, @@ -229,11 +216,6 @@ class SystemTestCase(base.TestCase): self.sys_inst._get_processor_collection_path) def test_memory_summary_missing_attr(self): - self.assertIsInstance(self.sys_inst.memory_summary, - system.MemorySummary) - self.assertEqual(96, self.sys_inst.memory_summary.size_gib) - self.assertEqual("OK", self.sys_inst.memory_summary.health) - # | GIVEN | self.sys_inst._json['MemorySummary']['Status'].pop('HealthRollup') # | WHEN | diff --git a/sushy/tests/unit/resources/test_base.py b/sushy/tests/unit/resources/test_base.py index f419698e..16cc1e18 100644 --- a/sushy/tests/unit/resources/test_base.py +++ b/sushy/tests/unit/resources/test_base.py @@ -13,6 +13,8 @@ # License for the specific language governing permissions and limitations # under the License. +import copy + import mock from sushy import exceptions @@ -123,3 +125,114 @@ class ResourceCollectionBaseTestCase(base.TestCase): self.assertTrue(isinstance(val, TestResource)) self.assertTrue(val.identity in member_ids) self.assertEqual('1.0.x', val.redfish_version) + + +TEST_JSON = { + 'String': 'a string', + 'Integer': '42', + 'List': ['a string', 42], + 'Nested': { + 'String': 'another string', + 'Integer': 0, + 'Object': { + 'Field': 'field value' + }, + 'Mapped': 'raw' + } +} + + +MAPPING = { + 'raw': 'real' +} + + +class NestedTestField(resource_base.CompositeField): + string = resource_base.Field('String', required=True) + integer = resource_base.Field('Integer', adapter=int) + nested_field = resource_base.Field(['Object', 'Field'], required=True) + mapped = resource_base.MappedField('Mapped', MAPPING) + non_existing = resource_base.Field('NonExisting', default=3.14) + + +class ComplexResource(resource_base.ResourceBase): + string = resource_base.Field('String', required=True) + integer = resource_base.Field('Integer', adapter=int) + nested = NestedTestField('Nested') + non_existing_nested = NestedTestField('NonExistingNested') + non_existing_mapped = resource_base.MappedField('NonExistingMapped', + MAPPING) + + +class FieldTestCase(base.TestCase): + def setUp(self): + super(FieldTestCase, self).setUp() + self.conn = mock.Mock() + self.json = copy.deepcopy(TEST_JSON) + self.conn.get.return_value.json.return_value = self.json + self.test_resource = ComplexResource(self.conn, + redfish_version='1.0.x') + + def test_ok(self): + self.assertEqual('a string', self.test_resource.string) + self.assertEqual(42, self.test_resource.integer) + self.assertEqual('another string', self.test_resource.nested.string) + self.assertEqual(0, self.test_resource.nested.integer) + self.assertEqual('field value', self.test_resource.nested.nested_field) + self.assertEqual('real', self.test_resource.nested.mapped) + self.assertEqual(3.14, self.test_resource.nested.non_existing) + self.assertIsNone(self.test_resource.non_existing_nested) + self.assertIsNone(self.test_resource.non_existing_mapped) + + def test_missing_required(self): + del self.json['String'] + self.assertRaisesRegex(exceptions.MissingAttributeError, + 'String', self.test_resource.refresh) + + def test_missing_nested_required(self): + del self.json['Nested']['String'] + self.assertRaisesRegex(exceptions.MissingAttributeError, + 'Nested/String', self.test_resource.refresh) + + def test_missing_nested_required2(self): + del self.json['Nested']['Object']['Field'] + self.assertRaisesRegex(exceptions.MissingAttributeError, + 'Nested/Object/Field', + self.test_resource.refresh) + + def test_malformed_int(self): + self.json['Integer'] = 'banana' + self.assertRaisesRegex( + exceptions.MalformedAttributeError, + 'attribute Integer is malformed.*invalid literal for int', + self.test_resource.refresh) + + def test_malformed_nested_int(self): + self.json['Nested']['Integer'] = 'banana' + self.assertRaisesRegex( + exceptions.MalformedAttributeError, + 'attribute Nested/Integer is malformed.*invalid literal for int', + self.test_resource.refresh) + + def test_mapping_missing(self): + self.json['Nested']['Mapped'] = 'banana' + self.test_resource.refresh() + + self.assertIsNone(self.test_resource.nested.mapped) + + def test_composite_field_as_mapping(self): + field = self.test_resource.nested + keys = {'string', 'integer', 'nested_field', 'mapped', 'non_existing'} + values = {'another string', 0, 'field value', 'real', 3.14} + + self.assertEqual(keys, set(iter(field))) + self.assertEqual(keys, set(field.keys())) + self.assertEqual(values, set(field.values())) + self.assertEqual(3.14, field['non_existing']) + self.assertEqual(3.14, field.get('non_existing')) + self.assertIsNone(field.get('foobar')) + # Check KeyError from undefined fields + self.assertRaisesRegex(KeyError, 'foobar', lambda: field['foobar']) + # Regular attributes cannot be accessed via mapping + self.assertRaisesRegex(KeyError, '_load', lambda: field['_load']) + self.assertRaisesRegex(KeyError, '__init__', lambda: field['__init__'])