Prepare the ground to use enums instead of strings

Makes MappedField understand enum classes instead of a dictionary.

Also adds a script to generate enum definitions from DMTF definitions,
as well as creating compatibility constants.

Change-Id: Ic950a35bb9fc603e5c4b943ac19dba982bdf7212
This commit is contained in:
Dmitry Tantsur
2021-10-22 11:44:17 +02:00
parent 69b03d8010
commit 4b99f93a11
3 changed files with 138 additions and 9 deletions

View File

@@ -16,6 +16,7 @@
import abc import abc
import collections import collections
import copy import copy
import enum
import io import io
import json import json
import logging import logging
@@ -269,18 +270,28 @@ class MappedField(Field):
:param field: JSON field to fetch the value from. This can be either :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 a string or a list of string. In the latter case, the value will
be fetched from a nested object. be fetched from a nested object.
:param mapping: a mapping to take values from. :param mapping: a mapping to take values from, a dictionary or
an enumeration.
:param required: whether this field is required. Missing required, :param required: whether this field is required. Missing required,
but not defaulted, fields result in MissingAttributeError. but not defaulted, fields result in MissingAttributeError.
:param default: the default value to use when the field is missing. :param default: the default value to use when the field is missing.
This value is not matched against the mapping. This value is not matched against the mapping.
""" """
if not isinstance(mapping, collections.abc.Mapping): if isinstance(mapping, type) and issubclass(mapping, enum.Enum):
raise TypeError("The mapping argument must be a mapping") def adapter(value):
try:
return mapping(value)
except ValueError:
return None
elif isinstance(mapping, collections.abc.Mapping):
adapter = mapping.get
else:
raise TypeError("The mapping argument must be a mapping or "
"an enumeration")
super(MappedField, self).__init__( super(MappedField, self).__init__(
field, required=required, default=default, field, required=required, default=default, adapter=adapter)
adapter=mapping.get)
class MappedListField(Field): class MappedListField(Field):
@@ -300,10 +311,20 @@ class MappedListField(Field):
but not defaulted, fields result in MissingAttributeError. but not defaulted, fields result in MissingAttributeError.
:param default: the default value to use when the field is missing. :param default: the default value to use when the field is missing.
""" """
if not isinstance(mapping, collections.abc.Mapping): if isinstance(mapping, type) and issubclass(mapping, enum.Enum):
raise TypeError("The mapping argument must be a mapping") def adapter(value):
try:
return mapping(value)
except ValueError:
return None
self._mapping_adapter = mapping.get elif isinstance(mapping, collections.abc.Mapping):
adapter = mapping.get
else:
raise TypeError("The mapping argument must be a mapping or "
"an enumeration")
self._mapping_adapter = adapter
super(MappedListField, self).__init__( super(MappedListField, self).__init__(
field, required=required, default=default, field, required=required, default=default,
adapter=lambda x: x) adapter=lambda x: x)

View File

@@ -14,6 +14,7 @@
# under the License. # under the License.
import copy import copy
import enum
from http import client as http_client from http import client as http_client
import io import io
import json import json
@@ -332,7 +333,9 @@ TEST_JSON = {
'Dictionary': { 'Dictionary': {
'key1': {'property_a': 'value1', 'property_b': 'value2'}, 'key1': {'property_a': 'value1', 'property_b': 'value2'},
'key2': {'property_a': 'value3', 'property_b': 'value4'} 'key2': {'property_a': 'value3', 'property_b': 'value4'}
} },
'Enum': 'PROTOCOL_FIELD_2',
'EnumList': ['PROTOCOL_FIELD_2', 'PROTOCOL_FIELD_3'],
} }
@@ -343,6 +346,13 @@ MAPPING = {
} }
class EnumMapping(enum.Enum):
FIELD1 = "PROTOCOL_FIELD_1"
FIELD2 = "PROTOCOL_FIELD_2"
FIELD3 = "PROTOCOL_FIELD_3"
class NestedTestField(resource_base.CompositeField): class NestedTestField(resource_base.CompositeField):
string = resource_base.Field('String', required=True) string = resource_base.Field('String', required=True)
integer = resource_base.Field('Integer', adapter=int) integer = resource_base.Field('Integer', adapter=int)
@@ -371,6 +381,10 @@ class ComplexResource(resource_base.ResourceBase):
non_existing_nested = NestedTestField('NonExistingNested') non_existing_nested = NestedTestField('NonExistingNested')
non_existing_mapped = resource_base.MappedField('NonExistingMapped', non_existing_mapped = resource_base.MappedField('NonExistingMapped',
MAPPING) MAPPING)
enum_mapped = resource_base.MappedField('Enum', EnumMapping)
enum_mapped_list = resource_base.MappedListField('EnumList', EnumMapping)
non_existing_enum_mapped = resource_base.MappedField('NonExistingEnum',
EnumMapping)
class FieldTestCase(base.TestCase): class FieldTestCase(base.TestCase):
@@ -402,6 +416,10 @@ class FieldTestCase(base.TestCase):
self.test_resource.dictionary['key2'].property_b) self.test_resource.dictionary['key2'].property_b)
self.assertIsNone(self.test_resource.non_existing_nested) self.assertIsNone(self.test_resource.non_existing_nested)
self.assertIsNone(self.test_resource.non_existing_mapped) self.assertIsNone(self.test_resource.non_existing_mapped)
self.assertEqual(EnumMapping.FIELD2, self.test_resource.enum_mapped)
self.assertEqual([EnumMapping.FIELD2, EnumMapping.FIELD3],
self.test_resource.enum_mapped_list)
self.assertIsNone(self.test_resource.non_existing_enum_mapped)
def test_missing_required(self): def test_missing_required(self):
del self.json['String'] del self.json['String']
@@ -437,9 +455,11 @@ class FieldTestCase(base.TestCase):
def test_mapping_missing(self): def test_mapping_missing(self):
self.json['Nested']['Mapped'] = 'banana' self.json['Nested']['Mapped'] = 'banana'
self.json['Enum'] = 'banana'
self.test_resource.refresh(force=True) self.test_resource.refresh(force=True)
self.assertIsNone(self.test_resource.nested.mapped) self.assertIsNone(self.test_resource.nested.mapped)
self.assertIsNone(self.test_resource.enum_mapped)
def test_composite_field_as_mapping(self): def test_composite_field_as_mapping(self):
field = self.test_resource.nested field = self.test_resource.nested
@@ -458,6 +478,11 @@ class FieldTestCase(base.TestCase):
self.assertRaisesRegex(KeyError, '_load', lambda: field['_load']) self.assertRaisesRegex(KeyError, '_load', lambda: field['_load'])
self.assertRaisesRegex(KeyError, '__init__', lambda: field['__init__']) self.assertRaisesRegex(KeyError, '__init__', lambda: field['__init__'])
def test_invalid_mapping_definiton(self):
self.assertRaises(TypeError, resource_base.MappedField, 'Field', 42)
self.assertRaises(TypeError, resource_base.MappedListField,
'Field', 42)
class PartialKeyResource(resource_base.ResourceBase): class PartialKeyResource(resource_base.ResourceBase):
string = resource_base.Field( string = resource_base.Field(

83
tools/generate-enum.py Executable file
View File

@@ -0,0 +1,83 @@
#!/usr/bin/env python3
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
import argparse
import json
import re
import sys
import textwrap
import requests
def to_underscores(item):
words = re.findall(r'[A-Z](?:[a-z0-9]+|[A-Z0-9]*(?=[A-Z]|$))', item)
return '_'.join(w.upper() for w in words)
def main():
parser = argparse.ArgumentParser()
parser.add_argument("url", help="URL of a DMTF definition")
parser.add_argument("name", help="name of the enumeration")
parser.add_argument("--compat", help="generate old-style constants",
action="store_true")
args = parser.parse_args()
if args.url.startswith("https://") or args.url.startswith("http://"):
resp = requests.get(args.url)
resp.raise_for_status()
content = resp.json()
else:
with open(args.url, "t") as fp:
content = json.load(fp)
try:
definition = content["definitions"][args.name]
except KeyError as exc:
sys.exit(f"Key {exc} was not found in definition at {args.url}")
try:
items = definition["enum"]
descriptions = definition.get("enumDescriptions", {})
except (TypeError, KeyError):
sys.exit(f"Definition {args.name} is malformed or not en enumeration")
items = [(to_underscores(item), item) for item in items]
print(f"class {args.name}(enum.Enum):")
for varname, item in items:
print(f" {varname} = '{item}'")
try:
description = descriptions[item]
except KeyError:
pass
else:
# 79 (expected) - 4 (indentation) - 2 * 3 (quotes) = 69
lines = textwrap.wrap(description, 69)
lines[0] = '"""' + lines[0]
lines[-1] += '"""'
for line in lines:
print(f' {line}')
print()
if args.compat:
print()
print("# Backward compatibility")
for varname, item in items:
print(f"{to_underscores(args.name)}_{varname} = "
f"{args.name}.{varname}")
if __name__ == '__main__':
sys.exit(main())