Source code for doctor.types

"""
Copyright © 2017, Encode OSS Ltd. All rights reserved.

Redistribution and use in source and binary forms, with or without modification,
are permitted provided that the following conditions are met:

Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.

Redistributions in binary form must reproduce the above copyright notice, this
list of conditions and the following disclaimer in the documentation and/or
other materials provided with the distribution.

Neither the name of the copyright holder nor the names of its contributors may
be used to endorse or promote products derived from this software without
specific prior written permission.

THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

This file is a modified version of the typingsystem.py module in apistar.
https://github.com/encode/apistar/blob/973c6485d8297c1bcef35a42221ac5107dce25d5/apistar/typesystem.py
"""
import math
import re
import typing
from datetime import datetime
from typing import Any

import isodate
import rfc3987

from doctor.errors import SchemaError, SchemaValidationError, TypeSystemError
from doctor.parsers import parse_value


StrOrList = typing.Union[str, typing.List[str]]


[docs]class classproperty(object): """A decorator that allows a class to contain a class property. This is a function that can be executed on a non-instance but accessed via a property. >>> class Foo(object): ... a = 1 ... @classproperty ... def b(cls): ... return cls.a + 1 ... >>> Foo.b 2 """ def __init__(self, fget): self.fget = fget def __get__(self, owner_self, owner_cls): return self.fget(owner_cls)
[docs]class MissingDescriptionError(ValueError): """An exception raised when a type is missing a description.""" pass
[docs]class SuperType(object): """A super type all custom types must extend from. This super type requires all subclasses define a description attribute that describes what the type represents. A `ValueError` will be raised if the subclass does not define a `description` attribute. """ #: The description of what the type represents. description = None # type: str #: An example value for the type. example: Any = None #: Indicates if the value of this type is allowed to be None. nullable = False # type: bool #: An optional name of where to find the request parameter if it does not #: match the variable name in your logic function. param_name = None # type: str #: An optional callable to parse a request paramter before it gets validated #: by a type. It should accept a single value paramter and return the #: parsed value. parser = None # type: typing.Callable def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) if self.description is None: cls = self.__class__ raise MissingDescriptionError( '{} did not define a description attribute'.format(cls))
[docs] @classmethod def validate(cls, value: typing.Any): """Additional validation for a type. All types will have a validate method where custom validation logic can be placed. The implementor should return nothing if the value is valid, otherwise a `TypeSystemError` should be raised. :param value: The value to be validated. """ pass
[docs]class UnionType(SuperType): """A type that can be one of any of the defined `types`. The first type that does not raise a :class:`~doctor.errors.TypeSystemError` will be used as the type for the variable. """ #: A list of allowed types. types = [] _native_type = None def __new__(cls, *args, **kwargs): if not cls.types: raise TypeSystemError( 'Sub-class must define a `types` list attribute containing at ' 'least 1 type.', cls=cls) valid = False value = None errors = {} for obj_class in cls.types: try: value = obj_class(*args, **kwargs) valid = True # Dynamically change the native_type based on that of the value. cls._native_type = obj_class.native_type break except TypeSystemError as e: errors[obj_class.__name__] = str(e) continue if not valid: klasses = [klass.__name__ for klass in cls.types] raise TypeSystemError('Value is not one of {}. {}'.format( klasses, errors)) cls.validate(value) return value
[docs] @classmethod def get_example(cls): """Returns an example value for the UnionType.""" return cls.types[0].get_example()
@classproperty def native_type(cls): """Returns the native type. Since UnionType can have multiple types, simply return the native type of the first type defined in the types attribute. If _native_type is set based on initializing a value with the class, then we return the dynamically modified type that matches that of the value used during instantiation. e.g. >>> from doctor.types import UnionType, string, boolean >>> class BoolOrStr(UnionType): ... description = 'bool or str' ... types = [boolean('a bool'), string('a string')] ... >>> BoolOrStr.native_type <class 'bool'> >>> BoolOrStr('str') 'str' >>> BoolOrStr.native_type <class 'str'> >>> BoolOrStr(False) False >>> BoolOrStr.native_type <class 'bool'> """ if cls._native_type is not None: return cls._native_type return cls.types[0].native_type
[docs]class String(SuperType, str): """Represents a `str` type.""" native_type = str errors = { 'blank': 'Must not be blank.', 'max_length': 'Must have no more than {max_length} characters.', 'min_length': 'Must have at least {min_length} characters.', 'pattern': 'Must match the pattern /{pattern}/.', } #: Will check format of the string for `date`, `date-time`, `email`, #: `time` and `uri`. format = None #: The maximum length of the string. max_length = None # type: int #: The minimum length of the string. min_length = None # type: int #: A regex pattern that the string should match. pattern = None # type: str #: Whether to trim whitespace on a string. Defaults to `True`. trim_whitespace = True def __new__(cls, *args, **kwargs): if cls.nullable and args[0] is None: return None value = super().__new__(cls, *args, **kwargs) if cls.trim_whitespace: value = value.strip() if cls.min_length is not None: if len(value) < cls.min_length: if cls.min_length == 1: raise TypeSystemError(cls=cls, code='blank') else: raise TypeSystemError(cls=cls, code='min_length') if cls.max_length is not None: if len(value) > cls.max_length: raise TypeSystemError(cls=cls, code='max_length') if cls.pattern is not None: if not re.search(cls.pattern, value): raise TypeSystemError(cls=cls, code='pattern') # Validate format, if specified if cls.format == 'date': try: value = datetime.strptime(value, "%Y-%m-%d").date() except ValueError as e: raise TypeSystemError(str(e), cls=cls) elif cls.format == 'date-time': try: value = isodate.parse_datetime(value) except (ValueError, isodate.ISO8601Error) as e: raise TypeSystemError(str(e), cls=cls) elif cls.format == 'email': if '@' not in value: raise TypeSystemError('Not a valid email address.', cls=cls) elif cls.format == 'time': try: value = datetime.strptime(value, "%H:%M:%S") except ValueError as e: raise TypeSystemError(str(e), cls=cls) elif cls.format == 'uri': try: rfc3987.parse(value, rule='URI') except ValueError as e: raise TypeSystemError(str(e), cls=cls) # Coerce value to the native str type. We only do this if the value # is an instance of the class. It could be a datetime instance or # a str already if `trim_whitespace` is True. if isinstance(value, cls): value = cls.native_type(value) cls.validate(value) return value
[docs] @classmethod def get_example(cls) -> str: """Returns an example value for the String type.""" if cls.example is not None: return cls.example return 'string'
[docs]class _NumericType(SuperType): """ Base class for both `Number` and `Integer`. """ native_type = None # type: type errors = { 'type': 'Must be a valid number.', 'finite': 'Must be a finite number.', 'minimum': 'Must be greater than or equal to {minimum}.', 'exclusive_minimum': 'Must be greater than {minimum}.', 'maximum': 'Must be less than or equal to {maximum}.', 'exclusive_maximum': 'Must be less than {maximum}.', 'multiple_of': 'Must be a multiple of {multiple_of}.', } #: The minimum value allowed. minimum = None # type: typing.Union[float, int] #: The maximum value allowed. maximum = None # type: typing.Union[float, int] #: The minimum value should be treated as exclusive or not. exclusive_minimum = False #: The maximum value should be treated as exclusive or not. exclusive_maximum = False #: The value is required to be a multiple of this value. multiple_of = None # type: typing.Union[float, int] def __new__(cls, *args, **kwargs): if cls.nullable and args[0] is None: return None try: value = cls.native_type.__new__(cls, *args, **kwargs) except (TypeError, ValueError): raise TypeSystemError(cls=cls, code='type') from None if not math.isfinite(value): raise TypeSystemError(cls=cls, code='finite') if cls.minimum is not None: if cls.exclusive_minimum: if value <= cls.minimum: raise TypeSystemError(cls=cls, code='exclusive_minimum') else: if value < cls.minimum: raise TypeSystemError(cls=cls, code='minimum') if cls.maximum is not None: if cls.exclusive_maximum: if value >= cls.maximum: raise TypeSystemError(cls=cls, code='exclusive_maximum') else: if value > cls.maximum: raise TypeSystemError(cls=cls, code='maximum') if cls.multiple_of is not None: if isinstance(cls.multiple_of, float): failed = not (value * (1 / cls.multiple_of)).is_integer() else: failed = value % cls.multiple_of if failed: raise TypeSystemError(cls=cls, code='multiple_of') # Coerce value to the native type. We only do this if the value # is an instance of the class. if isinstance(value, cls): value = cls.native_type(value) cls.validate(value) return value
[docs]class Number(_NumericType, float): """Represents a `float` type.""" native_type = float
[docs] @classmethod def get_example(cls) -> float: """Returns an example value for the Number type.""" if cls.example is not None: return cls.example return 3.14
[docs]class Integer(_NumericType, int): """Represents an `int` type.""" native_type = int
[docs] @classmethod def get_example(cls) -> int: """Returns an example value for the Integer type.""" if cls.example is not None: return cls.example return 1
[docs]class Boolean(SuperType): """Represents a `bool` type.""" native_type = bool errors = { 'type': 'Must be a valid boolean.' } def __new__(cls, *args, **kwargs) -> bool: value = args[0] if cls.nullable and value is None: return None if args and isinstance(value, str): try: value = { 'true': True, 'false': False, 'on': True, 'off': False, '1': True, '0': False, '': False }[value.lower()] except KeyError: raise TypeSystemError(cls=cls, code='type') from None cls.validate(value) return value cls.validate(value) return bool(*args, **kwargs)
[docs] @classmethod def get_example(cls) -> bool: """Returns an example value for the Boolean type.""" if cls.example is not None: return cls.example return True
[docs]class Enum(SuperType, str): """ Represents a `str` type that must be one of any defined allowed values. """ native_type = str errors = { 'invalid': 'Must be one of: {enum}', } #: A list of valid values. enum = [] # type: typing.List[str] #: Indicates if the values of the enum are case insensitive or not. case_insensitive = False #: If True the input value will be lowercased before validation. lowercase_value = False #: If True the input value will be uppercased before validation. uppercase_value = False def __new__(cls, value: typing.Union[None, str]): if cls.nullable and value is None: return None if cls.case_insensitive: if cls.uppercase_value: cls.enum = [v.upper() for v in cls.enum] else: cls.enum = [v.lower() for v in cls.enum] value = value.lower() if cls.lowercase_value: value = value.lower() if cls.uppercase_value: value = value.upper() if value not in cls.enum: raise TypeSystemError(cls=cls, code='invalid') cls.validate(value) return value
[docs] @classmethod def get_example(cls) -> str: """Returns an example value for the Enum type.""" if cls.example is not None: return cls.example return cls.enum[0]
[docs]class Object(SuperType, dict): """Represents a `dict` type.""" native_type = dict errors = { 'type': 'Must be an object.', 'invalid_key': 'Object keys must be strings.', 'required': 'This field is required.', 'additional_properties': 'Additional properties are not allowed.', } #: A mapping of property name to expected type. properties = {} # type: typing.Dict[str, typing.Any] #: A list of required properties. required = [] # type: typing.List[str] #: If True additional properties will be allowed, otherwise they will not. additional_properties = True # type: bool #: A human readable title for the object. title = None #: A mapping of property name to a list of other properties it requires #: when the property name is present. property_dependencies = {} # type: typing.Dict[str, typing.List[str]] def __init__(self, *args, **kwargs): if self.nullable and args[0] is None: return try: super().__init__(*args, **kwargs) except MissingDescriptionError: raise except (ValueError, TypeError): if (len(args) == 1 and not kwargs and hasattr(args[0], '__dict__')): value = dict(args[0].__dict__) else: raise TypeSystemError( cls=self.__class__, code='type') from None value = self # Ensure all property keys are strings. errors = {} if any(not isinstance(key, str) for key in value.keys()): raise TypeSystemError(cls=self.__class__, code='invalid_key') # Properties for key, child_schema in self.properties.items(): try: item = value[key] except KeyError: if hasattr(child_schema, 'default'): # If a key is missing but has a default, then use that. self[key] = child_schema.default elif key in self.required: exc = TypeSystemError(cls=self.__class__, code='required') errors[key] = exc.detail else: # Coerce value into the given schema type if needed. if isinstance(item, child_schema): self[key] = item else: try: self[key] = child_schema(item) except TypeSystemError as exc: errors[key] = exc.detail # If additional properties are allowed set any other key/value(s) not # in the defined properties. if self.additional_properties: for key, value in value.items(): if key not in self: self[key] = value # Raise an exception if additional properties are defined and # not allowed. if not self.additional_properties: properties = list(self.properties.keys()) for key in self.keys(): if key not in properties: detail = '{key} not in {properties}'.format( key=key, properties=properties) exc = TypeSystemError(detail, cls=self.__class__, code='additional_properties') errors[key] = exc.detail # Check for any property dependencies that are defined. if self.property_dependencies: err = 'Required properties {} for property `{}` are missing.' for prop, dependencies in self.property_dependencies.items(): if prop in self: for dep in dependencies: if dep not in self: raise TypeSystemError(err.format( dependencies, prop)) if errors: raise TypeSystemError(errors) self.validate(self.copy())
[docs] @classmethod def get_example(cls) -> dict: """Returns an example value for the Dict type. If an example isn't a defined attribute on the class we return a dict of example values based on each property's annotation. """ if cls.example is not None: return cls.example return {k: v.get_example() for k, v in cls.properties.items()}
[docs]class Array(SuperType, list): """Represents a `list` type.""" native_type = list errors = { 'type': 'Must be a list.', 'min_items': 'Not enough items.', 'max_items': 'Too many items.', 'unique_items': 'This item is not unique.', } #: The type each item should be, or a list of types where the position #: of the type in the list represents the type at that position in the #: array the item should be. items = None # type: typing.Union[type, typing.List[type]] #: If `items` is a list and this is `True` then additional items whose #: types aren't defined are allowed in the list. additional_items = False # type: bool #: The minimum number of items allowed in the list. min_items = 0 # type: typing.Optional[int] #: The maxiimum number of items allowed in the list. max_items = None # type: typing.Optional[int] #: If `True` items in the array should be unique from one another. unique_items = False # type: bool def __init__(self, *args, **kwargs): if self.nullable and args[0] is None: return if args and isinstance(args[0], (str, bytes)): raise TypeSystemError(cls=self.__class__, code='type') try: value = list(*args, **kwargs) except TypeError: raise TypeSystemError(cls=self.__class__, code='type') from None if isinstance(self.items, list) and len(self.items) > 1: if len(value) < len(self.items): raise TypeSystemError(cls=self.__class__, code='min_items') elif len(value) > len(self.items) and not self.additional_items: raise TypeSystemError(cls=self.__class__, code='max_items') if len(value) < self.min_items: raise TypeSystemError(cls=self.__class__, code='min_items') elif self.max_items is not None and len(value) > self.max_items: raise TypeSystemError(cls=self.__class__, code='max_items') # Ensure all items are of the right type. errors = {} if self.unique_items: seen_items = set() for pos, item in enumerate(value): try: if isinstance(self.items, list): if pos < len(self.items): item = self.items[pos](item) elif self.items is not None: item = self.items(item) if self.unique_items: if item in seen_items: raise TypeSystemError( cls=self.__class__, code='unique_items') else: seen_items.add(item) self.append(item) except TypeSystemError as exc: errors[pos] = exc.detail if errors: raise TypeSystemError(errors) self.validate(value)
[docs] @classmethod def get_example(cls) -> list: """Returns an example value for the Array type. If an example isn't a defined attribute on the class we return a list of 1 item containing the example value of the `items` attribute. If `items` is None we simply return a `[1]`. """ if cls.example is not None: return cls.example if cls.items is not None: if isinstance(cls.items, list): return [item.get_example() for item in cls.items] else: return [cls.items.get_example()] return [1]
[docs]class JsonSchema(SuperType): """Represents a type loaded from a json schema. NOTE: This class should not be used directly. Instead use :func:`~doctor.types.json_schema_type` to create a new class based on this one. """ json_type = None native_type = None #: The loaded ResourceSchema schema = None # type: doctor.resource.ResourceSchema #: The full path to the schema file. schema_file = None # type: str #: The key from the definitions in the schema file that the type should #: come from. definition_key = None # type: str def __new__(cls, value): # Attempt to parse the value if it came from a query string try: _, value = parse_value(value, [cls.json_type]) except ValueError: pass request_schema = None if cls.definition_key is not None: params = [cls.definition_key] request_schema = cls.schema._create_request_schema(params, params) data = {cls.definition_key: value} else: data = value super().__new__(cls) # Validate the data against the schema and raise an error if it # does not validate. validator = cls.schema.get_validator(request_schema) try: cls.schema.validate(data, validator) except SchemaValidationError as e: raise TypeSystemError(e.args[0], cls=cls) return value
[docs] @classmethod def get_example(cls) -> typing.Any: """Returns an example value for the JsonSchema type.""" return cls.example
#: A mapping of json types to native python types. JSON_TYPES_TO_NATIVE = { 'array': list, 'boolean': bool, 'integer': int, 'object': dict, 'number': float, 'string': str, }
[docs]def get_value_from_schema(schema, definition: dict, key: str, definition_key: str): """Gets a value from a schema and definition. If the value has references it will recursively attempt to resolve them. :param ResourceSchema schema: The resource schema. :param dict definition: The definition dict from the schema. :param str key: The key to use to get the value from the schema. :param str definition_key: The name of the definition. :returns: The value. :raises TypeSystemError: If the key can't be found in the schema/definition or we can't resolve the definition. """ resolved_definition = definition.copy() if '$ref' in resolved_definition: try: # NOTE: The resolve method recursively resolves references, so # we don't need to worry about that in this function. resolved_definition = schema.resolve(definition['$ref']) except SchemaError as e: raise TypeSystemError(str(e)) try: value = resolved_definition[key] except KeyError: # Before raising an error, the resolved definition may have an array # or object inside it that needs to be resolved in order to get # values. Attempt that here and then fail if we still can't find # the key we are looking for. # If the key was missing and this is an array, try to resolve it # from the items key. if resolved_definition['type'] == 'array': return [ get_value_from_schema(schema, resolved_definition['items'], key, definition_key) ] # If the key was missing and this is an object, resolve it from it's # properties. elif resolved_definition['type'] == 'object': value = {} for prop, definition in resolved_definition['properties'].items(): value[prop] = get_value_from_schema( schema, definition, key, definition_key) return value raise TypeSystemError( 'Definition `{}` is missing a {}.'.format( definition_key, key)) return value
[docs]def get_types(json_type: StrOrList) -> typing.Tuple[str, str]: """Returns the json and native python type based on the json_type input. If json_type is a list of types it will return the first non 'null' value. :param json_type: A json type or a list of json types. :returns: A tuple containing the json type and native python type. """ # If the type is a list, use the first non 'null' value as the type. if isinstance(json_type, list): for j_type in json_type: if j_type != 'null': json_type = j_type break return (json_type, JSON_TYPES_TO_NATIVE[json_type])
[docs]def json_schema_type(schema_file: str, **kwargs) -> typing.Type: """Create a :class:`~doctor.types.JsonSchema` type. This function will automatically load the schema and set it as an attribute of the class along with the description and example. :param schema_file: The full path to the json schema file to load. :param kwargs: Can include any attribute defined in :class:`~doctor.types.JsonSchema` """ # Importing here to avoid circular dependencies from doctor.resource import ResourceSchema schema = ResourceSchema.from_file(schema_file) kwargs['schema'] = schema # Look up the description, example and type in the schema. definition_key = kwargs.get('definition_key') if definition_key: params = [definition_key] request_schema = schema._create_request_schema(params, params) try: definition = request_schema['definitions'][definition_key] except KeyError: raise TypeSystemError( 'Definition `{}` is not defined in the schema.'.format( definition_key)) description = get_value_from_schema( schema, definition, 'description', definition_key) example = get_value_from_schema( schema, definition, 'example', definition_key) json_type = get_value_from_schema( schema, definition, 'type', definition_key) json_type, native_type = get_types(json_type) kwargs['description'] = description kwargs['example'] = example kwargs['json_type'] = json_type kwargs['native_type'] = native_type else: try: kwargs['description'] = schema.schema['description'] except KeyError: raise TypeSystemError('Schema is missing a description.') try: json_type = schema.schema['type'] except KeyError: raise TypeSystemError('Schema is missing a type.') json_type, native_type = get_types(json_type) kwargs['json_type'] = json_type kwargs['native_type'] = native_type try: kwargs['example'] = schema.schema['example'] except KeyError: # Attempt to load from properties, if defined. if schema.schema.get('properties'): example = {} for prop, definition in schema.schema['properties'].items(): example[prop] = get_value_from_schema( schema, definition, 'example', 'root') kwargs['example'] = example else: raise TypeSystemError('Schema is missing an example.') return type('JsonSchema', (JsonSchema,), kwargs)
[docs]def string(description: str, **kwargs) -> Any: """Create a :class:`~doctor.types.String` type. :param description: A description of the type. :param kwargs: Can include any attribute defined in :class:`~doctor.types.String` """ kwargs['description'] = description return type('String', (String,), kwargs)
[docs]def integer(description, **kwargs) -> Any: """Create a :class:`~doctor.types.Integer` type. :param description: A description of the type. :param kwargs: Can include any attribute defined in :class:`~doctor.types.Integer` """ kwargs['description'] = description return type('Integer', (Integer,), kwargs)
[docs]def number(description, **kwargs) -> Any: """Create a :class:`~doctor.types.Number` type. :param description: A description of the type. :param kwargs: Can include any attribute defined in :class:`~doctor.types.Number` """ kwargs['description'] = description return type('Number', (Number,), kwargs)
[docs]def boolean(description, **kwargs) -> Any: """Create a :class:`~doctor.types.Boolean` type. :param description: A description of the type. :param kwargs: Can include any attribute defined in :class:`~doctor.types.Boolean` """ kwargs['description'] = description return type('Boolean', (Boolean,), kwargs)
[docs]def enum(description, **kwargs) -> Any: """Create a :class:`~doctor.types.Enum` type. :param description: A description of the type. :param kwargs: Can include any attribute defined in :class:`~doctor.types.Enum` """ kwargs['description'] = description return type('Enum', (Enum,), kwargs)
[docs]def array(description, **kwargs) -> Any: """Create a :class:`~doctor.types.Array` type. :param description: A description of the type. :param kwargs: Can include any attribute defined in :class:`~doctor.types.Array` """ kwargs['description'] = description return type('Array', (Array,), kwargs)
[docs]def new_type(cls, **kwargs) -> Any: """Create a user defined type. The new type will contain all attributes of the `cls` type passed in. Any attribute's value can be overwritten using kwargs. :param kwargs: Can include any attribute defined in the provided user defined type. """ props = dict(cls.__dict__) props.update(kwargs) return type(cls.__name__, (cls,), props)