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 collections
import copy
import enum
import io
import json
import logging
@ -269,18 +270,28 @@ class MappedField(Field):
: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 mapping: a mapping to take values from, a dictionary or
an enumeration.
:param required: whether this field is required. Missing required,
but not defaulted, fields result in MissingAttributeError.
:param default: the default value to use when the field is missing.
This value is not matched against the mapping.
"""
if not isinstance(mapping, collections.abc.Mapping):
raise TypeError("The mapping argument must be a mapping")
if isinstance(mapping, type) and issubclass(mapping, enum.Enum):
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__(
field, required=required, default=default,
adapter=mapping.get)
field, required=required, default=default, adapter=adapter)
class MappedListField(Field):
@ -300,10 +311,20 @@ class MappedListField(Field):
but not defaulted, fields result in MissingAttributeError.
:param default: the default value to use when the field is missing.
"""
if not isinstance(mapping, collections.abc.Mapping):
raise TypeError("The mapping argument must be a mapping")
if isinstance(mapping, type) and issubclass(mapping, enum.Enum):
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__(
field, required=required, default=default,
adapter=lambda x: x)

View File

@ -14,6 +14,7 @@
# under the License.
import copy
import enum
from http import client as http_client
import io
import json
@ -332,7 +333,9 @@ TEST_JSON = {
'Dictionary': {
'key1': {'property_a': 'value1', 'property_b': 'value2'},
'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):
string = resource_base.Field('String', required=True)
integer = resource_base.Field('Integer', adapter=int)
@ -371,6 +381,10 @@ class ComplexResource(resource_base.ResourceBase):
non_existing_nested = NestedTestField('NonExistingNested')
non_existing_mapped = resource_base.MappedField('NonExistingMapped',
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):
@ -402,6 +416,10 @@ class FieldTestCase(base.TestCase):
self.test_resource.dictionary['key2'].property_b)
self.assertIsNone(self.test_resource.non_existing_nested)
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):
del self.json['String']
@ -437,9 +455,11 @@ class FieldTestCase(base.TestCase):
def test_mapping_missing(self):
self.json['Nested']['Mapped'] = 'banana'
self.json['Enum'] = 'banana'
self.test_resource.refresh(force=True)
self.assertIsNone(self.test_resource.nested.mapped)
self.assertIsNone(self.test_resource.enum_mapped)
def test_composite_field_as_mapping(self):
field = self.test_resource.nested
@ -458,6 +478,11 @@ class FieldTestCase(base.TestCase):
self.assertRaisesRegex(KeyError, '_load', lambda: field['_load'])
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):
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())