Source code for commonlibs.dicts.schemadicts

#!/usr/bin/env python3
# -*- coding: utf-8 -*-

# ----------------------------------------------------------------------
# Copyright 2019 Airinnova AB and the CommonLibs authors
#
# 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.
# ----------------------------------------------------------------------

# Author: Aaron Dettmann

"""
Schema dictionaries
"""

import operator
from uuid import uuid4


OPERATORS = {
    '>': operator.gt,
    '<': operator.lt,
    '<=': operator.le,
    '>=': operator.ge,
}

SPECIAL_KEY_CHECK_REQ_KEYS = '__REQUIRED_KEYS'

SPECIAL_KEYS = [
    SPECIAL_KEY_CHECK_REQ_KEYS,
]

# Special 'type' used for checks
NOTHING = f'__NOTHING__{uuid4()}'


[docs]class SchemaError(Exception): """Raised if the schema dictionary is ill-defined""" pass
[docs]def check_dict_against_schema(test_dict, schema_dict): """ Check that a dictionary conforms to a schema dictionary This function will raise an error if the 'test_dict' is not in alignment with the 'schema_dict' Args: :schema_dict: Schema dictionary :test_dict: Dictionary to test against schema dictionary Raises: :KeyError: If test dictionary does not have a required key :SchemaError: If the schema itself is ill-defined :TypeError: If test dictionary has a value of wrong type :ValueError: If test dictionary has a value of wrong 'size' Note: * Schema validation inspired by JSON schema, see https://json-schema.org/understanding-json-schema/reference/index.html """ # TODO # LIST check # -- check numerical items in range... # STRING check # -- check REGEX patterns for key, form in schema_dict.items(): # ----- Check that dictionary has required keys ----- if key == SPECIAL_KEY_CHECK_REQ_KEYS: check_keys_in_dict(form, test_dict) continue # Note: Required keys are checked separately test_dict_value = test_dict.get(key, None) if test_dict_value is None and key not in schema_dict.get(SPECIAL_KEY_CHECK_REQ_KEYS, []): continue # ----- Basic type check ----- schema_dict_type = form.get('type', None) if schema_dict_type is None: raise SchemaError(f"Expected type is not defined in schema (key: {key})") if not isinstance(test_dict_value, schema_dict_type): raise TypeError( f""" Unexpected data type for key '{key}'. Expected {schema_dict_type}, got {type(test_dict_value)}. """ ) # ----- TYPE dict ----- if schema_dict_type is dict: sub_schema_dict = form.get('schema', None) if sub_schema_dict is not None: check_dict_against_schema(test_dict_value, sub_schema_dict) # ----- TYPE bool ----- # No further checks required # ----- TYPE float/int ----- elif schema_dict_type in (float, int): for check_key in OPERATORS.keys(): check_value = form.get(check_key, None) if check_value is None: continue schema_dict_value = form.get(check_key, None) if not isinstance(schema_dict_value, (int, float)): raise SchemaError("Comparison value is not of type 'int' or 'float'") if not OPERATORS[check_key](test_dict_value, schema_dict_value): raise ValueError( f""" Test dictionary has wrong value for key '{key}'. Expected {check_key}{schema_dict_value}, but test value is '{test_dict_value}'. """ ) # ----- TYPE str ----- elif schema_dict_type is str: min_len = form.get('min_len', None) if min_len is not None: if len(test_dict_value) < min_len: raise ValueError( f""" String is too short for key '{key}'. Minimum length is '{min_len}', got length '{len(test_dict_value)}' """ ) max_len = form.get('max_len', None) if max_len is not None: if len(test_dict_value) > max_len: raise ValueError( f""" String is too long for key '{key}'. Maximum length is '{max_len}', got length '{len(test_dict_value)}' """ ) # ----- TYPE tuple/list ----- elif schema_dict_type in (tuple, list): min_len = form.get('min_len', None) if min_len is not None: if len(test_dict_value) < min_len: raise ValueError( f""" Array is too short for key '{key}'. Minimum length is '{min_len}', got length '{len(test_dict_value)}' """ ) max_len = form.get('max_len', None) if max_len is not None: if len(test_dict_value) > max_len: raise ValueError( f""" Array is too long for key '{key}'. Maximum length is '{max_len}', got length '{len(test_dict_value)}' """ ) # Check type of the items item_types = form.get('item_types', None) if item_types is not None: if not all(isinstance(item, item_types) for item in test_dict_value): raise TypeError( f""" Array for key '{key}' has item(s) with wrong type. """ )
[docs]def check_keys_in_dict(required_keys, test_dict): """ Check that required keys are in a test dictionary Args: :required_keys: List of keys required in the test dictionary :test_dict: Test dictionary Raises: :KeyError: If a required key is not found in the test dictionary """ # TODO: check that required_keys is list of strings test_dict_keys = list(test_dict.keys()) for required_key in required_keys: if not required_key in test_dict_keys: err_msg = f""" Key '{required_key}' is required, but not found in test dictionary """ raise KeyError(err_msg)
[docs]def get_default_value_dict(schema_dict): """ Return a dictionary with default values based on a schema dict Default values are genereated as follows: * If a key 'default' exists, the corresponding value will be used * If value is callable object, then the called value will be used * If value is non-callable, the value itself will be used * If no key, 'default' exists, 'type' will be called (if callable type) * Otherwise the default value will be None Example: .. code:: python from datetime import datetime def time_now(): return datetime.strftime(datetime.now(), '%H:%S') schema_dict = { 'time': {'type': str, 'default': time_now}, 'person': {'type': str, 'default': 'C.Lindbergh'}, 'age': {'type': int}, 'pets': { 'type': dict, 'schema': { 'dog': {'type': bool, 'default': None}, 'cat': {'type': bool} }, }, } The function will return .. code:: python defaults = { 'time': '08:40', 'person': 'C.Lindbergh', 'age': 0, 'pets': { 'dog': None, 'cat': False, } } """ defaults = {} for key, form in schema_dict.items(): if key in SPECIAL_KEYS: continue # ----- Basic type check ----- schema_dict_type = form.get('type', None) if schema_dict_type is None: raise SchemaError(f"Expected type is not defined in schema (key: {key})") # ----- Recursion ----- if schema_dict_type is dict: defaults[key] = get_default_value_dict(form.get('schema', {})) continue # ----- Set default value ----- # Hint: default value could be intentionally set to None default_value = form.get('default', NOTHING) if default_value is not NOTHING: if callable(default_value): defaults[key] = default_value() else: defaults[key] = default_value # TODO: maybe check that defaults[key] has type schema_dict_type elif callable(schema_dict_type): defaults[key] = schema_dict_type() else: defaults[key] = None return defaults