Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
c464c06
Merge pull request #244 from seequent/dev
fwkoch Jun 7, 2018
e34b2f6
Propagate validation errors when instance properties are set from dicts
fwkoch Sep 12, 2018
7853d62
Merge branch 'master' into feat/propagate_errors
fwkoch Oct 15, 2018
7665f3d
Print truncated representation of bad values in error messages
fwkoch Nov 28, 2018
acf37d1
Add extra info to most error messages
fwkoch Nov 28, 2018
a9536f3
Remove unused import
fwkoch Nov 28, 2018
03b5cc2
Fix strange import paths
fwkoch Nov 30, 2018
076f0e2
Break out union property error collection to separate method
fwkoch Nov 30, 2018
dd5bf45
Revert a few redundant 'extra' error messages
fwkoch Nov 30, 2018
2f81a37
Add test for truncated error messages
fwkoch Nov 30, 2018
32713b3
Tidy up vector array error messages for string representations
fwkoch Nov 30, 2018
52a53bc
A couple more tiny error tests
fwkoch Nov 30, 2018
76455fd
Add ValueError to possible exceptions on dictionary coercion
fwkoch Nov 30, 2018
2a20f8d
Merge pull request #261 from seequent/feat/more_error_info
fwkoch Nov 30, 2018
8297f63
Merge branch 'master' into beta
fwkoch Nov 30, 2018
d8dc762
Merge branch 'beta' into dev
fwkoch Nov 30, 2018
292b272
Bump version: 0.5.4 → 0.5.5b0
fwkoch Nov 30, 2018
5286ee4
Fix potentially cyclic import
fwkoch Nov 30, 2018
9f285b3
Merge pull request #262 from seequent/dev
fwkoch Nov 30, 2018
ad343ce
Do not use functools.wrap for ser/deser functions
fwkoch Jan 8, 2019
4c91459
Add deserializer test that would fail previously
fwkoch Jan 8, 2019
9d3f1ce
Merge branch 'dev' into fix/functools_error
fwkoch Jan 15, 2019
f3e75a4
Update tests for pypng updates
fwkoch Jan 16, 2019
559a9f7
Merge pull request #265 from seequent/fix/functools_error
fwkoch Jan 16, 2019
5dd72d0
Merge branch 'beta' into dev
fwkoch Jan 16, 2019
fee4b30
Merge pull request #268 from seequent/dev
fwkoch Jan 16, 2019
fe28b97
Bump version: 0.5.5b0 → 0.5.5
fwkoch Jan 16, 2019
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .bumpversion.cfg
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
[bumpversion]
current_version = 0.5.4
current_version = 0.5.5
files = properties/__init__.py setup.py docs/conf.py

4 changes: 2 additions & 2 deletions docs/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion properties/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
22 changes: 16 additions & 6 deletions properties/base/containers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
12 changes: 9 additions & 3 deletions properties/base/instance.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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'):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can't we do this through checking the subclass rather than adding a attribution dynamically?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That makes sense, isinstance(err, ValidationError) is better than duck typing.

extra = '({})'.format(' & '.join(
Copy link
Contributor

@hishnash hishnash Dec 4, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Personally I find this a little odd, below we re-raise this maybe we can find a way to store this list of tuples on the re-raised error.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I agree with this. We already are keeping some error info in a structured object; totally makes sense to keep it all structured.

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"""
Expand Down
47 changes: 27 additions & 20 deletions properties/base/union.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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'):
Copy link
Contributor

@hishnash hishnash Dec 4, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As above checking for an attribute on an instance of a class is, in my view, not the cleanest way.

if we could have some subclasses of our GENERIC_ERRORS that we know have error_tuples then we could have 2 different except blocks.

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)
Copy link
Contributor

@hishnash hishnash Dec 4, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Perhaps I'm missing something as to why we need to re-raise with a merged string.

What about defining a __str__ and __unicode__ and __repr__ metthod on the custom exception classed. This these methods (one of them) merging messages in a error_tuples list.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, this is great. Feeds back into having a structured representation rather than just a somewhat arbitrary string...

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"""
Expand All @@ -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
Expand Down
57 changes: 40 additions & 17 deletions properties/basic.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@

import collections
import datetime
from functools import wraps
import math
import random
import re
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -732,7 +735,7 @@ def validate(self, instance, value):
try:
value = bool(value)
except ValueError:
Copy link
Contributor

@hishnash hishnash Dec 4, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can the bool function really raises value error?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is a bit of an unlikely / pathological case. Technically the implementation of bool(...) checks the argument for a __bool__ magic method and calls that bound to the instance. So, you could imagine a weird implementation where someone did:

image

This seems really odd at first, but maybe you could imagine a use case like a future object or a deferred job ID. You do a job = something.defer(...), and then do if job.ready: ..., where job.ready is a handle to something and overrides __bool__.

image

In that case, since using in a conditional causes the __bool__ method to be called, you could get a ValueError if any internal code does that. However, this is not really an implementation of the bool function, but rather specific to the implementation of the object it's receiving.

I'm not sure there is a lot of benefit to catching this, other than that it is structurally consistent with the other property implementations. I guess it doesn't hurt either. Thoughts @fwkoch?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @hishnash and @bsmithyman - I agree that it doesn't make a ton of sense to catch this. It will only come up in strange situations, and in that case, what is special about ValueError vs. TypeError? I think it was introduced for structural consistency (as @bsmithyman suggests).

For the sake of this PR, I will leave it (outside the scope of the current changes, not quite backwards compatible), but it's worth addressing in the future. #264

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
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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):
Expand Down
2 changes: 1 addition & 1 deletion properties/extras/web.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
25 changes: 17 additions & 8 deletions properties/math.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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:
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
3 changes: 3 additions & 0 deletions tests/test_container.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading