Skip to content

Commit

Permalink
Add the ability for Object to check schema invariants.
Browse files Browse the repository at this point in the history
Leave the ability to opt-out of that in case it causes unexpected breakage.

Fixes #10.
  • Loading branch information
jamadden committed Aug 14, 2018
1 parent c61de98 commit e1e5686
Show file tree
Hide file tree
Showing 6 changed files with 121 additions and 31 deletions.
6 changes: 6 additions & 0 deletions CHANGES.rst
Expand Up @@ -4,6 +4,12 @@ Changes
4.5.1 (unreleased)
------------------

- ``Object`` instances call their schema's ``validateInvariants``
method by default to collect errors from functions decorated with
``@invariant`` when validating. This can be disabled by passing
``validate_invariants=False`` to the ``Object`` constructor. See
`issue 10 <https://github.com/zopefoundation/zope.schema/issues/10>`_.

- ``Field`` instances are hashable on Python 3, and use a defined
hashing algorithm that matches what equality does on all versions of
Python. Previously, on Python 2, fields were hashed based on their
Expand Down
2 changes: 1 addition & 1 deletion docs/conf.py
Expand Up @@ -77,7 +77,7 @@
exclude_patterns = ['_build']

# The reST default role (used for this markup: `text`) to use for all documents.
#default_role = None
default_role = 'obj'

# If true, '()' will be appended to :func: etc. cross-reference text.
#add_function_parentheses = True
Expand Down
23 changes: 23 additions & 0 deletions src/zope/schema/_field.py
Expand Up @@ -27,6 +27,7 @@
from zope.interface import classImplements
from zope.interface import implementer
from zope.interface import Interface
from zope.interface import Invalid
from zope.interface.interfaces import IInterface
from zope.interface.interfaces import IMethod

Expand Down Expand Up @@ -615,10 +616,21 @@ class Object(Field):
__doc__ = IObject.__doc__

def __init__(self, schema, **kw):
"""
Object(schema, validate_invariants=True, **kwargs)
Create an `~.IObject` field. The keyword arguments are as for `~.Field`.
.. versionchanged:: 4.6.0
Add the keyword argument *validate_invariants*. When true (the default),
the schema's ``validateInvariants`` method will be invoked to check
the ``@invariant`` properties of the schema.
"""
if not IInterface.providedBy(schema):
raise WrongType

self.schema = schema
self.validate_invariants = kw.pop('validate_invariants', True)
super(Object, self).__init__(**kw)

def _validate(self, value):
Expand All @@ -630,6 +642,17 @@ def _validate(self, value):

# check the value against schema
errors = _validate_fields(self.schema, value)

if self.validate_invariants:
try:
self.schema.validateInvariants(value, errors)
except Invalid:
# validateInvariants raises a wrapper error around
# all the errors it got if it got errors, in addition
# no appending them to the errors list. We don't want
# that, we raise our own error.
pass

if errors:
raise WrongContainedType(errors, self.__name__)

Expand Down
13 changes: 12 additions & 1 deletion src/zope/schema/interfaces.py
Expand Up @@ -535,13 +535,24 @@ class IFrozenSet(IAbstractSet):


class IObject(IField):
"""Field containing an Object value."""
"""
Field containing an Object value.
.. versionchanged:: 4.6.0
Add the *validate_invariants* attribute.
"""

schema = Attribute(
"schema",
_("The Interface that defines the Fields comprising the Object.")
)

validate_invariants = Attribute(
"validate_invariants",
_("A boolean that says whether ``schema.validateInvariants`` "
"is called from ``self.validate()``. The default is true.")
)


class IBeforeObjectAssignedEvent(Interface):
"""An object is going to be assigned to an attribute on another object.
Expand Down
106 changes: 79 additions & 27 deletions src/zope/schema/tests/test__field.py
Expand Up @@ -17,6 +17,12 @@

from zope.schema.tests.test__bootstrapfields import OrderableMissingValueMixin

# pylint:disable=protected-access
# pylint:disable=too-many-lines
# pylint:disable=inherit-non-class
# pylint:disable=no-member
# pylint:disable=blacklisted-name

class BytesTests(unittest.TestCase):

def _getTargetClass(self):
Expand Down Expand Up @@ -73,7 +79,6 @@ def test_validate_required(self):
self.assertRaises(RequiredMissing, field.validate, None)

def test_fromUnicode_miss(self):
from zope.schema._compat import text_type
byt = self._makeOne()
self.assertRaises(UnicodeEncodeError, byt.fromUnicode, u'\x81')

Expand Down Expand Up @@ -458,46 +463,42 @@ def test_validate_wrong_types(self):
self.assertRaises(WrongType, field.validate, date.today())

def test_validate_not_required(self):
from datetime import datetime
field = self._makeOne(required=False)
field.validate(None) # doesn't raise
field.validate(datetime.now()) # doesn't raise
field.validate(datetime.datetime.now()) # doesn't raise

def test_validate_required(self):
from zope.schema.interfaces import RequiredMissing
field = self._makeOne(required=True)
self.assertRaises(RequiredMissing, field.validate, None)

def test_validate_w_min(self):
from datetime import datetime
from zope.schema.interfaces import TooSmall
d1 = datetime(2000, 10, 1)
d2 = datetime(2000, 10, 2)
d1 = datetime.datetime(2000, 10, 1)
d2 = datetime.datetime(2000, 10, 2)
field = self._makeOne(min=d1)
field.validate(d1) # doesn't raise
field.validate(d2) # doesn't raise
self.assertRaises(TooSmall, field.validate, datetime(2000, 9, 30))
self.assertRaises(TooSmall, field.validate, datetime.datetime(2000, 9, 30))

def test_validate_w_max(self):
from datetime import datetime
from zope.schema.interfaces import TooBig
d1 = datetime(2000, 10, 1)
d2 = datetime(2000, 10, 2)
d3 = datetime(2000, 10, 3)
d1 = datetime.datetime(2000, 10, 1)
d2 = datetime.datetime(2000, 10, 2)
d3 = datetime.datetime(2000, 10, 3)
field = self._makeOne(max=d2)
field.validate(d1) # doesn't raise
field.validate(d2) # doesn't raise
self.assertRaises(TooBig, field.validate, d3)

def test_validate_w_min_and_max(self):
from datetime import datetime
from zope.schema.interfaces import TooBig
from zope.schema.interfaces import TooSmall
d1 = datetime(2000, 10, 1)
d2 = datetime(2000, 10, 2)
d3 = datetime(2000, 10, 3)
d4 = datetime(2000, 10, 4)
d5 = datetime(2000, 10, 5)
d1 = datetime.datetime(2000, 10, 1)
d2 = datetime.datetime(2000, 10, 2)
d3 = datetime.datetime(2000, 10, 3)
d4 = datetime.datetime(2000, 10, 4)
d5 = datetime.datetime(2000, 10, 5)
field = self._makeOne(min=d2, max=d4)
field.validate(d2) # doesn't raise
field.validate(d3) # doesn't raise
Expand Down Expand Up @@ -530,10 +531,8 @@ def test_instance_conforms_to_IDate(self):
verifyObject(IDate, self._makeOne())

def test_validate_wrong_types(self):
from datetime import datetime
from zope.schema.interfaces import WrongType


field = self._makeOne()
self.assertRaises(WrongType, field.validate, u'')
self.assertRaises(WrongType, field.validate, u'')
Expand All @@ -545,7 +544,7 @@ def test_validate_wrong_types(self):
self.assertRaises(WrongType, field.validate, set())
self.assertRaises(WrongType, field.validate, frozenset())
self.assertRaises(WrongType, field.validate, object())
self.assertRaises(WrongType, field.validate, datetime.now())
self.assertRaises(WrongType, field.validate, datetime.datetime.now())

def test_validate_not_required(self):
from datetime import date
Expand All @@ -554,22 +553,20 @@ def test_validate_not_required(self):
field.validate(date.today())

def test_validate_required(self):
from datetime import datetime
from zope.schema.interfaces import RequiredMissing
field = self._makeOne()
field.validate(datetime.now().date())
field.validate(datetime.datetime.now().date())
self.assertRaises(RequiredMissing, field.validate, None)

def test_validate_w_min(self):
from datetime import date
from datetime import datetime
from zope.schema.interfaces import TooSmall
d1 = date(2000, 10, 1)
d2 = date(2000, 10, 2)
field = self._makeOne(min=d1)
field.validate(d1)
field.validate(d2)
field.validate(datetime.now().date())
field.validate(datetime.datetime.now().date())
self.assertRaises(TooSmall, field.validate, date(2000, 9, 30))

def test_validate_w_max(self):
Expand Down Expand Up @@ -1970,6 +1967,64 @@ def _replace(event):
self.assertEqual(log[-1].name, 'field')
self.assertEqual(log[-1].context, inst)

def test_validates_invariants_by_default(self):
from zope.interface import invariant
from zope.interface import Interface
from zope.interface import implementer
from zope.interface import Invalid
from zope.schema import Text
from zope.schema import Bytes

class ISchema(Interface):

foo = Text()
bar = Bytes()

@invariant
def check_foo(o):
if o.foo == u'bar':
raise Invalid("Foo is not valid")

@invariant
def check_bar(o):
if o.bar == b'foo':
raise Invalid("Bar is not valid")

@implementer(ISchema)
class O(object):
foo = u''
bar = b''


field = self._makeOne(ISchema)
inst = O()

# Fine at first
field.validate(inst)

inst.foo = u'bar'
errors = self._getErrors(field.validate, inst)
self.assertEqual(len(errors), 1)
self.assertEqual(errors[0].args[0], "Foo is not valid")

del inst.foo
inst.bar = b'foo'
errors = self._getErrors(field.validate, inst)
self.assertEqual(len(errors), 1)
self.assertEqual(errors[0].args[0], "Bar is not valid")

# Both invalid
inst.foo = u'bar'
errors = self._getErrors(field.validate, inst)
self.assertEqual(len(errors), 2)
errors.sort(key=lambda i: i.args)
self.assertEqual(errors[0].args[0], "Bar is not valid")
self.assertEqual(errors[1].args[0], "Foo is not valid")

# We can specifically ask for invariants to be turned off.
field = self._makeOne(ISchema, validate_invariants=False)
field.validate(inst)


class DictTests(unittest.TestCase):

Expand Down Expand Up @@ -2085,9 +2140,6 @@ def _makeSampleVocabulary():
from zope.interface import implementer
from zope.schema.interfaces import IVocabulary

class SampleTerm(object):
pass

@implementer(IVocabulary)
class SampleVocabulary(object):

Expand Down
2 changes: 0 additions & 2 deletions tox.ini
Expand Up @@ -33,5 +33,3 @@ commands =
sphinx-build -b doctest -d docs/_build/doctrees docs docs/_build/doctest
deps =
.[test,docs]
Sphinx
repoze.sphinx.autointerface

0 comments on commit e1e5686

Please sign in to comment.