Source code for file_config.schema_builder

# Copyright (c) 2019 Stephen Bunn <stephen@bunn.io>
# ISC License <https://choosealicense.com/licenses/isc>

import re
import typing
import warnings
import collections

import attr

from .utils import (
    is_bool_type,
    is_enum_type,
    is_null_type,
    is_array_type,
    is_config_var,
    is_regex_type,
    is_union_type,
    is_config_type,
    is_number_type,
    is_object_type,
    is_string_type,
    is_typing_type,
    is_builtin_type,
    is_integer_type,
)
from .constants import CONFIG_KEY, REGEX_TYPE_NAME

DEFAULT_SCHEMA_DRAFT = "http://json-schema.org/draft-07/schema#"
SUPPORTED_SCHEMA_DRAFTS = (DEFAULT_SCHEMA_DRAFT,)


[docs]def Regex(pattern): """ A custom typing type to store regular expressions for schema building. :param str pattern: The regular expression :return: A new Regex type instance :rtype: typing.Type """ return typing.NewType(REGEX_TYPE_NAME, re.compile(pattern))
[docs]def _build_attribute_modifiers(var, attribute_mapping, ignore=None): """ Handles adding schema modifiers for a given config var and some mapping. :param attr._make.Attribute var: The config var to build modifiers for :param Dict[str, str] attribute_mapping: A mapping of attribute to jsonschema modifiers :param List[str] ignore: A list of mapping keys to ignore, defaults to None :raises ValueError: When the given ``var`` is not an config var :raises ValueError: When jsonschema modifiers are given the wrong type :return: A dictionary of the built modifiers :rtype: Dict[str, Any] """ if not isinstance(ignore, list): ignore = ["type", "name", "required", "default"] if not is_config_var(var): raise ValueError( f"cannot build field modifiers for {var!r}, is not a config var" ) entry = var.metadata[CONFIG_KEY] modifiers = {} for (entry_attribute, entry_value) in zip( attr.fields(type(entry)), attr.astuple(entry) ): if entry_value is not None: if entry_attribute.name in ignore: continue elif entry_attribute.name in attribute_mapping: # NOTE: stupid type comparisons required for off case where # bool is a subclass of int `isinstance(True, (int, float)) == True` if entry_attribute.type is not None and ( type(entry_value) in entry_attribute.type if isinstance(entry_attribute.type, (list, tuple, set)) else type(entry_value) == entry_attribute.type ): # noqa modifiers[attribute_mapping[entry_attribute.name]] = entry_value else: raise ValueError( f"invalid modifier type for modifier {entry_attribute.name!r} " f"on var {var.name!r}, expected type {entry_attribute.type!r}, " f"received {entry_value!r} of type {type(entry_value)!r}" ) else: warnings.warn( f"field modifier {entry_attribute.name!r} has no effect on var " f"{var.name!r} of type {entry.type!r}" ) return modifiers
[docs]def _build_null_type(var, property_path=None): """ Builds schema definitions for null type values. :param var: The null type value :param List[str] property_path: The property path of the current type, defaults to None, optional :return: The built schema definition :rtype: Dict[str, Any] """ if not property_path: property_path = [] return {"type": "null"}
[docs]def _build_enum_type(var, property_path=None): """ Builds schema definitions for enum type values. :param var: The enum type value :param List[str] property_path: The property path of the current type, defaults to None, optional :return: The built schema definition :rtype: Dict[str, Any] """ if not property_path: property_path = [] entry = var.metadata[CONFIG_KEY] enum_values = [member.value for member in entry.type.__members__.values()] schema = {"enum": enum_values} for (type_name, check) in dict( bool=is_bool_type, string=is_string_type, number=is_number_type, integer=is_integer_type, ).items(): if all(check(type(_)) for _ in enum_values): schema["type"] = type_name break return schema
[docs]def _build_bool_type(var, property_path=None): """ Builds schema definitions for boolean type values. :param var: The boolean type value :param List[str] property_path: The property path of the current type, defaults to None, optional :param property_path: [type], optional :return: The built schema definition :rtype: Dict[str, Any] """ if not property_path: property_path = [] return {"type": "boolean"}
[docs]def _build_string_type(var, property_path=None): """ Builds schema definitions for string type values. :param var: The string type value :param List[str] property_path: The property path of the current type, defaults to None, optional :param property_path: [type], optional :return: The built schema definition :rtype: Dict[str, Any] """ if not property_path: property_path = [] schema = {"type": "string"} if is_builtin_type(var): return schema if is_regex_type(var): schema["pattern"] = var.__supertype__.pattern return schema if is_config_var(var): schema.update( _build_attribute_modifiers(var, {"min": "minLength", "max": "maxLength"}) ) if is_regex_type(var.type): schema["pattern"] = var.type.__supertype__.pattern return schema
[docs]def _build_integer_type(var, property_path=None): """ Builds schema definitions for integer type values. :param var: The integer type value :param List[str] property_path: The property path of the current type, defaults to None, optional :param property_path: [type], optional :return: The built schema definition :rtype: Dict[str, Any] """ if not property_path: property_path = [] schema = {"type": "integer"} if is_builtin_type(var): return schema if is_config_var(var): schema.update( _build_attribute_modifiers(var, {"min": "minimum", "max": "maximum"}) ) return schema
[docs]def _build_number_type(var, property_path=None): """ Builds schema definitions for number type values. :param var: The number type value :param List[str] property_path: The property path of the current type, defaults to None, optional :param property_path: [type], optional :return: The built schema definition :rtype: Dict[str, Any] """ if not property_path: property_path = [] schema = {"type": "number"} if is_builtin_type(var): return schema if is_config_var(var): schema.update( _build_attribute_modifiers(var, {"min": "minimum", "max": "maximum"}) ) return schema
[docs]def _build_array_type(var, property_path=None): """ Builds schema definitions for array type values. :param var: The array type value :param List[str] property_path: The property path of the current type, defaults to None, optional :param property_path: [type], optional :return: The built schema definition :rtype: Dict[str, Any] """ if not property_path: property_path = [] schema = {"type": "array", "items": {"$id": f"#/{'/'.join(property_path)}/items"}} if is_builtin_type(var): return schema if is_config_var(var): schema.update( _build_attribute_modifiers( var, { "min": "minItems", "max": "maxItems", "unique": "uniqueItems", "contains": "contains", }, ) ) if is_typing_type(var.type) and len(var.type.__args__) > 0: # NOTE: typing.List only allows one typing argument nested_type = var.type.__args__[0] schema["items"].update( _build(nested_type, property_path=property_path + ["items"]) ) elif is_typing_type(var): nested_type = var.__args__[0] schema["items"].update( _build(nested_type, property_path=property_path + ["items"]) ) return schema
[docs]def _build_object_type(var, property_path=None): """ Builds schema definitions for object type values. :param var: The object type value :param List[str] property_path: The property path of the current type, defaults to None, optional :param property_path: [type], optional :return: The built schema definition :rtype: Dict[str, Any] """ if not property_path: property_path = [] schema = {"type": "object"} if is_builtin_type(var): return schema entry = var.metadata[CONFIG_KEY] if isinstance(entry.min, int): schema["minProperties"] = entry.min if isinstance(entry.max, int): schema["maxProperties"] = entry.max # NOTE: typing.Dict only accepts two typing arguments if is_typing_type(var.type) and len(var.type.__args__) == 2: (key_type, value_type) = var.type.__args__ key_pattern = "^(.*)$" if is_regex_type(key_type): key_pattern = key_type.__supertype__.pattern elif not is_string_type(key_type): raise ValueError( f"cannot serialize object with key of type {key_type!r}, " f"located in var {var.name!r}" ) schema["patternProperties"] = { key_pattern: _build(value_type, property_path=property_path) } return schema
[docs]def _build_type(type_, value, property_path=None): """ Builds the schema definition based on the given type for the given value. :param type_: The type of the value :param value: The value to build the schema definition for :param List[str] property_path: The property path of the current type, defaults to None, optional :return: The built schema definition :rtype: Dict[str, Any] """ if not property_path: property_path = [] for (type_check, builder) in ( (is_enum_type, _build_enum_type), (is_null_type, _build_null_type), (is_bool_type, _build_bool_type), (is_string_type, _build_string_type), (is_integer_type, _build_integer_type), (is_number_type, _build_number_type), (is_array_type, _build_array_type), (is_object_type, _build_object_type), ): if type_check(type_): return builder(value, property_path=property_path) # NOTE: warning ignores type None (as that is the config var default) if type_: warnings.warn(f"unhandled translation for type {type_!r} with value {value!r}") return {}
[docs]def _build_var(var, property_path=None): """ Builds a schema definition for a given config var. :param attr._make.Attribute var: The var to generate a schema definition for :param List[str] property_path: The property path of the current type, defaults to None, optional :raises ValueError: When the given ``var`` is not a file_config var :return: The built schema definition :rtype: Dict[str, Any] """ if not property_path: property_path = [] if not is_config_var(var): raise ValueError(f"var {var!r} is not a config var") entry = var.metadata[CONFIG_KEY] var_name = entry.name if entry.name else var.name schema = {"$id": f"#/{'/'.join(property_path)}/{var_name}"} if var.default is not None: schema["default"] = var.default if entry is not None: if isinstance(entry.title, str): schema["title"] = entry.title if isinstance(entry.description, str): schema["description"] = entry.description if ( isinstance(entry.examples, collections.abc.Iterable) and len(entry.examples) > 0 ): schema["examples"] = entry.examples # handle typing.Union types by simply using the "anyOf" key if is_union_type(var.type): type_union = {"anyOf": []} for allowed_type in var.type.__args__: # NOTE: requires jsonschema draft-07 type_union["anyOf"].append( _build_type( allowed_type, allowed_type, property_path=property_path + [var_name] ) ) schema.update(type_union) else: schema.update( _build_type(var.type, var, property_path=property_path + [var_name]) ) return schema
[docs]def _build_config(config_cls, property_path=None): """ Builds the schema definition for a given config class. :param class config_cls: The config class to build a schema definition for :param List[str] property_path: The property path of the current type, defaults to None, optional :raises ValueError: When the given ``config_cls`` is not a config decorated class :return: The built schema definition :rtype: Dict[str, Any] """ if not property_path: property_path = [] if not is_config_type(config_cls): raise ValueError(f"class {config_cls!r} is not a config class") schema = {"type": "object", "required": [], "properties": {}} cls_entry = getattr(config_cls, CONFIG_KEY) # add schema title, defaults to config classes `__qualname__` schema_title = cls_entry.get("title", config_cls.__qualname__) if isinstance(schema_title, str): schema["title"] = schema_title schema_description = cls_entry.get("description") if isinstance(schema_description, str): schema["description"] = schema_description # if the length of the property path is 0, assume that current object is root if len(property_path) <= 0: schema_id = cls_entry.get("schema_id") if schema_id is None: schema_id = f"{config_cls.__qualname__}.json" schema["$id"] = schema_id # NOTE: requires at leaast draft-07 for typing.Union type schema generation schema_draft = cls_entry.get("schema_draft") if schema_draft is None: schema_draft = DEFAULT_SCHEMA_DRAFT if schema_draft not in SUPPORTED_SCHEMA_DRAFTS: warnings.warn( "Specifying a custom JSONSchema draft is allowed but not advised. " "We depend on the features provided in drafts " f"{SUPPORTED_SCHEMA_DRAFTS!s} and functionality may become unstable if " "using an unsupported draft." ) schema["$schema"] = schema_draft else: schema["$id"] = f"#/{'/'.join(property_path)}" property_path.append("properties") for var in attr.fields(config_cls): if not is_config_var(var): # encountered attribute is not a serialized field (i.e. missing CONFIG_KEY) continue entry = var.metadata[CONFIG_KEY] var_name = entry.name if entry.name else var.name if entry.required: schema["required"].append(var_name) if is_config_type(var.type): schema["properties"][var_name] = _build_config( var.type, property_path=property_path + [var_name] ) else: schema["properties"][var_name] = _build_var( var, property_path=property_path ) return schema
[docs]def _build(value, property_path=None): """ The generic schema definition build method. :param value: The value to build a schema definition for :param List[str] property_path: The property path of the current type, defaults to None, optional :return: The built schema definition :rtype: Dict[str, Any] """ if not property_path: property_path = [] if is_config_type(value): return _build_config(value, property_path=property_path) elif is_config_var(value): return _build_var(value, property_path=property_path) elif is_builtin_type(value): return _build_type(value, value, property_path=property_path) elif is_regex_type(value): # NOTE: building regular expression types assumes type is string return _build_type(str, value, property_path=property_path) elif is_typing_type(value): return _build_type(value, value, property_path=property_path) return _build_type(type(value), value, property_path=property_path)
[docs]def build_schema(config_cls): """ Builds the JSONSchema for a given config class. .. important:: Although you can configure the generated JSONSchema's ``$id`` and ``$schema`` properties through the :func:`file_config._file_config.config` decorator, this method will default those values for you. You should be wary of using a ``$schema`` draft of anything less than `draft-07 <https://json-schema.org/draft-07/json-schema-release-notes.html>`_ as we rely on the specified functionality for handling both union and regex pattern matching types. :param class config_cls: The config class to build the JSONSchema for :return: The resulting JSONSchema :rtype: dict """ return _build_config(config_cls, property_path=[])