diff --git a/.gitignore b/.gitignore index 3e9a1a0..14313d8 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ *.py[cod] +.coverage.* # C extensions *.so diff --git a/Dockerfile b/Dockerfile index a2b496c..81e7019 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,12 +1,16 @@ # Install pytest python library as well as add all files in current directory -FROM python:3 AS base +FROM python:3.7 AS base WORKDIR /usr/src/app RUN apt-get update \ && apt-get install -y enchant \ && rm -rf /var/lib/apt/lists/* -RUN pip install --upgrade pip + RUN pip install coveralls -ADD . . +ADD requirements.txt ./ RUN pip install --no-cache-dir -r requirements.txt + +COPY . . +RUN pip install --no-cache-dir -e . + RUN python ./setup.py test CMD ["python", "./setup.py", "test"] diff --git a/HISTORY.rst b/HISTORY.rst index a7bae46..37a7905 100644 --- a/HISTORY.rst +++ b/HISTORY.rst @@ -3,6 +3,12 @@ History ------- +2.4 (2018-12-01) +++++++++++++++++ + +* Fixed length validator. +* Added Python 3.7 support. + 2.3 (2018-02-04) ++++++++++++++++ diff --git a/README.rst b/README.rst index 83a1d08..4de1860 100644 --- a/README.rst +++ b/README.rst @@ -86,7 +86,7 @@ Features >>> dog = Dog() >>> dog.validate() - *** ValidationError: Field "name" is required! + *** FieldValidationError: Error for field 'name': Field is required! * Cast models to python struct and JSON: @@ -337,6 +337,18 @@ Features >>> compare_schemas(schema1, schema2) False +* Create custom reusable fields: + + .. code-block:: python + + class NameField(fields.StringField): + def __init__(self): + super().__init__(required=True) + + class Person(models.Base): + name = NameField() + surnames = fields.DerivedListField(NameField()) + More ---- diff --git a/jsonmodels/__init__.py b/jsonmodels/__init__.py index a5b74b0..90f42ae 100644 --- a/jsonmodels/__init__.py +++ b/jsonmodels/__init__.py @@ -2,4 +2,4 @@ __author__ = 'Szczepan Cieślik' __email__ = 'szczepan.cieslik@gmail.com' -__version__ = '2.3' +__version__ = '2.4' diff --git a/jsonmodels/builders.py b/jsonmodels/builders.py index 2380421..a19efdc 100644 --- a/jsonmodels/builders.py +++ b/jsonmodels/builders.py @@ -67,6 +67,8 @@ def __init__(self, model_type, *args, **kwargs): def add_field(self, name, field, schema): _apply_validators_modifications(schema, field) + if isinstance(schema, dict) and field.help_text: + schema["description"] = field.help_text self.properties[name] = schema if field.required: self.required.append(name) @@ -78,7 +80,7 @@ def build(self): [self.maybe_build(value) for _, value in self.properties.items()] return '#/definitions/{name}'.format(name=self.type_name) else: - return builder.build_definition(nullable=self.nullable) + return builder.build_definition() @property def type_name(self): @@ -88,7 +90,7 @@ def type_name(self): ) return module_name.replace('.', '_').lower() - def build_definition(self, add_defintitions=True, nullable=False): + def build_definition(self, add_definitions=True): properties = dict( (name, self.maybe_build(value)) for name, value @@ -99,11 +101,14 @@ def build_definition(self, add_defintitions=True, nullable=False): 'additionalProperties': False, 'properties': properties, } + if self.required: schema['required'] = self.required - if self.definitions and add_defintitions: + + if self.definitions and add_definitions: schema['definitions'] = dict( - (builder.type_name, builder.build_definition(False, False)) + (builder.type_name, + builder.build_definition(add_definitions=False)) for builder in self.definitions ) return schema @@ -155,10 +160,9 @@ def build(self): obj_type = 'number' elif issubclass(self.type, float): obj_type = 'number' + schema['format'] = 'float' else: - raise errors.FieldNotSupported( - "Can't specify value schema!", self.type - ) + raise errors.FieldNotSupported(self.type) if self.nullable: obj_type = [obj_type, 'null'] diff --git a/jsonmodels/collections.py b/jsonmodels/collections.py index 950af96..6c756eb 100644 --- a/jsonmodels/collections.py +++ b/jsonmodels/collections.py @@ -10,6 +10,7 @@ class ModelCollection(list): """ def __init__(self, field): + super(ModelCollection, self).__init__() self.field = field def append(self, value): diff --git a/jsonmodels/errors.py b/jsonmodels/errors.py index 23b933b..200584b 100644 --- a/jsonmodels/errors.py +++ b/jsonmodels/errors.py @@ -1,15 +1,197 @@ +from typing import List, Tuple, Type class ValidationError(RuntimeError): - - pass + """ + The base validation error + """ class FieldNotFound(RuntimeError): - - pass + """ Error raised when a field is not found """ + def __init__(self, field_name: str): + """ + :param field_name: The name of the field. + """ + super(FieldNotFound, self).__init__('Field not found', field_name) + self.field_name = field_name class FieldNotSupported(ValueError): + def __init__(self, field_type: Type): + super(FieldNotSupported, self).__init__( + "Can't specify value schema!", field_type + ) + self.field_type = field_type + + +class ValidatorError(ValidationError): + """ + Base error for all errors caused by a validator. These errors do not + contain any information about which field generated them. Models + should catch this error and convert it to a FieldValidationError. + """ + + +class FieldValidationError(ValidationError): + """ + Enriches a validator error with the name of the field that caused it. + """ + def __init__(self, model_name: str, field_name: str, + given_value: any, error: ValidatorError): + """ + :param model_name: The name of the model. + :param field_name: The name of the field. + :param error: The validator error. + """ + tpl = "Error for field '{name}': {error}" + super(FieldValidationError, self).__init__(tpl.format( + name=field_name, error=error + )) + self.model_name = model_name + self.field_name = field_name + self.given_value = given_value + self.error = error + + +class RequiredFieldError(ValidatorError): + """ Error raised when a required field has no value """ + def __init__(self): + super(RequiredFieldError, self).__init__('Field is required!') + + +class RegexError(ValidatorError): + """ Error raised by the Regex validator """ + + def __init__(self, value: str, pattern: str): + tpl = 'Value "{value}" did not match pattern "{pattern}".' + super(RegexError, self).__init__(tpl.format( + value=value, pattern=pattern + )) + self.value = value + self.pattern = pattern + + +class BadTypeError(ValidatorError): + """ + Error raised when the user gives a type that does not match the + expected one + """ + + def __init__(self, value: any, types: Tuple, is_list: bool): + """ + :param value: The given value. + :param types: The accepted types. + :param is_list: Whether the error occurred in the items of a list. + """ + if is_list: + tpl = 'All items must be instances of "{types}", and not "{type}".' + else: + tpl = 'Value is wrong, expected type "{types}", received {value}.' + super(BadTypeError, self).__init__(tpl.format( + types=', '.join([t.__name__ for t in types]), + value=value, + type=type(value).__name__ + )) + self.value = value + self.types = types + self.is_array = is_list + + +class AmbiguousTypeError(ValidatorError): + """ + Error that occurs if the user gives a dictionary to an embedded field + that supports multiple types + """ + + def __init__(self, types: Tuple): + """ The types that are allowed """ + tpl = 'Cannot decide which type to choose from "{types}".' + super(AmbiguousTypeError, self).__init__(tpl.format( + types=', '.join([t.__name__ for t in types]) + )) + self.types = types + + +class MinLengthError(ValidatorError): + """ Error raised by the Length validator when too few items are present """ + + def __init__(self, value: list, minimum_length: int): + """ + :param value: The given value. + :param minimum_length: The minimum length expected. + """ + tpl = "Value '{value}' length is lower than allowed minimum '{min}'." + super(MinLengthError, self).__init__(tpl.format( + value=value, min=minimum_length + )) + self.value = value + self.minimum_length = minimum_length + + +class MaxLengthError(ValidatorError): + """ Error raised by the Length validator when receiving too many items """ + + def __init__(self, value: list, maximum_length: int): + """ + :param value: The given value. + :param maximum_length: The maximum length expected. + """ + tpl = "Value '{value}' length is bigger than allowed maximum '{max}'." + super(MaxLengthError, self).__init__(tpl.format( + value=value, max=maximum_length + )) + self.value = value + self.maximum_length = maximum_length + + +class MinValidationError(ValidatorError): + """ Error raised by the Min validator """ + + def __init__(self, value, minimum_value, exclusive: bool): + """ + :param value: The given value. + :param minimum_value: The minimum value allowed. + :param exclusive: Whether the validation is inclusive or not. + """ + tpl = "'{value}' is lower or equal than minimum ('{min}')." \ + if exclusive else "'{value}' is lower than minimum ('{min}')." + super(MinValidationError, self).__init__(tpl.format( + value=value, min=minimum_value + )) + self.value = value + self.minimum_value = minimum_value + self.exclusive = exclusive + + +class MaxValidationError(ValidatorError): + """ Error raised by the Max validator """ + + def __init__(self, value, maximum_value, exclusive: bool): + """ + :param value: The given value. + :param maximum_value: The maximum value allowed. + :param exclusive: Whether the validation is inclusive or not. + """ + tpl = "'{value}' is bigger or equal than maximum ('{max}')." \ + if exclusive else "'{value}' is bigger than maximum ('{max}')." + super(MaxValidationError, self).__init__(tpl.format( + value=value, max=maximum_value + )) + self.value = value + self.maximum_value = maximum_value + self.exclusive = exclusive + + +class EnumError(ValidatorError): + """ Error raised by the Enum validator """ - pass + def __init__(self, value: any, choices: List[any]): + """ + :param value: The given value. + :param choices: The allowed choices. + """ + tpl = "Value '{val}' is not a valid choice." + super(EnumError, self).__init__(tpl.format(val=value)) + self.value = value + self.choices = choices diff --git a/jsonmodels/fields.py b/jsonmodels/fields.py index 4e60d1e..93a1f64 100644 --- a/jsonmodels/fields.py +++ b/jsonmodels/fields.py @@ -1,14 +1,14 @@ -import datetime -import re -from copy import deepcopy +import warnings from weakref import WeakKeyDictionary +import datetime +import re import six from dateutil.parser import parse +from typing import List, Optional, Dict, Set -from .errors import ValidationError from .collections import ModelCollection - +from .errors import RequiredFieldError, BadTypeError, AmbiguousTypeError # unique marker for "no default value specified". None is not good enough since # it is a completely valid default value. @@ -84,21 +84,46 @@ def validate(self, value): def _check_against_required(self, value): if value is None and self.required: - raise ValidationError('Field is required!') + raise RequiredFieldError() def _validate_against_types(self, value): if value is not None and not isinstance(value, self.types): - raise ValidationError( - 'Value is wrong, expected type "{types}", received {value}.' - .format(types=', '.join([t.__name__ for t in self.types]), - value=value) - ) + raise BadTypeError(value, self.types, is_list=False) def _check_types(self): if self.types is None: - raise ValidationError( - 'Field "{type}" is not usable, try ' - 'different field type.'.format(type=type(self).__name__)) + tpl = 'Field "{type}" is not usable, try different field type.' + raise ValueError(tpl.format(type=type(self).__name__)) + + @staticmethod + def _get_embed_type(value, models): + """ + Tries to guess which of the given models is applicable to the dict. + :param value: The dict to check. + :param models: A list of acceptable models. + :return: A single model from the list that contains all the fields + that are also in the dict. + :raise AmbiguousTypeError: If more than one model is matched. + """ + if len(models) > 1: + # dict of the available fields per model, so we can automatically + # recognize dicts + model_fields = { + model: { + name or attr for attr, name, field + in model.iterate_with_name() + } for model in models + if hasattr(model, "iterate_with_name") + } # type: Dict[type, Set[str]] + matching_models = [model for model, fields in model_fields.items() + if fields.issuperset(value)] + + if len(matching_models) != 1: + raise AmbiguousTypeError(models) + + # this is the only model that has all given fields + return matching_models[0] + return models[0] def to_struct(self, value): """Cast value to Python structure.""" @@ -134,12 +159,17 @@ def get_default_value(self): def _validate_name(self): if self.name is None: return - if not re.match('^[A-Za-z_](([\w\-]*)?\w+)?$', self.name): + if not re.match(r'^[A-Za-z_](([\w\-]*)?\w+)?$', self.name): raise ValueError('Wrong name', self.name) - def structue_name(self, default): + def structure_name(self, default): return self.name if self.name is not None else default + def structue_name(self, default): + warnings.warn("`structue_name` is deprecated, please use " + "`structure_name`") + return self.structure_name(default) + class StringField(BaseField): @@ -159,7 +189,10 @@ def parse_value(self, value): parsed = super(IntField, self).parse_value(value) if parsed is None: return parsed - return int(parsed) + try: + return int(parsed) + except ValueError: + raise BadTypeError(value, types=(int,), is_list=False) class FloatField(BaseField): @@ -185,14 +218,16 @@ class ListField(BaseField): """List field.""" - types = (list,) + types = (list, tuple) - def __init__(self, items_types=None, item_validators=(), *args, **kwargs): + def __init__(self, items_types=None, item_validators=(), omit_empty=False, + *args, **kwargs): """Init. `ListField` is **always not required**. If you want to control number of items use validators. If you want to validate each individual item, - use `item_validators`. + use `item_validators`. You may pass omit_empty so empty lists are not + included in the to_struct method. """ self._assign_types(items_types) @@ -201,6 +236,7 @@ def __init__(self, items_types=None, item_validators=(), *args, **kwargs): else item_validators or [] super(ListField, self).__init__(*args, **kwargs) self.required = False + self._omit_empty = omit_empty def get_default_value(self): default = super(ListField, self).get_default_value() @@ -242,12 +278,7 @@ def validate_single_value(self, value): return if not isinstance(value, self.items_types): - raise ValidationError( - 'All items must be instances ' - 'of "{types}", and not "{type}".'.format( - types=', '.join([t.__name__ for t in self.items_types]), - type=type(value).__name__, - )) + raise BadTypeError(value, self.items_types, is_list=True) def parse_value(self, values): """Cast value to proper collection.""" @@ -265,31 +296,20 @@ def _cast_value(self, value): if isinstance(value, self.items_types): return value elif isinstance(value, dict): - if len(self.items_types) != 1: - tpl = 'Cannot decide which type to choose from "{types}".' - raise ValidationError( - tpl.format( - types=', '.join([t.__name__ for t in self.items_types]) - ) - ) - return self.items_types[0](**value) + model_type = self._get_embed_type(value, self.items_types) + return model_type(**value) else: - raise ValidationError( - 'All items must be instances ' - 'of "{types}", and not "{type}".'.format( - types=', '.join([t.__name__ for t in self.items_types]), - type=type(value).__name__, - )) + raise BadTypeError(value, self.items_types, is_list=True) def _finish_initialization(self, owner): super(ListField, self)._finish_initialization(owner) types = [] - for type in self.items_types: - if isinstance(type, _LazyType): - types.append(type.evaluate(owner)) + for item_type in self.items_types: + if isinstance(item_type, _LazyType): + types.append(item_type.evaluate(owner)) else: - types.append(type) + types.append(item_type) self.items_types = tuple(types) def _elem_to_struct(self, value): @@ -299,7 +319,54 @@ def _elem_to_struct(self, value): return value def to_struct(self, values): - return [self._elem_to_struct(v) for v in values] + return [self._elem_to_struct(v) for v in values] \ + if values or not self._omit_empty else None + + +class DerivedListField(ListField): + """ + A list field that has another field for its items. + """ + + def __init__(self, field: BaseField, *args, **kwargs): + """ + :param field: The field that will be in each of the items of the list. + :param help_text: The help text of the list field. + :param validators: The validators for the list field. + """ + super(DerivedListField, self).__init__( + items_types=field.types, + item_validators=field.validators, + *args, **kwargs, + ) + self._field = field + + def to_struct(self, values: List[any]) -> List[any]: + """ + Converts the list to its output format. + :param values: The values in the list. + :return: The converted values. + """ + return [self._field.to_struct(value) for value in values] \ + if values or not self._omit_empty else None + + def parse_value(self, values: List[any]) -> List[any]: + """ + Converts the list to its internal format. + :param values: The values in the list. + :return: The converted values. + """ + try: + return [self._field.parse_value(value) for value in values] + except TypeError: + raise BadTypeError(values, self._field.types, is_list=True) + + def validate_single_value(self, value: any) -> None: + """ + Validates a single value in the list. + :param value: One of the values in the list. + """ + self._field.validate(value) class EmbeddedField(BaseField): @@ -324,13 +391,13 @@ def _assign_model_types(self, model_types): def _finish_initialization(self, owner): super(EmbeddedField, self)._finish_initialization(owner) - types = [] - for type in self.types: - if isinstance(type, _LazyType): - types.append(type.evaluate(owner)) + for model_type in self.types: + if isinstance(model_type, _LazyType): + types.append(model_type.evaluate(owner)) else: - types.append(type) + types.append(model_type) + self.types = tuple(types) def validate(self, value): @@ -345,31 +412,85 @@ def parse_value(self, value): if not isinstance(value, dict): return value - embed_type = self._get_embed_type() + embed_type = self._get_embed_type(value, self.types) return embed_type(**value) - def _get_embed_type(self): - if len(self.types) != 1: - raise ValidationError( - 'Cannot decide which type to choose from "{types}".'.format( - types=', '.join([t.__name__ for t in self.types]) - ) - ) - return self.types[0] - def to_struct(self, value): return value.to_struct() -class DictField(BaseField): +class MapField(BaseField): """ - Field for dictionaries that are not modelled. + Model field that keeps a mapping between two other fields. + It is basically a dictionary with key and values being separate fields. + + `MapField` is **always not required**. If you want to control number + of items use validators. You may pass omit_empty so empty lists are not + included in the to_struct method. + """ - types = dict, + types = (dict,) - def to_struct(self, value): - """Cast value to Python structure.""" - return deepcopy(value) + def __init__(self, key_field: BaseField, value_field: BaseField, + **kwargs): + """ + :param key_field: The field that is responsible for converting and + validating the keys in this mapping. + :param value_field: The field that is responsible for converting and + validating the values in this mapping. + :param kwargs: Other keyword arguments to the base class. + """ + super(MapField, self).__init__(**kwargs) + self._key_field = key_field + self._value_field = value_field + + def _finish_initialization(self, owner): + """ + Completes the initialization of the fields, allowing for lazy refs. + """ + super(MapField, self)._finish_initialization(owner) + self._key_field._finish_initialization(owner) + self._value_field._finish_initialization(owner) + + def get_default_value(self) -> any: + """ Gets the default value for this field """ + default = super(MapField, self).get_default_value() + if default is None and self.required: + return dict() + return default + + def parse_value(self, values: Optional[dict]) -> Optional[dict]: + """ Parses the given values into a new dict. """ + values = super().parse_value(values) + if values is None: + return + items = [ + (self._key_field.parse_value(key), + self._value_field.parse_value(value)) + for key, value in values.items() + ] + return type(values)(items) # Preserves OrderedDict + + def to_struct(self, values: Optional[dict]) -> Optional[dict]: + """ Casts the field values into a dict. """ + items = [ + (self._key_field.to_struct(key), + self._value_field.to_struct(value)) + for key, value in values.items() + ] + return type(values)(items) # Preserves OrderedDict + + def validate(self, values: Optional[dict]) -> Optional[dict]: + """ + Validates all keys and values in the map field. + :param values: The values in the mapping. + """ + super(MapField, self).validate(values) + if values is None: + return + for key, value in values.items(): + self._key_field.validate(key) + self._value_field.validate(value) class _LazyType(object): @@ -511,3 +632,30 @@ def parse_value(self, value): return parse(value) else: return None + + +class GenericField(BaseField): + """ + Field that supports any kind of value, converting models to their correct + struct, keeping ordered dictionaries in their original order. + """ + types = (any,) + + def _validate_against_types(self, value) -> None: + pass + + def to_struct(self, values: any) -> any: + """ Casts value to Python structure. """ + from .models import Base + if isinstance(values, Base): + return values.to_struct() + + if isinstance(values, (list, tuple)): + return [self.to_struct(value) for value in values] + + if isinstance(values, dict): + items = [(self.to_struct(key), self.to_struct(value)) + for key, value in values.items()] + return type(values)(items) # preserves OrderedDict + + return values diff --git a/jsonmodels/models.py b/jsonmodels/models.py index 0ee489f..350393e 100644 --- a/jsonmodels/models.py +++ b/jsonmodels/models.py @@ -2,7 +2,7 @@ from . import parsers, errors from .fields import BaseField -from .errors import ValidationError +from .errors import FieldValidationError, ValidatorError, ValidationError class JsonmodelMeta(type): @@ -19,10 +19,10 @@ def validate_fields(attributes): } taken_names = set() for name, field in fields.items(): - structue_name = field.structue_name(name) - if structue_name in taken_names: - raise ValueError('Name taken', structue_name, name) - taken_names.add(structue_name) + structure_name = field.structure_name(name) + if structure_name in taken_names: + raise ValueError('Name taken', structure_name, name) + taken_names.add(structure_name) class Base(six.with_metaclass(JsonmodelMeta, object)): @@ -51,15 +51,15 @@ def get_field(self, field_name): if field_name == attr_name: return field - raise errors.FieldNotFound('Field not found', field_name) + raise errors.FieldNotFound(field_name) def set_field(self, field, field_name, value): """ Sets the value of a field. """ try: field.__set__(self, value) - except ValidationError as error: - raise ValidationError("Error for field '{name}': {error}" - .format(name=field_name, error=error)) + except ValidatorError as error: + raise FieldValidationError(type(self).__name__, field_name, + value, error) def __iter__(self): """Iterate through fields and values.""" @@ -71,28 +71,29 @@ def validate(self): for name, field in self: try: field.validate_for_object(self) - except ValidationError as error: - raise ValidationError("Error for field '{name}': {error}" - .format(name=name, error=error)) + except ValidatorError as error: + value = field.memory.get(self._cache_key) + raise FieldValidationError(type(self).__name__, name, + value, error) @classmethod def iterate_over_fields(cls): """Iterate through fields as `(attribute_name, field_instance)`.""" for attr in dir(cls): - clsattr = getattr(cls, attr) - if isinstance(clsattr, BaseField): - yield attr, clsattr + class_attribute = getattr(cls, attr) + if isinstance(class_attribute, BaseField): + yield attr, class_attribute @classmethod def iterate_with_name(cls): """Iterate over fields, but also give `structure_name`. - Format is `(attribute_name, structue_name, field_instance)`. + Format is `(attribute_name, structure_name, field_instance)`. Structure name is name under which value is seen in structure and schema (in primitives) and only there. """ for attr_name, field in cls.iterate_over_fields(): - structure_name = field.structue_name(attr_name) + structure_name = field.structure_name(attr_name) yield attr_name, structure_name, field def to_struct(self): @@ -127,9 +128,9 @@ def __str__(self): def __setattr__(self, name, value): try: return super(Base, self).__setattr__(name, value) - except ValidationError as error: - raise ValidationError("Error for field '{name}': {error}" - .format(name=name, error=error)) + except ValidatorError as error: + raise FieldValidationError(type(self).__name__, name, + value, error) def __eq__(self, other): if type(other) is not type(self): @@ -138,12 +139,12 @@ def __eq__(self, other): for name, _ in self.iterate_over_fields(): try: our = getattr(self, name) - except errors.ValidationError: + except ValidationError: our = None try: their = getattr(other, name) - except errors.ValidationError: + except ValidationError: their = None if our != their: diff --git a/jsonmodels/parsers.py b/jsonmodels/parsers.py index 97b65cb..3f0f60d 100644 --- a/jsonmodels/parsers.py +++ b/jsonmodels/parsers.py @@ -5,8 +5,8 @@ def to_struct(model): - """Cast instance of model to python structure. - + """ + Cast instance of model to python structure. :param model: Model to be casted. :rtype: ``dict`` @@ -20,7 +20,8 @@ def to_struct(model): continue value = field.to_struct(value) - resp[name] = value + if value is not None: + resp[name] = value return resp @@ -82,27 +83,34 @@ def build_json_schema_primitive(cls, parent_builder): def _create_primitive_field_schema(field): + schema = {'type': _get_schema_type(field)} + + if isinstance(field, fields.FloatField): + schema['format'] = 'float' + elif isinstance(field, fields.DateField): + schema['format'] = 'date' + elif isinstance(field, fields.DateTimeField): + schema['format'] = 'date-time' + + if field.has_default: + schema["default"] = field._default + + return schema + + +def _get_schema_type(field): if isinstance(field, fields.StringField): obj_type = 'string' elif isinstance(field, fields.IntField): obj_type = 'number' elif isinstance(field, fields.FloatField): - obj_type = 'float' + obj_type = 'number' elif isinstance(field, fields.BoolField): obj_type = 'boolean' - elif isinstance(field, fields.DictField): + elif isinstance(field, fields.GenericField): obj_type = 'object' else: - raise errors.FieldNotSupported( - 'Field {field} is not supported!'.format( - field=type(field).__class__.__name__)) - + raise errors.FieldNotSupported(type(field)) if field.nullable: obj_type = [obj_type, 'null'] - - schema = {'type': obj_type} - - if field.has_default: - schema["default"] = field._default - - return schema + return obj_type diff --git a/jsonmodels/utilities.py b/jsonmodels/utilities.py index 9b9cdc2..5e864ed 100644 --- a/jsonmodels/utilities.py +++ b/jsonmodels/utilities.py @@ -44,12 +44,13 @@ def _compare_lists(one, two): if len(one) != len(two): return False - they_match = False + they_match = not one # two empty lists are equal for first_item in one: for second_item in two: if they_match: continue they_match = compare_schemas(first_item, second_item) + return they_match @@ -133,7 +134,7 @@ def convert_ecma_regex_to_python(value): return PythonRegex('/'.join(parts[1:]), result_flags) -def convert_python_regex_to_ecma(value, flags=[]): +def convert_python_regex_to_ecma(value, flags=()): """Convert Python regex to ECMA 262 regex. If given value is already ECMA regex it will be returned unchanged. diff --git a/jsonmodels/validators.py b/jsonmodels/validators.py index 0e18cb4..480141a 100644 --- a/jsonmodels/validators.py +++ b/jsonmodels/validators.py @@ -3,7 +3,8 @@ from six.moves import reduce -from .errors import ValidationError +from .errors import MinValidationError, MaxValidationError, BadTypeError, \ + RegexError, MinLengthError, MaxLengthError, EnumError from . import utilities @@ -24,16 +25,9 @@ def __init__(self, minimum_value, exclusive=False): def validate(self, value): """Validate value.""" - if self.exclusive: - if value <= self.minimum_value: - tpl = "'{value}' is lower or equal than minimum ('{min}')." - raise ValidationError( - tpl.format(value=value, min=self.minimum_value)) - else: - if value < self.minimum_value: - raise ValidationError( - "'{value}' is lower than minimum ('{min}').".format( - value=value, min=self.minimum_value)) + if value < self.minimum_value \ + or (self.exclusive and value == self.minimum_value): + raise MinValidationError(value, self.minimum_value, self.exclusive) def modify_schema(self, field_schema): """Modify field schema.""" @@ -59,16 +53,9 @@ def __init__(self, maximum_value, exclusive=False): def validate(self, value): """Validate value.""" - if self.exclusive: - if value >= self.maximum_value: - tpl = "'{val}' is bigger or equal than maximum ('{max}')." - raise ValidationError( - tpl.format(val=value, max=self.maximum_value)) - else: - if value > self.maximum_value: - raise ValidationError( - "'{value}' is bigger than maximum ('{max}').".format( - value=value, max=self.maximum_value)) + if value > self.maximum_value \ + or (self.exclusive and value == self.maximum_value): + raise MaxValidationError(value, self.maximum_value, self.exclusive) def modify_schema(self, field_schema): """Modify field schema.""" @@ -117,16 +104,13 @@ def validate(self, value): try: result = re.search(self.pattern, value, flags) - except TypeError as te: - raise ValidationError(*te.args) + except TypeError: + raise BadTypeError(value, (str,), is_list=False) if not result: if self.custom_error: raise self.custom_error - raise ValidationError( - 'Value "{value}" did not match pattern "{pattern}".'.format( - value=value, pattern=self.pattern - )) + raise RegexError(value, self.pattern) def _calculate_flags(self): return reduce(lambda x, y: x | y, self.flags, 0) @@ -134,7 +118,8 @@ def _calculate_flags(self): def modify_schema(self, field_schema): """Modify field schema.""" field_schema['pattern'] = utilities.convert_python_regex_to_ecma( - self.pattern, self.flags) + self.pattern, self.flags + ) class Length(object): @@ -153,7 +138,8 @@ def __init__(self, minimum_value=None, maximum_value=None): """ if minimum_value is None and maximum_value is None: raise ValueError( - "Either 'minimum_value' or 'maximum_value' must be specified.") + "Either 'minimum_value' or 'maximum_value' must be specified." + ) self.minimum_value = minimum_value self.maximum_value = maximum_value @@ -163,18 +149,10 @@ def validate(self, value): len_ = len(value) if self.minimum_value is not None and len_ < self.minimum_value: - tpl = "Value '{val}' length is lower than allowed minimum '{min}'." - raise ValidationError(tpl.format( - val=value, min=self.minimum_value - )) + raise MinLengthError(value, self.minimum_value) if self.maximum_value is not None and len_ > self.maximum_value: - raise ValidationError( - "Value '{val}' length is bigger than " - "allowed maximum '{max}'.".format( - val=value, - max=self.maximum_value, - )) + raise MaxLengthError(value, self.maximum_value) def modify_schema(self, field_schema): """Modify field schema.""" @@ -203,8 +181,7 @@ def __init__(self, *choices): def validate(self, value): if value not in self.choices: - tpl = "Value '{val}' is not a valid choice." - raise ValidationError(tpl.format(val=value)) + raise EnumError(value, self.choices) def modify_schema(self, field_schema): field_schema['enum'] = self.choices diff --git a/requirements.txt b/requirements.txt index a94f34d..9925baf 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,3 @@ --e . Jinja2 MarkupSafe Pygments diff --git a/tests/fixtures/schema2.json b/tests/fixtures/schema2.json index 0892028..34bd5ba 100644 --- a/tests/fixtures/schema2.json +++ b/tests/fixtures/schema2.json @@ -32,12 +32,17 @@ } } }, - "required": ["surname", "name"], + "required": ["name", "surname"], "additionalProperties": false }, - "type": "array" + "type": "array", + "default": [{ + "name": "Name", + "surname": "Surname", + "toys": [] + }] } }, - "required": ["surname", "name"], + "required": ["name", "surname"], "additionalProperties": false } diff --git a/tests/fixtures/schema3.json b/tests/fixtures/schema3.json index 1b13ce1..3ae071b 100644 --- a/tests/fixtures/schema3.json +++ b/tests/fixtures/schema3.json @@ -13,7 +13,8 @@ "type": "string" }, "capacity": { - "type": "float" + "type": "number", + "format": "float" } }, "type": "object" @@ -25,7 +26,8 @@ "type": "string" }, "velocity": { - "type": "float" + "type": "number", + "format": "float" } }, "type": "object" @@ -52,7 +54,8 @@ "additionalProperties": false, "properties": { "battery_voltage": { - "type": "float" + "type": "number", + "format": "float" }, "name": { "type": "string" diff --git a/tests/fixtures/schema4.json b/tests/fixtures/schema4.json index 0d0cff5..e8190ac 100644 --- a/tests/fixtures/schema4.json +++ b/tests/fixtures/schema4.json @@ -2,10 +2,12 @@ "additionalProperties": false, "properties": { "date": { - "type": "string" + "type": "string", + "format": "date" }, "end": { - "type": "string" + "type": "string", + "format": "date-time" }, "time": { "type": "string" diff --git a/tests/fixtures/schema_circular2.json b/tests/fixtures/schema_circular2.json index bd0b274..5ade82e 100644 --- a/tests/fixtures/schema_circular2.json +++ b/tests/fixtures/schema_circular2.json @@ -26,7 +26,8 @@ "type": "string" }, "size": { - "type": "float" + "type": "number", + "format": "float" } }, "type": "object" diff --git a/tests/fixtures/schema_enum.json b/tests/fixtures/schema_enum.json index 7ae1788..3c837aa 100644 --- a/tests/fixtures/schema_enum.json +++ b/tests/fixtures/schema_enum.json @@ -2,6 +2,7 @@ "additionalProperties": false, "properties": { "handness": { + "description": "The person's favorite hand.", "type": "string", "enum": ["left", "right"] } diff --git a/tests/fixtures/schema_with_list.json b/tests/fixtures/schema_with_list.json index a12229f..fd8e07c 100644 --- a/tests/fixtures/schema_with_list.json +++ b/tests/fixtures/schema_with_list.json @@ -33,6 +33,7 @@ } ] }, + "description": "A list of names.", "type": "array" } }, diff --git a/tests/test_data_initialization.py b/tests/test_data_initialization.py index db3036d..1cc6067 100644 --- a/tests/test_data_initialization.py +++ b/tests/test_data_initialization.py @@ -1,8 +1,9 @@ +import datetime import pytest import six -import datetime from jsonmodels import models, fields, errors +from jsonmodels.errors import FieldValidationError def test_initialization(): @@ -345,6 +346,10 @@ class Counter(models.Base): counter2 = Counter(value='2') assert isinstance(counter2.value, int) assert counter2.value == 2 + + with pytest.raises(FieldValidationError): + Counter(value='2X') + if not six.PY3: counter3 = Counter(value=long(3)) # noqa assert isinstance(counter3.value, int) diff --git a/tests/test_fields.py b/tests/test_fields.py index ac94d03..3c21235 100644 --- a/tests/test_fields.py +++ b/tests/test_fields.py @@ -1,4 +1,15 @@ -from jsonmodels import models, fields +from collections import OrderedDict + +import pytest +from jsonmodels import models, fields, validators, errors + + +def test_deprecated_structue_name(): + field = fields.BoolField(name='field') + assert field.structue_name('default') == 'field' + + field = fields.BoolField() + assert field.structue_name('default') == 'default' def test_bool_field(): @@ -28,3 +39,168 @@ class Person(models.Base): assert field.parse_value(0) is False assert field.parse_value('') is False assert field.parse_value([]) is False + + +def test_custom_field(): + class NameField(fields.StringField): + def __init__(self): + super(NameField, self).__init__(required=True) + + class Person(models.Base): + name = NameField() + surnames = fields.DerivedListField(NameField()) + + person = Person(name='Person') + person.surnames = ['Surname', 'Surname'] + + expected = {'name': 'Person', 'surnames': ['Surname', 'Surname']} + assert person.to_struct() == expected + + +def test_custom_field_validation(): + class NameField(fields.StringField): + def __init__(self): + super(NameField, self).__init__( + required=True, + validators=validators.Regex("[A-Z][a-z]+") + ) + + class Person(models.Base): + name = NameField() + surnames = fields.DerivedListField(NameField()) + + with pytest.raises(errors.FieldValidationError): + Person(name=None) + + with pytest.raises(errors.FieldValidationError): + Person().name = "N" + + with pytest.raises(errors.FieldValidationError): + Person(surnames=[None]) + + person = Person() + person.surnames.append(None) + with pytest.raises(errors.FieldValidationError): + person.validate() + + +def test_map_field(): + class Model(models.Base): + str_to_int = fields.MapField(fields.StringField(), fields.IntField()) + int_to_str = fields.MapField(fields.IntField(), fields.StringField()) + empty = fields.MapField(fields.IntField(), fields.StringField()) + + model = Model() + model.str_to_int = {"first": 1, "second": 2} + model.int_to_str = {1: "first", 2: "second"} + + expected = { + "str_to_int": {"first": 1, "second": 2}, + "int_to_str": {1: "first", 2: "second"}, + } + assert expected == model.to_struct() + + +class CircularMapModel(models.Base): + """ + Test model used in the following test, + must be defined outside function for lazy loading + """ + mapping = fields.MapField( + fields.IntField(), + fields.EmbeddedField("CircularMapModel"), + default=None + ) + + +def test_map_field_circular(): + model = CircularMapModel(mapping={1: {}, 2: CircularMapModel()}) + expected = {'mapping': {1: {}, 2: {}}} + assert expected == model.to_struct() + + +def test_map_field_validation(): + class Model(models.Base): + str_to_int = fields.MapField(fields.StringField(), fields.IntField()) + int_to_str = fields.MapField(fields.IntField(), fields.StringField(), + required=True) + + assert Model().to_struct() == {"int_to_str": {}} + + with pytest.raises(errors.FieldValidationError): + Model().str_to_int = {1: "first", 2: "second"} + + with pytest.raises(errors.FieldValidationError): + Model().int_to_str = {"first": 1, "second": 2} + + model = Model(str_to_int={}) + model.str_to_int[1] = "first" + with pytest.raises(errors.FieldValidationError): + model.validate() + + model = Model() + model.int_to_str["first"] = 1 + with pytest.raises(errors.FieldValidationError): + model.validate() + + +def test_generic_field(): + class Model(models.Base): + field = fields.GenericField() + + model_int = Model(field=1) + model_str = Model(field="str") + model_model = Model(field=model_int) + model_ordered = Model(field=OrderedDict([("b", 2), ("a", 1)])) + + assert {"field": 1} == model_int.to_struct() + assert {"field": "str"} == model_str.to_struct() + assert {"field": {"field": 1}} == model_model.to_struct() + expected = {"field": OrderedDict([("b", 2), ("a", 1)])} + assert expected == model_ordered.to_struct() + + +def test_derived_list_omit_empty(): + + class Car(models.Base): + wheels = fields.DerivedListField(fields.StringField(), + omit_empty=True) + doors = fields.DerivedListField(fields.StringField(), + omit_empty=False) + + viper = Car() + assert viper.to_struct() == {"doors": []} + + +def test_automatic_model_detection(): + + class FullName(models.Base): + first_name = fields.StringField() + last_name = fields.StringField() + + class Car(models.Base): + models = fields.DerivedListField(fields.StringField(), + omit_empty=False) + + class Person(models.Base): + + names = fields.ListField( + [str, int, float, bool, FullName, Car], + help_text='A list of names.', + ) + + person = Person(names=['Daniel', 1, True, {'last_name': 'Schiavini'}, + {'models': ['Model 3']}]) + assert person.to_struct() == { + 'names': ['Daniel', 1, True, {'last_name': 'Schiavini'}, + {'models': ['Model 3']}] + } + + assert isinstance(person.names[-2], FullName) + assert isinstance(person.names[-1], Car) + + with pytest.raises(errors.FieldValidationError): + Person(names=[{'last_name': 'Schiavini', 'models': ['Model 3']}]) + + with pytest.raises(errors.FieldValidationError): + Person(names=[{'models': 1}]) diff --git a/tests/test_jsonmodels.py b/tests/test_jsonmodels.py index ab7aad5..3e0e162 100644 --- a/tests/test_jsonmodels.py +++ b/tests/test_jsonmodels.py @@ -54,10 +54,10 @@ class Person(models.Base): alan = Person() - with pytest.raises(errors.ValidationError): + with pytest.raises(ValueError): alan.name = 'some name' - with pytest.raises(errors.ValidationError): + with pytest.raises(ValueError): alan.name = 2345 @@ -112,6 +112,16 @@ class Car(models.Base): viper.wheels.append(Wheel2) +def test_list_omit_empty(): + + class Car(models.Base): + wheels = fields.ListField(items_types=[str], + omit_empty=True) + + viper = Car() + assert viper.to_struct() == {} + + def test_list_field_types_when_assigning(): class Wheel(models.Base): @@ -335,7 +345,7 @@ class Person(models.Base): class Person2(models.Base): - name = fields.StringField() + name = fields.StringField(required=True) surname = fields.StringField() age = fields.IntField() diff --git a/tests/test_schema.py b/tests/test_schema.py index cfee4ef..be7358c 100644 --- a/tests/test_schema.py +++ b/tests/test_schema.py @@ -44,7 +44,8 @@ class Person(models.Base): name = fields.StringField(required=True) surname = fields.StringField(required=True) age = fields.IntField() - kids = fields.ListField(Kid) + kids = fields.ListField(Kid, + default=[Kid(name="Name", surname="Surname")]) car = fields.EmbeddedField(Car) chuck = Person() @@ -88,7 +89,7 @@ class Person(models.Base): age = fields.IntField() car = fields.EmbeddedField([Viper, Lamborghini]) computer = fields.ListField([PC, Laptop, Tablet]) - meta = fields.DictField() + meta = fields.GenericField() schema = Person.to_json_schema() @@ -117,21 +118,20 @@ class Kid(models.Base): age = fields.IntField() toys = fields.ListField(Toy) - def __init__(self, some_value): - pass + def __init__(self, name="Name", surname="Surname"): + super().__init__(name=name, surname=surname) class Person(models.Base): name = fields.StringField(required=True) surname = fields.StringField(required=True) age = fields.IntField() - kids = fields.ListField(Kid) + kids = fields.ListField(Kid, default=[Kid()]) car = fields.EmbeddedField(Car) def __init__(self, some_value): pass schema = Person.to_json_schema() - pattern = get_fixture('schema2.json') assert compare_schemas(pattern, schema) is True @@ -380,7 +380,10 @@ class Event(models.Base): class Person(models.Base): - names = fields.ListField([str, int, float, bool, Event]) + names = fields.ListField( + [str, int, float, bool, Event], + help_text="A list of names.", + ) schema = Person.to_json_schema() @@ -401,12 +404,12 @@ class Person(models.Base): def test_enum_validator(): class Person(models.Base): handness = fields.StringField( + help_text="The person's favorite hand.", validators=validators.Enum('left', 'right') ) schema = Person.to_json_schema() pattern = get_fixture('schema_enum.json') - assert compare_schemas(pattern, schema) @@ -434,7 +437,6 @@ def test_primitives(): (str, "string"), (bool, "boolean"), (int, "number"), - (float, "number"), ) for pytpe, jstype in cases: b = builders.PrimitiveBuilder(pytpe) @@ -443,3 +445,11 @@ def test_primitives(): assert b.build() == {"type": [jstype, "null"]} b = builders.PrimitiveBuilder(pytpe, nullable=True, default=0) assert b.build() == {"type": [jstype, "null"], "default": 0} + + b = builders.PrimitiveBuilder(float) + assert b.build() == {"type": "number", "format": "float"} + b = builders.PrimitiveBuilder(float, nullable=True) + assert b.build() == {"type": ["number", "null"], "format": "float"} + b = builders.PrimitiveBuilder(float, nullable=True, default=0) + assert b.build() == {"type": ["number", "null"], "default": 0, + "format": "float"} diff --git a/tests/test_struct.py b/tests/test_struct.py index 0dca573..a26e3bd 100644 --- a/tests/test_struct.py +++ b/tests/test_struct.py @@ -18,7 +18,7 @@ class Person(models.Base): surname = fields.StringField(required=True) age = fields.IntField() cash = fields.FloatField() - meta = fields.DictField() + meta = fields.GenericField() alan = Person() with pytest.raises(errors.ValidationError): diff --git a/tests/test_utilities.py b/tests/test_utilities.py index 604428e..bb6a326 100644 --- a/tests/test_utilities.py +++ b/tests/test_utilities.py @@ -59,129 +59,132 @@ def test_failed_comparison_of_two_dicts(): def test_is_ecma_regex(): - assert utilities.is_ecma_regex('some regex') is False - assert utilities.is_ecma_regex('^some regex$') is False - assert utilities.is_ecma_regex('/^some regex$/') is True - assert utilities.is_ecma_regex('/^some regex$/gim') is True - assert utilities.is_ecma_regex('/^some regex$/miug') is True + assert utilities.is_ecma_regex(r'some regex') is False + assert utilities.is_ecma_regex(r'^some regex$') is False + assert utilities.is_ecma_regex(r'/^some regex$/') is True + assert utilities.is_ecma_regex(r'/^some regex$/gim') is True + assert utilities.is_ecma_regex(r'/^some regex$/miug') is True with pytest.raises(ValueError): - utilities.is_ecma_regex('[wrong regex') + utilities.is_ecma_regex(r'[wrong regex') with pytest.raises(ValueError): - utilities.is_ecma_regex('wrong regex[]') + utilities.is_ecma_regex(r'wrong regex[]') with pytest.raises(ValueError): - utilities.is_ecma_regex('wrong regex(gim') + utilities.is_ecma_regex(r'wrong regex(gim') with pytest.raises(ValueError): - utilities.is_ecma_regex('wrong regex)asdf') + utilities.is_ecma_regex(r'wrong regex)asdf') - assert utilities.is_ecma_regex('/^some regex\/gim') is True + assert utilities.is_ecma_regex(r'/^some regex\/gim') is True - assert utilities.is_ecma_regex('/^some regex\\\\/miug') is True - assert utilities.is_ecma_regex('/^some regex\\\\\/gim') is True - assert utilities.is_ecma_regex('/\\\\/') is True + assert utilities.is_ecma_regex(r'/^some regex\\\\/miug') is True + assert utilities.is_ecma_regex(r'/^some regex\\\\\/gim') is True + assert utilities.is_ecma_regex(r'/\\\\/') is True - assert utilities.is_ecma_regex('some /regex/asdf') is False - assert utilities.is_ecma_regex('^some regex$//') is False + assert utilities.is_ecma_regex(r'some /regex/asdf') is False + assert utilities.is_ecma_regex(r'^some regex$//') is False def test_convert_ecma_regex_to_python(): - assert ('some', []) == utilities.convert_ecma_regex_to_python('/some/') assert ( - ('some/pattern', []) == - utilities.convert_ecma_regex_to_python('/some/pattern/') + (r'some', []) == + utilities.convert_ecma_regex_to_python(r'/some/') ) assert ( - ('^some \d+ pattern$', []) == - utilities.convert_ecma_regex_to_python('/^some \d+ pattern$/') + (r'some/pattern', []) == + utilities.convert_ecma_regex_to_python(r'/some/pattern/') + ) + assert ( + (r'^some \d+ pattern$', []) == + utilities.convert_ecma_regex_to_python(r'/^some \d+ pattern$/') ) - regex, flags = utilities.convert_ecma_regex_to_python('/^regex \d/i') - assert '^regex \d' == regex + regex, flags = utilities.convert_ecma_regex_to_python(r'/^regex \d/i') + assert r'^regex \d' == regex assert set([re.I]) == set(flags) - result = utilities.convert_ecma_regex_to_python('/^regex \d/m') - assert '^regex \d' == result.regex + result = utilities.convert_ecma_regex_to_python(r'/^regex \d/m') + assert r'^regex \d' == result.regex assert set([re.M]) == set(result.flags) - result = utilities.convert_ecma_regex_to_python('/^regex \d/mi') - assert '^regex \d' == result.regex + result = utilities.convert_ecma_regex_to_python(r'/^regex \d/mi') + assert r'^regex \d' == result.regex assert set([re.M, re.I]) == set(result.flags) with pytest.raises(ValueError): - utilities.convert_ecma_regex_to_python('/regex/wrong') + utilities.convert_ecma_regex_to_python(r'/regex/wrong') assert ( - ('python regex', []) == - utilities.convert_ecma_regex_to_python('python regex') + (r'python regex', []) == + utilities.convert_ecma_regex_to_python(r'python regex') ) assert ( - ('^another \d python regex$', []) == - utilities.convert_ecma_regex_to_python('^another \d python regex$') + (r'^another \d python regex$', []) == + utilities.convert_ecma_regex_to_python(r'^another \d python regex$') ) - result = utilities.convert_ecma_regex_to_python('python regex') - assert 'python regex' == result.regex + result = utilities.convert_ecma_regex_to_python(r'python regex') + assert r'python regex' == result.regex assert [] == result.flags def test_convert_python_regex_to_ecma(): assert ( - '/^some regex$/' == - utilities.convert_python_regex_to_ecma('^some regex$') + r'/^some regex$/' == + utilities.convert_python_regex_to_ecma(r'^some regex$') ) assert ( - '/^some regex$/' == - utilities.convert_python_regex_to_ecma('^some regex$', []) + r'/^some regex$/' == + utilities.convert_python_regex_to_ecma(r'^some regex$', []) ) assert ( - '/pattern \d+/i' == - utilities.convert_python_regex_to_ecma('pattern \d+', [re.I]) + r'/pattern \d+/i' == + utilities.convert_python_regex_to_ecma(r'pattern \d+', [re.I]) ) assert ( - '/pattern \d+/m' == - utilities.convert_python_regex_to_ecma('pattern \d+', [re.M]) + r'/pattern \d+/m' == + utilities.convert_python_regex_to_ecma(r'pattern \d+', [re.M]) ) assert ( - '/pattern \d+/im' == - utilities.convert_python_regex_to_ecma('pattern \d+', [re.I, re.M]) + r'/pattern \d+/im' == + utilities.convert_python_regex_to_ecma(r'pattern \d+', [re.I, re.M]) ) assert ( - '/ecma pattern$/' == - utilities.convert_python_regex_to_ecma('/ecma pattern$/') + r'/ecma pattern$/' == + utilities.convert_python_regex_to_ecma(r'/ecma pattern$/') ) assert ( - '/ecma pattern$/im' == - utilities.convert_python_regex_to_ecma('/ecma pattern$/im') + r'/ecma pattern$/im' == + utilities.convert_python_regex_to_ecma(r'/ecma pattern$/im') ) assert ( - '/ecma pattern$/wrong' == - utilities.convert_python_regex_to_ecma('/ecma pattern$/wrong') + r'/ecma pattern$/wrong' == + utilities.convert_python_regex_to_ecma(r'/ecma pattern$/wrong') ) assert ( - '/ecma pattern$/m' == - utilities.convert_python_regex_to_ecma('/ecma pattern$/m', [re.M]) + r'/ecma pattern$/m' == + utilities.convert_python_regex_to_ecma(r'/ecma pattern$/m', [re.M]) ) def test_converters(): assert ( - '/^ecma \d regex$/im' == + r'/^ecma \d regex$/im' == utilities.convert_python_regex_to_ecma( - *utilities.convert_ecma_regex_to_python('/^ecma \d regex$/im')) + *utilities.convert_ecma_regex_to_python(r'/^ecma \d regex$/im')) ) result = utilities.convert_ecma_regex_to_python( utilities.convert_python_regex_to_ecma( - '^some \w python regex$', [re.I])) + r'^some \w python regex$', [re.I])) - assert '^some \w python regex$' == result.regex + assert r'^some \w python regex$' == result.regex assert [re.I] == result.flags