diff --git a/.bumpversion.cfg b/.bumpversion.cfg index f93cf08..27e2bbd 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,4 +1,4 @@ [bumpversion] -current_version = 0.5.4 +current_version = 0.5.5 files = properties/__init__.py setup.py docs/conf.py diff --git a/docs/conf.py b/docs/conf.py index 52d1fc4..5811fcf 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -64,9 +64,9 @@ # built documents. # # The short X.Y version. -version = u'0.5.4' +version = u'0.5.5' # The full version, including alpha/beta/rc tags. -release = u'0.5.4' +release = u'0.5.5' # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. diff --git a/properties/__init__.py b/properties/__init__.py index ae98e8f..8640351 100644 --- a/properties/__init__.py +++ b/properties/__init__.py @@ -89,7 +89,7 @@ class Profile(properties.HasProperties): ValidationError, ) -__version__ = '0.5.4' +__version__ = '0.5.5' __author__ = 'Seequent' __license__ = 'MIT' __copyright__ = 'Copyright 2018 Seequent' diff --git a/properties/base/containers.py b/properties/base/containers.py index af1aed1..edb8fbf 100644 --- a/properties/base/containers.py +++ b/properties/base/containers.py @@ -289,10 +289,16 @@ def assert_valid(self, instance, value=None): value = instance._get(self.name) if value is None: return True - if self.min_length is not None and len(value) < self.min_length: - self.error(instance, value) - if self.max_length is not None and len(value) > self.max_length: - self.error(instance, value) + if ( + (self.min_length is not None and len(value) < self.min_length) + or + (self.max_length is not None and len(value) > self.max_length) + ): + self.error( + instance=instance, + value=value, + extra='(Length is {})'.format(len(value)), + ) for val in value: if not self.prop.assert_valid(instance, val): return False @@ -567,8 +573,12 @@ def validate(self, instance, value): if self.coerce: try: value = self._class_container(value) - except TypeError: - self.error(instance, value) + except (TypeError, ValueError): + self.error( + instance=instance, + value=value, + extra='Cannot coerce to the correct type', + ) out = value.__class__() for key, val in iteritems(value): if self.key_prop: diff --git a/properties/base/instance.py b/properties/base/instance.py index fc58de9..2b622fc 100644 --- a/properties/base/instance.py +++ b/properties/base/instance.py @@ -9,7 +9,7 @@ from six import PY2 -from .base import HasProperties, equal +from .base import GENERIC_ERRORS, HasProperties, equal from .. import basic from .. import utils @@ -101,8 +101,14 @@ def validate(self, instance, value): if isinstance(value, dict): return self.instance_class(**value) return self.instance_class(value) - except (ValueError, KeyError, TypeError): - self.error(instance, value) + except GENERIC_ERRORS as err: + if hasattr(err, 'error_tuples'): + extra = '({})'.format(' & '.join( + err_tup.message for err_tup in err.error_tuples + )) + else: + extra = '' + self.error(instance, value, extra=extra) def assert_valid(self, instance, value=None): """Checks if valid, including HasProperty instances pass validation""" diff --git a/properties/base/union.py b/properties/base/union.py index abc1057..1c800c3 100644 --- a/properties/base/union.py +++ b/properties/base/union.py @@ -8,7 +8,8 @@ from six import PY2 -from ..base import GENERIC_ERRORS, HasProperties, Instance +from .base import GENERIC_ERRORS, HasProperties +from .instance import Instance from .. import basic from .. import utils @@ -160,14 +161,32 @@ def _unused_default_warning(self): warn('Union prop default ignored: {}'.format(prop.default), RuntimeWarning) - def validate(self, instance, value): - """Check if value is a valid type of one of the Union props""" + def _try_prop_method(self, instance, value, method_name): + """Helper method to perform a method on each of the union props + + This method gathers all errors and returns them at the end + if the method on each of the props fails. + """ + error_messages = [] for prop in self.props: try: - return prop.validate(instance, value) - except GENERIC_ERRORS: - continue - self.error(instance, value) + return getattr(prop, method_name)(instance, value) + except GENERIC_ERRORS as err: + if hasattr(err, 'error_tuples'): + error_messages += [ + err_tup.message for err_tup in err.error_tuples + ] + if error_messages: + extra = 'Possible explanation:' + for message in error_messages: + extra += '\n - {}'.format(message) + else: + extra = '' + self.error(instance, value, extra=extra) + + def validate(self, instance, value): + """Check if value is a valid type of one of the Union props""" + return self._try_prop_method(instance, value, 'validate') def assert_valid(self, instance, value=None): """Check if the Union has a valid value""" @@ -178,19 +197,7 @@ def assert_valid(self, instance, value=None): value = instance._get(self.name) if value is None: return True - for prop in self.props: - try: - return prop.assert_valid(instance, value) - except GENERIC_ERRORS: - continue - message = ( - 'The "{name}" property of a {cls} instance has not been set ' - 'correctly'.format( - name=self.name, - cls=instance.__class__.__name__ - ) - ) - raise utils.ValidationError(message, 'invalid', self.name, instance) + return self._try_prop_method(instance, value, 'assert_valid') def serialize(self, value, **kwargs): """Return a serialized value diff --git a/properties/basic.py b/properties/basic.py index 8fc19d3..ed8928f 100644 --- a/properties/basic.py +++ b/properties/basic.py @@ -6,7 +6,6 @@ import collections import datetime -from functools import wraps import math import random import re @@ -45,7 +44,6 @@ def accept_kwargs(func): functions always receive kwargs from serialize, but by using this, the original functions may simply take a single value. """ - @wraps(func) def wrapped(val, **kwargs): """Perform a function on a value, ignoring kwargs if necessary""" try: @@ -339,14 +337,19 @@ def error(self, instance, value, error_class=None, extra=''): prefix = prefix + ' of a {cls} instance'.format( cls=instance.__class__.__name__, ) + print_value = repr(value) + if len(print_value) > 107: + print_value = '{} ... {}'.format( + print_value[:50], print_value[-50:] + ) message = ( - '{prefix} must be {info}. A value of {val!r} {vtype!r} was ' - 'specified. {extra}'.format( + '{prefix} must be {info}. An invalid value of {val} {vtype} was ' + 'specified.{extra}'.format( prefix=prefix, info=self.info or 'corrected', - val=value, + val=print_value, vtype=type(value), - extra=extra, + extra=' {}'.format(extra) if extra else '', ) ) if issubclass(error_class, ValidationError): @@ -732,7 +735,7 @@ def validate(self, instance, value): try: value = bool(value) except ValueError: - self.error(instance, value) + self.error(instance, value, extra='Cannot cast to boolean.') if not isinstance(value, BOOLEAN_TYPES): self.error(instance, value) return value @@ -765,7 +768,7 @@ def _in_bounds(prop, instance, value): (prop.min is not None and value < prop.min) or (prop.max is not None and value > prop.max) ): - prop.error(instance, value) + prop.error(instance, value, extra='Not within allowed range.') class Integer(Boolean): @@ -811,9 +814,13 @@ def validate(self, instance, value): try: intval = int(value) if not self.cast and abs(value - intval) > TOL: - self.error(instance, value) + self.error( + instance=instance, + value=value, + extra='Not within tolerance range of {}.'.format(TOL), + ) except (TypeError, ValueError): - self.error(instance, value) + self.error(instance, value, extra='Cannot cast to integer.') _in_bounds(self, instance, intval) return intval @@ -861,9 +868,13 @@ def validate(self, instance, value): try: floatval = float(value) if not self.cast and abs(value - floatval) > TOL: - self.error(instance, value) + self.error( + instance=instance, + value=value, + extra='Not within tolerance range of {}.'.format(TOL), + ) except (TypeError, ValueError): - self.error(instance, value) + self.error(instance, value, extra='Cannot cast to float.') _in_bounds(self, instance, floatval) return floatval @@ -907,7 +918,11 @@ def validate(self, instance, value): abs(value.real - compval.real) > TOL or abs(value.imag - compval.imag) > TOL ): - self.error(instance, value) + self.error( + instance=instance, + value=value, + extra='Not within tolerance range of {}.'.format(TOL), + ) except (TypeError, ValueError, AttributeError): self.error(instance, value) return compval @@ -1012,7 +1027,7 @@ def validate(self, instance, value): if not isinstance(value, string_types): self.error(instance, value) if self.regex is not None and self.regex.search(value) is None: #pylint: disable=no-member - self.error(instance, value) + self.error(instance, value, extra='Regex does not match.') value = value.strip(self.strip) if self.change_case == 'upper': value = value.upper() @@ -1153,7 +1168,7 @@ def validate(self, instance, value): #pyli test_val = val if self.case_sensitive else [_.upper() for _ in val] if test_value == test_key or test_value in test_val: return key - self.error(instance, value) + self.error(instance, value, extra='Not an available choice.') class Color(Property): @@ -1226,11 +1241,19 @@ def validate(self, instance, value): if isinstance(value, datetime.datetime): return value if not isinstance(value, string_types): - self.error(instance, value) + self.error( + instance=instance, + value=value, + extra='Cannot convert non-strings to datetime.', + ) try: return self.from_json(value) except ValueError: - self.error(instance, value) + self.error( + instance=instance, + value=value, + extra='Invalid format for converting to datetime.', + ) @staticmethod def to_json(value, **kwargs): diff --git a/properties/extras/web.py b/properties/extras/web.py index 74d8a5a..6c8f40d 100644 --- a/properties/extras/web.py +++ b/properties/extras/web.py @@ -48,7 +48,7 @@ def validate(self, instance, value): value = super(URL, self).validate(instance, value) parsed_url = urlparse(value) if not parsed_url.scheme or not parsed_url.netloc: - self.error(instance, value) + self.error(instance, value, extra='URL needs scheme and netloc.') parse_result = ParseResult( scheme=parsed_url.scheme, netloc=parsed_url.netloc, diff --git a/properties/math.py b/properties/math.py index f378e70..952a1f2 100644 --- a/properties/math.py +++ b/properties/math.py @@ -133,7 +133,7 @@ def validate(self, instance, value): 'subclasses of numpy.ndarray' ) if value.dtype.kind not in (TYPE_MAPPINGS[typ] for typ in self.dtype): - self.error(instance, value) + self.error(instance, value, extra='Invalid dtype.') if self.shape is None: return value for shape in self.shape: @@ -144,7 +144,7 @@ def validate(self, instance, value): break else: return value - self.error(instance, value) + self.error(instance, value, extra='Invalid shape.') def equal(self, value_a, value_b): try: @@ -421,7 +421,11 @@ def validate(self, instance, value): for i, val in enumerate(value): if isinstance(val, string_types): if val.upper() not in VECTOR_DIRECTIONS: - self.error(instance, val) + self.error( + instance=instance, + value=val, + extra='This is an invalid Vector3 representation.', + ) value[i] = VECTOR_DIRECTIONS[val.upper()] return super(Vector3Array, self).validate(instance, value) @@ -482,11 +486,16 @@ def validate(self, instance, value): self.error(instance, value) if isinstance(value, (tuple, list)): for i, val in enumerate(value): - if ( - isinstance(val, string_types) and - val.upper() in VECTOR_DIRECTIONS and - val.upper() not in ('Z', '-Z', 'UP', 'DOWN') - ): + if isinstance(val, string_types): + if ( + val.upper() not in VECTOR_DIRECTIONS or + val.upper() in ('Z', '-Z', 'UP', 'DOWN') + ): + self.error( + instance=instance, + value=val, + extra='This is an invalid Vector2 representation.', + ) value[i] = VECTOR_DIRECTIONS[val.upper()][:2] return super(Vector2Array, self).validate(instance, value) diff --git a/setup.py b/setup.py index 397f847..4405e25 100644 --- a/setup.py +++ b/setup.py @@ -29,7 +29,7 @@ EXTRAS.update({'full': sum(EXTRAS.values(), [])}) setup( name='properties', - version='0.5.4', + version='0.5.5', packages=find_packages(exclude=('tests',)), install_requires=['six>=1.7.3'], extras_require=EXTRAS, diff --git a/tests/test_container.py b/tests/test_container.py index f7770e7..d62cbed 100644 --- a/tests/test_container.py +++ b/tests/test_container.py @@ -941,6 +941,9 @@ class HasCoercedDict(properties.HasProperties): hcd.my_coerced_dict = key_val_list assert hcd.my_coerced_dict == {'a': 1, 'b': 2, 'c': 3} + with self.assertRaises(properties.ValidationError): + hcd.my_coerced_dict = 'a' + def test_nested_observed(self): self._test_nested_observed(True) self._test_nested_observed(False) diff --git a/tests/test_errors.py b/tests/test_errors.py index 0f0808a..60d08db 100644 --- a/tests/test_errors.py +++ b/tests/test_errors.py @@ -265,6 +265,24 @@ class HasSillyProp(properties.HasProperties): with self.assertRaises(SillyError): hsp.a = 'hi' + def test_long_error_truncated(self): + + class HasInt(properties.HasProperties): + + a = properties.Integer('') + + hi = HasInt() + huge_dictionary = { + 'a'*1000: [1]*1000, + 'b'*1000: [2]*1000, + 'c'*1000: [3]*1000, + } + with self.assertRaises(properties.ValidationError) as context: + hi.a = huge_dictionary + + assert len(str(context.exception)) < 1000 + assert ' ... ' in str(context.exception) + if __name__ == '__main__': unittest.main() diff --git a/tests/test_images.py b/tests/test_images.py index 3ebb9bc..d2229c6 100644 --- a/tests/test_images.py +++ b/tests/test_images.py @@ -21,6 +21,7 @@ def test_png(self): '101011010100', '110010110101', '100010010011'] + s = [[int(v) for v in val] for val in s] f = open(png_file, 'wb') w = png.Writer(len(s[0]), len(s), greyscale=True, bitdepth=16) w.write(f, s) diff --git a/tests/test_math.py b/tests/test_math.py index 3bb7077..0038b7b 100644 --- a/tests/test_math.py +++ b/tests/test_math.py @@ -220,6 +220,8 @@ class HasVec2Arr(properties.HasProperties): assert hv2.vec2.shape == (1, 2) with self.assertRaises(ValueError): hv2.vec2 = 'east' + with self.assertRaises(ValueError): + hv2.vec2 = ['diagonal'] with self.assertRaises(ValueError): hv2.vec2 = [[1., 2., 3.]] @@ -271,7 +273,7 @@ class HasVec3Arr(properties.HasProperties): hv3.vec3 = [1., 2., 3.] assert hv3.vec3.shape == (1, 3) with self.assertRaises(ValueError): - hv3.vec3 = 'diagonal' + hv3.vec3 = 'east' with self.assertRaises(ValueError): hv3.vec3 = ['diagonal'] with self.assertRaises(ValueError): diff --git a/tests/test_serialization.py b/tests/test_serialization.py index 2c1486f..61facb6 100644 --- a/tests/test_serialization.py +++ b/tests/test_serialization.py @@ -348,5 +348,20 @@ def c(self): assert properties.equal(dm1, dm3) + def test_instance_deserializer(self): + + class DeserializeClass(object): + + def __call__(self, value): + print('deserializing') + + class HasDeserializer(properties.HasProperties): + + my_int = properties.Integer( + 'Int with deserializer', + deserializer=DeserializeClass(), + ) + + if __name__ == '__main__': unittest.main()