from __future__ import annotations import enum import hashlib import json import uuid import warnings from collections import defaultdict from functools import reduce from types import MappingProxyType from typing import ( Any, Dict, FrozenSet, List, Mapping, Optional, Sequence, Set, Type, Union, get_args, get_origin, get_type_hints, Iterator, Tuple, ) from .compat import classproperty from .fields import Expr, _Field from .types.union import inject_types TYPE_FIELD = "type" # ExcludeFieldsType: str -> ExcludeFieldsType | bool ExcludeFieldsType = Mapping[str, Any] SchemaType = Dict[str, Any] def _make_exclude_fields( exclude_fields: Optional[Sequence[str]] = None, ) -> ExcludeFieldsType: if not exclude_fields: return MappingProxyType({}) exclude: Dict[str, Any] = {} exclude_nested: Dict[str, List[str]] = defaultdict(list) for exclude_field in exclude_fields: if not exclude_field: raise ValueError("Field name cannot be empty") field_name, *nested_fields = exclude_field.split(".") if nested_fields: exclude_nested[field_name].append(".".join(nested_fields)) else: exclude[field_name] = True for field_name, nested_fields in exclude_nested.items(): if field_name not in exclude: exclude[field_name] = _make_exclude_fields(nested_fields) return MappingProxyType(exclude) def _cast_value_type(value: Any, type_: Any) -> Any: # noqa if isinstance(value, _Field): raise ValueError("Value cannot be of type _Field") if type_ is Any: return value origin_type = get_origin(type_) if isinstance(origin_type, type) and isinstance(origin_type, (str, bytes)): return type_(value) if isinstance(origin_type, type) and issubclass(origin_type, Sequence): if not isinstance(value, Sequence): raise ValueError( f"Value {value} has wrong type. Expected type is {type_}.", ) element_type, *_ = get_args(type_) return [_cast_value_type(v, element_type) for v in value] if isinstance(origin_type, type) and issubclass(origin_type, Mapping): if not isinstance(value, Mapping): raise ValueError( f"Value {value} has wrong type. Expected type is {type_}.", ) key_type, value_type = get_args(type_) return { _cast_value_type(k, key_type): _cast_value_type(v, value_type) for k, v in value.items() } if origin_type is Union: if not getattr(type_, "__injected_types__", False): inject_types(type_) setattr(type_, "__injected_types__", True) types = getattr(type_, "__types__", None) if types and isinstance(value, dict): type_value = value.get(TYPE_FIELD) if type_value: target_type = types.get(type_value) if target_type: return target_type(**value) raise ValueError( f"Union {type_} does not contain type {type_value}.", ) raise ValueError( f"Value {value} does not have field {TYPE_FIELD}.", ) for u_type in get_args(type_): try: return _cast_value_type(value, u_type) except ValueError: pass raise ValueError( f"Value {value} has wrong type. Expected type is {type_}.", ) if isinstance(value, type_): return value if isinstance(value, BaseEntity): if isinstance(value, type_): return value raise ValueError( f"Value {value} has wrong type. Expected type is {type_}", ) if issubclass(type_, BaseEntity) and isinstance(value, dict): return type_(**value) if value is not None: try: return type_(value) except Exception: pass raise ValueError( f"Value {value} has wrong type. Expected type is {type_}.", ) def dump(obj: Any) -> Any: if isinstance(obj, (str, bytes)): return obj if isinstance(obj, Sequence): return [dump(obj_item) for obj_item in obj] if isinstance(obj, Mapping): return {k: v for k, v in obj.items()} if isinstance(obj, Expr): return str(obj) if isinstance(obj, BaseEntity): return obj.dict() if isinstance(obj, enum.Enum): return obj.value return obj def _update_related_templates( value: Any, related_templates: Set[Type[BaseDiv]], ) -> None: if isinstance(value, BaseEntity): related_templates.update(value.related_templates()) elif isinstance(value, list): for v_item in value: _update_related_templates(v_item, related_templates) def _unpack_optional_type(type_: Any) -> Any: if get_origin(type_) is Union: inner_types = [] for inner_type in get_args(type_): if get_origin(inner_type) or not isinstance(None, inner_type): inner_types.append(inner_type) return Union[tuple(inner_types)] return type_ def _merge_types(fst_type: Any, snd_type: Any) -> Any: fst_type = _unpack_optional_type(fst_type) snd_type = _unpack_optional_type(snd_type) if fst_type == snd_type: return fst_type raise TypeError(f"Incompatible types: {fst_type} and {snd_type}") BUILTIN_TYPES_TO_SCHEMA: Mapping[type, Mapping[str, Any]] = MappingProxyType( { int: MappingProxyType({"type": "integer"}), float: MappingProxyType({"type": "number"}), bool: MappingProxyType( { "type": "integer", "enum": [0, 1], "format": "boolean", }, ), str: MappingProxyType({"type": "string"}), bytes: MappingProxyType({"type": "string"}), Expr: MappingProxyType({"type": "string", "pattern": "^@{.*}$"}), }, ) def _type_field_to_schema(field: _Field) -> SchemaType: return { "type": "string", "enum": [field.default], } def _enum_to_schema(type_: Type[enum.Enum]) -> SchemaType: return { "type": "string", "enum": [enum_el.value for enum_el in type_], } def _list_to_schema( type_: Any, definitions: Dict[str, SchemaType], exclude: ExcludeFieldsType, ) -> SchemaType: item_type, *_ = get_args(type_) return { "type": "array", "items": _field_to_schema(None, item_type, exclude, definitions), } def _dict_to_schema() -> SchemaType: return { "type": "object", "additionalProperties": True, } def _union_to_schema( field: Optional[_Field], type_: Any, exclude: ExcludeFieldsType, definitions: Dict[str, SchemaType], ) -> SchemaType: return { "anyOf": [ _field_to_schema(field, arg_type, exclude, definitions) for arg_type in get_args(type_) ], } def _add_field_extra_to_schema(field: _Field, schema: SchemaType) -> None: if field.description: schema["description"] = field.description if field.default: schema["default"] = field.default schema.update(**field.constraints) def _field_to_schema( field: Optional[_Field], type_: Any, exclude: ExcludeFieldsType, definitions: Dict[str, SchemaType], ) -> SchemaType: type_ = _unpack_optional_type(type_) origin = get_origin(type_) schema: Optional[SchemaType] = None if field and field.name == TYPE_FIELD and field.default: schema = _type_field_to_schema(field) elif type_ in BUILTIN_TYPES_TO_SCHEMA: schema = {**BUILTIN_TYPES_TO_SCHEMA[type_]} elif isinstance(origin, type) and issubclass(origin, Sequence): schema = _list_to_schema(type_, definitions, exclude) elif isinstance(origin, type) and issubclass(origin, Mapping): schema = _dict_to_schema() elif origin is Union: schema = _union_to_schema(field, type_, exclude, definitions) elif issubclass(type_, BaseEntity): schema = type_.schema_as_ref(definitions, exclude) elif issubclass(type_, enum.Enum): schema = _enum_to_schema(type_) if not schema: raise TypeError(f"Schema building error for unknown type {type_}") if field: _add_field_extra_to_schema(field, schema) return schema class BaseEntity: __fields__: Mapping[str, _Field] __field_names__: Mapping[uuid.UUID, str] __field_types__: Mapping[str, type] __subclasses__: Dict[str, Type[BaseEntity]] = {} __related_templates__: FrozenSet[Type[BaseDiv]] = frozenset({}) __template_name__: Optional[str] = None def __init__(self, **kwargs: Any) -> None: if self.__fields__ and not self.__field_types__: class_name = self.__class__.__name__ raise TypeError( f"Class {class_name} fields are not prepared yet, you may " f"need to call {class_name}.update_forward_refs().", ) self._instance_related_templates: Set[Type[BaseDiv]] = set() for field_name, value in kwargs.items(): if value is not None: setattr(self, field_name, value) for field_name, field in self.__fields__.items(): if field_name not in kwargs: setattr(self, field_name, field.default) @classproperty @classmethod def template_name(cls) -> str: if cls.__template_name__ is not None: return cls.__template_name__ return f"{cls.__module__}.{cls.__name__}" def __init_subclass__(cls, *args: Any, **kwargs: Any) -> None: super().__init_subclass__() if cls.template_name in cls.__subclasses__: warnings.warn( f"Template {cls.template_name!r} already defined in " f"{cls.__subclasses__[cls.template_name]!r} " f"and will be replaced to {cls!r}", RuntimeWarning, ) cls.__subclasses__[cls.template_name] = cls cls.__fields__ = cls._extract_fields() cls.__field_names__ = cls._extract_field_names(cls.__fields__) cls._remove_fields_from_cls_attrs() cls.update_forward_refs() if cls.__init__ != BaseEntity.__init__: setattr(cls, "__init__", BaseEntity.__init__) def __setattr__(self, key: str, value: Any) -> None: if not key.startswith("_"): field_type = self.__field_types__.get(key) if field_type is None: raise KeyError( f"'{self.__class__.__name__}' object " f"has no attribute '{key}'", ) if key == TYPE_FIELD: self._validate_type_value(value) if not (isinstance(value, _Field) and value.ref_to): value = _cast_value_type(value, field_type) _update_related_templates(value, self._instance_related_templates) super().__setattr__(key, value) @classmethod def _extract_field_names( cls, fields: Mapping[str, _Field], ) -> Mapping[uuid.UUID, str]: return MappingProxyType( {field.uid: field_name for field_name, field in fields.items()}, ) @classmethod def _remove_fields_from_cls_attrs(cls) -> None: to_remove = [] for attr_name, attr_value in cls.__dict__.items(): if isinstance(attr_value, _Field): to_remove.append(attr_name) for attr_name in to_remove: delattr(cls, attr_name) def _validate_type_value(self, value: Any) -> None: field = self.__fields__[TYPE_FIELD] if field.default and field.default != value: raise ValueError( f"Property '{TYPE_FIELD}' has wrong value: '{value}'. " f"Expected is '{field.default}'.", ) @classmethod def update_forward_refs(cls) -> None: try: cls.__field_types__ = cls._extract_field_types(cls.__fields__) except (NameError, AttributeError): cls.__field_types__ = MappingProxyType({}) @classmethod def _extract_fields(cls) -> Mapping[str, _Field]: fields: Dict[str, _Field] = {} for base in cls.__bases__: base_fields = getattr(base, "__fields__", None) if issubclass(base, BaseEntity) and base_fields: fields.update(base_fields) for key, value in cls.__dict__.items(): if isinstance(value, _Field): field_ref = value.ref_to if field_ref: value = fields[key] if value.is_ref: raise ValueError("Ref cannot point to another ref") if value.default is not None: raise ValueError( f"Cannot create a ref for field {key} because " f"it has a default value", ) value.ref_to = field_ref if not value.name: value.name = key fields[key] = value return MappingProxyType(fields) @classmethod def _extract_field_types( cls, fields: Mapping[str, _Field], ) -> Mapping[str, type]: cls_hints = get_type_hints(cls, localns={cls.__name__: cls}) cls._validate_field_types_of_bases(cls_hints) cls._validate_field_types(fields, cls_hints) field_types = {} for key in fields.keys(): field_types[key] = cls_hints[key] for base in cls.__bases__: base_field_types = getattr(base, "__field_types__", None) if issubclass(base, BaseEntity) and base_field_types: field_types.update(base_field_types) for field_name, field_type in field_types.items(): field = cls.__fields__[field_name] if field.ref_to: field_types[field_name] = Optional[field_type] return MappingProxyType(field_types) @classmethod def _validate_field_types( cls, fields: Mapping[str, _Field], cls_hints: Mapping[str, Any], ) -> None: for key, field in fields.items(): hint = cls_hints.get(key) if not hint: raise ValueError( f"Type hint is missed for {cls.__name__}.{key}", ) if field.default is not None: _cast_value_type(field.default, hint) @classmethod def _validate_field_types_of_bases( cls, cls_hints: Mapping[str, Any], ) -> None: for base in cls.__bases__: base_hints = get_type_hints(base) for key, cls_type_hint in cls_hints.items(): if not isinstance(getattr(base, key, None), _Field): continue base_type_hint = base_hints.get(key) if base_type_hint and (cls_type_hint is not base_type_hint): raise ValueError( f"Type hint mismatch. _Field {cls.__name__}.{key} " "should match type hint of a parent class " f"{base.__name__}.{key}\n" f"Expected: {base_type_hint}. " f"Actual: {cls_type_hint}", ) @classmethod def _merge_ref_types( cls, *ref_types_seq: Mapping[uuid.UUID, Any], ) -> Mapping[uuid.UUID, Any]: all_refs: Dict[uuid.UUID, List[Any]] = defaultdict(list) for ref_types in ref_types_seq: for ref_uid, ref_type in ref_types.items(): all_refs[ref_uid].append(ref_type) return MappingProxyType( { ref_uid: reduce(_merge_types, ref_types) for ref_uid, ref_types in all_refs.items() }, ) @classmethod def _extract_ref_types_from_obj( cls, obj: Any, ) -> Mapping[uuid.UUID, Any]: if isinstance(obj, BaseEntity): return obj._extract_all_ref_types() elif isinstance(obj, list): ref_types_seq = [] for item in obj: if isinstance(item, BaseEntity): ref_types_seq.append(item._extract_all_ref_types()) return cls._merge_ref_types(*ref_types_seq) return MappingProxyType({}) def _extract_ref_types_from_values(self) -> Mapping[uuid.UUID, Any]: ref_types: Dict[uuid.UUID, Any] = {} for field_name, field_type in self.__field_types__.items(): field_value = getattr(self, field_name, None) if isinstance(field_value, _Field) and field_value.ref_to: field = self.__fields__[field_name] field_value.ref_to.apply_constraints(field.constraints) ref_uid = field_value.ref_to.uid if ref_uid in ref_types: ref_types[ref_uid] = _merge_types( ref_types[ref_uid], field_type, ) else: ref_types[ref_uid] = field_type return MappingProxyType(ref_types) def _extract_all_ref_types(self) -> Mapping[uuid.UUID, Any]: ref_types_seq = [self._extract_ref_types_from_values()] for field_name in self.__fields__: field_value = getattr(self, field_name, None) ref_types_seq.append(self._extract_ref_types_from_obj(field_value)) return self._merge_ref_types(*ref_types_seq) def related_templates(self) -> Set[Type[BaseDiv]]: return {*self.__related_templates__, *self._instance_related_templates} def dict(self) -> Dict[str, Any]: result: Dict[str, Any] = {} for field_name, field in self.__fields__.items(): field_value = getattr(self, field_name, field.default) if field_value is not None: if isinstance(field_value, _Field) and field_value.ref_to: result[ f"${field.field_name}" ] = field_value.ref_to.field_name else: result[field.field_name] = dump(field_value) return result @classmethod def _can_add_field_to_schema( cls, field_name: str, field: _Field, ) -> bool: return not field.ref_to @classmethod def _build_schema( cls, definitions: Dict[str, SchemaType], exclude: ExcludeFieldsType, ) -> SchemaType: properties: Dict[str, Any] = {} required: List[str] = [] for field_name, field in cls.__fields__.items(): if ( not cls._can_add_field_to_schema(field_name, field) or exclude.get(field_name) is True ): continue field_type = cls.__field_types__[field_name] properties[field_name] = _field_to_schema( field=field, type_=field_type, exclude=exclude.get(field_name, {}), definitions=definitions, ) if field_type != Optional[field_type]: required.append(field_name) schema: SchemaType = {"type": "object"} if properties: schema["properties"] = properties if required: schema["required"] = required return schema @classmethod def schema_as_ref( cls, definitions: Dict[str, SchemaType], exclude: ExcludeFieldsType, ) -> SchemaType: schema_name = cls.__name__ if exclude: serialized_exclude = json.dumps(exclude, sort_keys=True) exclude_hash = hashlib.md5(serialized_exclude.encode()).hexdigest() schema_name += f"_{exclude_hash}" if schema_name not in definitions: definitions[schema_name] = {} # to stop infinity recursion schema = cls._build_schema(definitions, exclude) definitions[schema_name] = schema return {"$ref": f"#/definitions/{schema_name}"} class BaseDiv(BaseEntity): __tpl_values__: Mapping[str, Any] __refs__: Mapping[str, Set[Optional[str]]] __local_referred_fields__: FrozenSet[str] __base_type__: Optional[str] = None __template__: Optional[Dict[str, Any]] = None def __init_subclass__(cls, *args: Any, **kwargs: Any) -> None: cls.__base_type__ = cls._get_base_type() cls.__local_referred_fields__ = cls._extract_local_referred_fields() cls.__template__ = None cls._inject_type_field() super().__init_subclass__(*args, **kwargs) @classmethod def _extract_local_referred_fields(cls) -> FrozenSet[str]: return frozenset( { field_name for field_name, field in cls.__dict__.items() if isinstance(field, _Field) and field.ref_to }, ) def __setattr__(self, key: str, value: Any) -> None: if (value is None) and (key in self.__tpl_values__): return super().__setattr__(key, value) @classmethod def update_forward_refs(cls) -> None: super().update_forward_refs() cls.__tpl_values__ = cls._extract_tpl_values(cls.__field_types__) cls.__field_types__ = cls._make_fields_optional_from_tpl_values( cls.__field_types__, cls.__tpl_values__, ) cls.__related_templates__ = cls._extract_related_templates( cls.__tpl_values__, ) cls._validate_ref_types() @classmethod def _make_fields_optional_from_tpl_values( cls, field_types: Mapping[str, type], tpl_values: Mapping[str, Any], ) -> Mapping[str, type]: new_field_types: Dict[str, Any] = {**field_types} for field_name in tpl_values: new_field_types[field_name] = Optional[new_field_types[field_name]] return MappingProxyType(new_field_types) @classmethod def _extract_related_templates( cls, tpl_values: Mapping[str, Any], ) -> FrozenSet[Type[BaseDiv]]: related_templates: Set[Type[BaseDiv]] = set() if cls.__base_type__: related_templates.add(cls) for base_cls in cls.__bases__: if issubclass(base_cls, BaseEntity): related_templates.update(base_cls.__related_templates__) for tpl_value in tpl_values.values(): _update_related_templates(tpl_value, related_templates) return frozenset(related_templates) @classmethod def _extract_tpl_values( cls, field_types: Mapping[str, Any], ) -> Mapping[str, Any]: tpl_values: Dict[str, Any] = {} for name, field_type in field_types.items(): if not hasattr(cls, name): continue value = getattr(cls, name) if value is not None: tpl_values[name] = _cast_value_type(value, field_type) delattr(cls, name) return MappingProxyType(tpl_values) @classmethod def _extract_ref_types_from_tpl_values(cls) -> Mapping[uuid.UUID, Any]: return cls._merge_ref_types( *( cls._extract_ref_types_from_obj(tpl_value) for tpl_value in cls.__tpl_values__.values() ), ) @classmethod def _extract_ref_types_from_fields(cls) -> Mapping[uuid.UUID, Any]: ref_types: Dict[uuid.UUID, Any] = {} for field_name, field in cls.__fields__.items(): if not field.ref_to: continue field.ref_to.apply_constraints(field.constraints) ref_uid = field.ref_to.uid field_type = cls.__field_types__[field_name] if ref_uid in ref_types: ref_types[ref_uid] = _merge_types( ref_types[ref_uid], field_type, ) else: ref_types[ref_uid] = field_type return MappingProxyType(ref_types) @staticmethod def _make_union(type: Type[Any]) -> Set[Type[Any]]: if get_origin(type) is Union: return set(get_args(type)) return {type} @classmethod def _validate_subclass( cls, ref_type: Type[Any], expected_field_type: Type[Any], ) -> bool: def _check_origins() -> Iterator[Tuple[Tuple[Any, ...], Tuple[Any, ...]]]: for ref in cls._make_union(ref_type): for expect in cls._make_union(expected_field_type): if get_origin(expect) == get_origin(ref): yield get_args(ref), get_args(expect) return None for origins in _check_origins(): if origins is None: return False expect, ref = origins if get_args(expect) == get_args(ref): return True if not any( issubclass(exp, get_args(ref)) for exp in get_args(expect) if get_origin(exp) is not Union ): continue return True return False @classmethod def _validate_ref_types(cls) -> None: ref_types = cls._merge_ref_types( cls._extract_ref_types_from_fields(), cls._extract_ref_types_from_tpl_values(), ) for ref_uid, ref_type in ref_types.items(): field_name = cls.__field_names__[ref_uid] expected_field_type = cls.__field_types__[field_name] if ( ref_type != expected_field_type and ref_type != Optional[expected_field_type] and ref_type != Union[expected_field_type, Expr] and ref_type != Union[expected_field_type, Expr, None] and not cls._validate_subclass(ref_type, expected_field_type) ): raise TypeError( f"Type of attribute '{field_name}' does " f"not match ref type {expected_field_type} != {ref_type}", ) @classmethod def _get_base_type(cls) -> Optional[str]: if len(cls.__bases__) > 1: raise TypeError( "Types conflict: base class cannot be uniquely identified", ) base_cls, *_ = cls.__bases__ if not issubclass(base_cls, BaseDiv): raise TypeError( "Types conflict: class must be derived from the BaseDiv class", ) type_field = base_cls.__fields__.get(TYPE_FIELD) if (base_cls is BaseDiv) or not (type_field and type_field.default): return None return type_field.default @classmethod def _inject_type_field(cls) -> None: if cls.__base_type__: type_value = cls.template_name setattr(cls, TYPE_FIELD, _Field(default=type_value)) @classmethod def template(cls) -> Dict[str, Any]: if not cls.__base_type__: raise TypeError(f"Component {cls.__name__} is not a template") if cls.__template__ is None: cls.__template__ = cls._build_template() return cls.__template__ @classmethod def _build_template(cls) -> Dict[str, Any]: template = {TYPE_FIELD: cls.__base_type__} for field_name, tpl_field_value in cls.__tpl_values__.items(): field = cls.__fields__[field_name] template[field.field_name] = dump(tpl_field_value) for field_name, field in cls.__fields__.items(): if field.ref_to and field_name in cls.__local_referred_fields__: template[f"${field.field_name}"] = field.ref_to.field_name return template @classmethod def _can_add_field_to_schema( cls, field_name: str, field: _Field, ) -> bool: return ( super()._can_add_field_to_schema(field_name, field) and field_name not in cls.__tpl_values__ ) @classmethod def schema( cls, exclude_fields: Optional[Sequence[str]] = None, ) -> SchemaType: definitions: Dict[str, SchemaType] = {} exclude = _make_exclude_fields(exclude_fields) schema = cls._build_schema(definitions, exclude) schema["definitions"] = definitions return schema