blob: df83cbdcf5b8eac4640539474519a3c500336676 [file] [log] [blame]
# Copyright 2012 The ChromiumOS Authors
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
"""A function to create a schema tree from the given schema expression.
For example:
1. This is the schema of the encoded_fields in component database.
Dict('encoded_fields', Scalar('encoded_field', str),
Dict('encoded_indices', Scalar('encoded_index', int),
Dict('component_classes', Scalar('component_class', str),
AnyOf('component_names', [
Scalar('component_name', str),
List('list_of_component_names', Scalar('component_name', str)),
Scalar('none', type(None))
])
)
)
)
2. This is the schema of the pattern in component database.
List('pattern',
Dict('pattern_field', key_type=Scalar('encoded_index', str),
value_type=Scalar('bit_offset', int))
)
3. This is the schema of the components in component database.
Dict('components', Scalar('component_class', str),
Dict('component_names', Scalar('component_name', str),
FixedDict('component_attributes',
items={
'value': AnyOf('probed_value', [
Scalar('probed_value', str),
List('list_of_probed_values', Scalar('probed_value', str))
])
},
optional_items={
'labels': List('list_of_labels', Scalar('label', str))
}
)
)
)
"""
from __future__ import annotations
import abc
import copy
import re
from typing import List as _List
from typing import Literal
from typing import Optional as _Optional
from typing import Sequence, Set, Type, TypeVar, cast, overload
from typing import TYPE_CHECKING, Any, Generic, Iterable
from .type_utils import MakeList
# To simplify portability issues, validating JSON schema is optional.
try:
import jsonschema
_HAVE_JSONSCHEMA = True
except ImportError:
_HAVE_JSONSCHEMA = False
class SchemaException(Exception):
"""An exception raised by utils.schema."""
class SchemaInvalidException(SchemaException):
"""An exception indicates that an object doesn't match a schema."""
def __init__(self, value, schema, exception) -> None:
super().__init__()
self._value = value
self._schema = schema
self._exception = exception
def __str__(self) -> str:
return ', '.join((f'value={self._value!r}', f'schema={self._schema!r}',
f'exception={self._exception!r}'))
def __repr__(self) -> str:
return f'{self.__class__.__name__}({self})'
class AbstractType(abc.ABC):
"""Base type class for schema classes.
"""
def __init__(self, label: str):
self.label = label
def __repr__(self):
return f'AbstractType({self.label!r})'
@abc.abstractmethod
def Validate(self, data):
raise NotImplementedError
#TODO(chungsheng): Find a more specific type for "non iterable except string
# types".
_ScalarElementType = Any
class Scalar(AbstractType):
"""Scalar schema class.
Attributes:
label: A human-readable string to describe this Scalar.
element_type: The Python type of this Scalar. Cannot be a iterable type.
choices: A set of allowable choices for the scalar, or None to allow
any values of the given type.
Raises:
SchemaException if argument format is incorrect.
"""
def __init__(self, label, element_type: Type[_ScalarElementType],
choices: _Optional[Iterable[_ScalarElementType]] = None):
super().__init__(label)
if getattr(element_type, '__iter__',
None) and element_type not in (str, bytes):
raise SchemaException(
f'element_type {element_type!r} of Scalar {label!r} is not a scalar '
'type')
self.element_type: Type[_ScalarElementType] = element_type
self.choices: Set[_ScalarElementType] = set(choices) if choices else set()
def __repr__(self):
# Don't do type checking because sorted don't accept "Set[None]"
if TYPE_CHECKING:
return ''
choices = f', choices={sorted(self.choices)!r}' if self.choices else ''
return f'Scalar({self.label!r}, {self.element_type!r}{choices})'
def Validate(self, data):
"""Validates the given data against the Scalar schema.
It checks if the data's type matches the Scalar's element type. Also, it
checks if the data's value matches the Scalar's value if the required value
is specified.
Args:
data: A Python data structure to be validated.
Raises:
SchemaException if validation fails.
"""
if not isinstance(data, self.element_type):
raise SchemaException(
f'Type mismatch on {data!r}: expected {self.element_type!r}, got '
f'{type(data)!r}')
if self.choices and data not in self.choices:
if TYPE_CHECKING:
# Don't do type checking because sorted don't accept "Set[None]"
raise SchemaException('')
raise SchemaException(f'Value mismatch on {data!r}: expected one of '
f'{sorted(self.choices)!r}')
class RegexpStr(Scalar):
"""Schema class for a string which matches the specific regular expression.
Attributes:
label: A human-readable string to describe this Scalar.
regexp: A regular expression object to match.
Raises:
SchemaException if argument format is incorrect.
"""
def __init__(self, label, regexp: re.Pattern[str]):
super().__init__(label, str)
self.regexp: re.Pattern[str] = regexp
def __repr__(self):
return f'RegexpStr({self.label!r}, {self.regexp.pattern})'
def __deepcopy__(self, memo):
return RegexpStr(self.label, self.regexp)
def Validate(self, data):
"""Validates the given data against the RegexpStr schema.
It first checks if the data's type is `str`. Then, it checks if the
value matches the regular expression.
Args:
data: A Python data structure to be validated.
Raises:
SchemaException if validation fails.
"""
super().Validate(data)
if not self.regexp.match(data):
raise SchemaException(f"Value {data!r} doesn't match regeular expression "
f"{self.regexp.pattern}")
_DictKeyType = TypeVar('_DictKeyType', Scalar, 'AnyOf[Scalar]')
_DictValueType = TypeVar('_DictValueType', AbstractType, Scalar, RegexpStr,
'Dict', 'FixedDict', 'JSONSchemaDict', 'List', 'Tuple',
'AnyOf', 'Optional')
class Dict(AbstractType, Generic[_DictKeyType, _DictValueType]):
"""Dict schema class.
This schema class is used to verify simple dict. Only the key type and value
type are validated.
Attributes:
label: A human-readable string to describe this Scalar.
key_type: A schema object indicating the schema of the keys of this Dict. It
can be a Scalar or an AnyOf with possible values being all Scalars.
value_type: A schema object indicating the schema of the values of this
Dict.
min_size: The minimum size of the elements, default to 0.
max_size: None or the maximum size of the elements.
Raises:
SchemaException if argument format is incorrect.
"""
def __init__(self, label, key_type: _DictKeyType, value_type: _DictValueType,
min_size=0, max_size=None):
super().__init__(label)
if not (isinstance(key_type, Scalar) or
(isinstance(key_type, AnyOf) and
key_type.CheckTypeOfPossibleValues(Scalar))):
raise SchemaException(
f'key_type {key_type!r} of Dict {self.label!r} is not Scalar')
self.key_type: _DictKeyType = key_type
if not isinstance(value_type, AbstractType):
raise SchemaException(
f'value_type {value_type!r} of Dict {self.label!r} is not Schema '
'object')
self.value_type: _DictValueType = value_type
self.min_size = min_size
self.max_size = max_size
def __repr__(self):
size_expr = (f"[{int(self.min_size)}, "
f"{'inf' if self.max_size is None else f'{self.max_size:d}'}]")
return (f'Dict({self.label!r}, key_type={self.key_type!r}, value_type='
f'{self.value_type!r}, size={size_expr})')
def Validate(self, data):
"""Validates the given data against the Dict schema.
It checks that all the keys in data matches the schema defined by key_type,
and all the values in data matches the schema defined by value_type.
Args:
data: A Python data structure to be validated.
Raises:
SchemaException if validation fails.
"""
if not isinstance(data, dict):
raise SchemaException(
f'Type mismatch on {self.label!r}: expected dict, got {type(data)!r}')
if len(data) < self.min_size:
raise SchemaException(
f'Size mismatch on {self.label!r}: expected size >= {self.min_size!r}'
)
if self.max_size is not None and self.max_size < len(data):
raise SchemaException(
f'Size mismatch on {self.label!r}: expected size <= {self.max_size!r}'
)
for k, v in data.items():
self.key_type.Validate(k)
self.value_type.Validate(v)
class FixedDict(AbstractType):
"""FixedDict schema class.
FixedDict is a Dict with predefined allowed keys. And each key corresponds to
a value type. The analogy of Dict vs. FixedDict can be Elements vs. Attribues
in XML.
An example FixedDict schema:
FixedDict('foo',
items={
'a': Scalar('bar', str),
'b': Scalar('buz', int)
}, optional_items={
'c': Scalar('boo', int)
})
Attributes:
label: A human-readable string to describe this dict.
items: A dict of required items that must be specified.
optional_items: A dict of optional items.
allow_undefined_keys: A boolean that indicates whether additional items
that is not recorded in both `items` and `optional_items` are allowed
or not.
Raises:
SchemaException if argument format is incorrect.
"""
def __init__(self, label, items=None, optional_items=None,
allow_undefined_keys=False):
super().__init__(label)
if items and not isinstance(items, dict):
raise SchemaException(
f'items of FixedDict {self.label!r} should be a dict')
self.items = copy.deepcopy(items) if items is not None else {}
if optional_items and not isinstance(optional_items, dict):
raise SchemaException(
f'optional_items of FixedDict {self.label!r} should be a dict')
self.optional_items = (
copy.deepcopy(optional_items) if optional_items is not None else {})
self.allow_undefined_keys = allow_undefined_keys
def __repr__(self):
return (f'FixedDict({self.label!r}, items={self.items!r}, optional_items='
f'{self.optional_items!r})')
def Validate(self, data):
"""Validates the given data and all its key-value pairs against the Dict
schema.
If a key of Dict's type is required, then it must exist in the data's keys.
If `self.allow_undefined_keys` is `False` and some items in the given data
are not in either `self.items` or `self.optional_items`, the method will
raise `SchemaException`.
Args:
data: A Python data structure to be validated.
Raises:
SchemaException if validation fails.
"""
if not isinstance(data, dict):
raise SchemaException(
f'Type mismatch on {self.label!r}: expected dict, got {type(data)!r}')
data_key_list = list(data)
# Check that every key-value pair in items exists in data
for key, value_schema in self.items.items():
if key not in data:
raise SchemaException(
f'Required item {key!r} does not exist in FixedDict {data!r}')
value_schema.Validate(data[key])
data_key_list.remove(key)
# Check that all the remaining unmatched key-value pairs matches any
# definition in items or optional_items.
for key, value_schema in self.optional_items.items():
if key not in data:
continue
value_schema.Validate(data[key])
data_key_list.remove(key)
if not self.allow_undefined_keys and data_key_list:
raise SchemaException(
f'Keys {data_key_list!r} are undefined in FixedDict {self.label!r}')
class JSONSchemaDict(AbstractType):
"""JSON schema class.
This schema class allows mixing JSON schema with other schema types.
Attributes:
label: A human-readable string to describe this JSON schema.
schema: a JSON schema object.
Raises:
SchemaException if given schema is invalid (SchemaError) or fail
to validate data using the schema (ValidationError).
"""
def __init__(self, label, schema):
super().__init__(label)
self.label = label
if _HAVE_JSONSCHEMA:
try:
jsonschema.Draft4Validator.check_schema(schema)
except Exception as e:
raise SchemaException(f'Schema {schema!r} is invalid: {e!r}') from None
self.schema = schema
def __repr__(self):
return f'JSONSchemaDict({self.label!r}, {self.schema!r})'
def Validate(self, data):
if _HAVE_JSONSCHEMA:
try:
jsonschema.validate(data, self.schema)
except jsonschema.ValidationError as e:
raise SchemaInvalidException(e.instance, e.schema, e) from None
except Exception as e:
raise SchemaException(
f'Fail to validate {data!r} with JSON schema {self.schema!r}: {e!r}'
) from None
def CreateOptional(self):
"""Creates a new schema that accepts null and itself."""
return JSONSchemaDict(f'{self.label} or null',
{'anyOf': [
{
'type': 'null'
},
self.schema,
]})
_ListElementType = TypeVar('_ListElementType', AbstractType, Scalar, RegexpStr,
'Dict', 'FixedDict', 'JSONSchemaDict', 'List',
'Tuple', 'AnyOf', 'Optional', Literal[None])
class List(AbstractType, Generic[_ListElementType]):
"""List schema class.
Attributes:
label: A string to describe this list.
element_type: Optional schema object to validate the elements of the list.
Default None means no validation of elements' type.
min_length: The expected minimum length of the list. Default to 0.
max_length: None or the limit of the length.
Raises:
SchemaException if argument format is incorrect.
"""
element_type: _ListElementType
@overload
def __init__(self: 'List[_ListElementType]', label,
element_type: _ListElementType, min_length=0,
max_length=None) -> None:
...
@overload
def __init__(self: 'List[None]', label, element_type: Literal[None] = None,
min_length=0, max_length=None) -> None:
...
def __init__(self: 'List[_ListElementType]', label, element_type=None,
min_length=0, max_length=None):
super().__init__(label)
if element_type is not None and not isinstance(element_type, AbstractType):
raise SchemaException(
f'element_type {element_type!r} of List {self.label!r} is not a '
'Schema object')
self.element_type = cast(_ListElementType, copy.deepcopy(element_type))
self.min_length = min_length
self.max_length = max_length
def __repr__(self):
max_bound_repr = ('inf'
if self.max_length is None else f'{int(self.max_length)}')
return (
f'List({self.label!r}, {self.element_type!r}, [{self.min_length!r}, '
f'{max_bound_repr}])')
def Validate(self, data):
"""Validates the given data and all its elements against the List schema.
Args:
data: A Python data structure to be validated.
Raises:
SchemaException if validation fails.
"""
if not isinstance(data, list):
raise SchemaException(
f'Type mismatch on {self.label!r}: expected list, got {type(data)!r}')
if len(data) < self.min_length:
raise SchemaException(
f'Length mismatch on {self.label!r}: expected length >= '
f'{int(self.min_length)}')
if self.max_length is not None and self.max_length < len(data):
raise SchemaException(
f'Length mismatch on {self.label!r}: expected length <= '
f'{int(self.max_length)}')
if self.element_type:
for data_value in data:
self.element_type.Validate(data_value)
class Tuple(AbstractType):
"""Tuple schema class.
Comparing to List, the Tuple schema makes sure that every element exactly
matches the defined position and schema.
Attributes:
label: A string to describe this tuple.
element_types: Optional list or tuple schema object to describe the
types of the Tuple.
Raises:
SchemaException if argument format is incorrect.
"""
def __init__(self, label, element_types=None):
super().__init__(label)
if (element_types and (not isinstance(element_types, (tuple, list))) or
(not all([isinstance(x, AbstractType)] for x in element_types))):
raise SchemaException(
f'element_types {element_types!r} of Tuple {self.label!r} is not a '
'tuple or list')
self.element_types = copy.deepcopy(element_types)
def __repr__(self):
return f'Tuple({self.label!r}, {self.element_types!r})'
def Validate(self, data):
"""Validates the given data and all its elements against the Tuple schema.
Args:
data: A Python data structure to be validated.
Raises:
SchemaException if validation fails.
"""
if not isinstance(data, tuple):
raise SchemaException(
f'Type mismatch on {self.label!r}: expected tuple, got {type(data)!r}'
)
if self.element_types and len(self.element_types) != len(data):
raise SchemaException(
f'Number of elements in tuple {str(data)!r} does not match that '
f'defined in Tuple schema {self.label!r}')
for content, element_type in zip(data, self.element_types):
element_type.Validate(content)
_AnyType = TypeVar('_AnyType', Scalar, AbstractType)
class AnyOf(AbstractType, Generic[_AnyType]):
"""A Schema class which accepts any one of the given Schemas.
Attributes:
types: A list of Schema objects to be matched.
label: An optional string to describe this AnyOf type.
"""
def __init__(self, types: Sequence[_AnyType], label=None):
super().__init__(label)
if (not isinstance(types, list) or
not all(isinstance(x, AbstractType) for x in types)):
raise SchemaException(
f'types in AnyOf(types={types!r}'
f"{'' if label is None else ', label='f'{label}'}) should be a list "
'of Schemas')
self.types: _List[_AnyType] = list(types)
def __repr__(self):
label = '' if self.label is None else f', label={self.label!r}'
return f'AnyOf({self.types!r}{label})'
def CheckTypeOfPossibleValues(self, schema_type):
"""Checks if the acceptable types are of the same type as schema_type.
Args:
schema_type: The schema type to check against with.
"""
return all(isinstance(k, schema_type) for k in self.types)
def Validate(self, data):
"""Validates if the given data matches any schema in types
Args:
data: A Python data structue to be validated.
Raises:
SchemaException if no schemas in types validates the input data.
"""
match = False
for schema_type in self.types:
try:
schema_type.Validate(data)
except SchemaException:
continue
match = True
break
if not match:
raise SchemaException(
f'{data!r} does not match any type in {self.types!r}')
class Optional(AnyOf):
"""A Schema class which accepts either None or given Schemas.
It is a special case of AnyOf class: in addition of given schema(s), it also
accepts None.
Attributes:
types: A (or a list of) Schema object(s) to be matched.
label: An optional string to describe this Optional type.
"""
def __init__(self, types, label=None):
try:
super().__init__(MakeList(types), label=label)
except SchemaException:
raise SchemaException(
f'types in Optional(types={types!r}'
f"{'' if label is None else ', label='f'{label}'}') should be a "
'Schema or a list of Schemas') from None
def __repr__(self):
label = '' if self.label is None else f', label={self.label!r}'
return f'Optional({self.types!r}{label})'
def Validate(self, data):
"""Validates if the given data is None or matches any schema in types.
Args:
data: A Python data structue to be validated.
Raises:
SchemaException if data is not None and no schemas in types validates the
input data.
"""
if data is None:
return
try:
super().Validate(data)
except SchemaException:
raise SchemaException(
f'{data!r} is not None and does not match any type in {self.types!r}'
) from None