Merge "Declarative approach to parsing JSON fields"

This commit is contained in:
Jenkins 2017-05-23 11:14:13 +00:00 committed by Gerrit Code Review
commit 963c83ab78
6 changed files with 406 additions and 202 deletions

View File

@ -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')

View File

@ -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

View File

@ -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):

View File

@ -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):

View File

@ -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 |

View File

@ -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__'])