[constraint] make all constraints accept a 'msg' argument specifying custom failure message

This implementation serializes the message along with other constraint attributes, and as such doesn't need any particular migration since previously serialized values are properly decoded and message is added as needed.

Along the way, FormatConstraint has been refactored and JSON serialization of BoundaryConstraint and StaticVocabularyConstraint has been updated to keyword args, as for other, so msg is transparently deserialized.

Closes #288874

authorSylvain Thénault <sylvain.thenault@logilab.fr>
changeset7a0a6eb9d440
branchdefault
phasepublic
hiddenno
parent revision#a86442f2fb14 Added tag 0.43.0, debian/0.43.0-1, centos/0.43.0-1 for changeset 20b43fb67201
child revision#b420bdb4ab58 pep8 constraints.py
files modified by this revision
test/unittest_constraints.py
yams/constraints.py
# HG changeset patch
# User Sylvain Thénault <sylvain.thenault@logilab.fr>
# Date 1426801941 -3600
# Thu Mar 19 22:52:21 2015 +0100
# Node ID 7a0a6eb9d4401c500e3571c2c9d6c10b6b291dbf
# Parent a86442f2fb148187fa5dd25cc0845f201456db73
[constraint] make all constraints accept a 'msg' argument specifying custom failure message

This implementation serializes the message along with other constraint
attributes, and as such doesn't need any particular migration since previously
serialized values are properly decoded and message is added as needed.

Along the way, FormatConstraint has been refactored and JSON serialization of
BoundaryConstraint and StaticVocabularyConstraint has been updated to keyword
args, as for other, so msg is transparently deserialized.

Closes #288874

diff --git a/test/unittest_constraints.py b/test/unittest_constraints.py
@@ -1,6 +1,6 @@
1 -# copyright 2004-2010 LOGILAB S.A. (Paris, FRANCE), all rights reserved.
2 +# copyright 2004-2016 LOGILAB S.A. (Paris, FRANCE), all rights reserved.
3  # contact http://www.logilab.fr/ -- mailto:contact@logilab.fr
4  #
5  # This file is part of yams.
6  #
7  # yams is free software: you can redistribute it and/or modify it under the
@@ -19,10 +19,11 @@
8 
9  from yams.constraints import *
10  # after import *
11  from datetime import datetime, date, timedelta
12 
13 +
14  class ConstraintTC(TestCase):
15 
16      def test_membership(self):
17          s = set()
18          cstrs = [UniqueConstraint(),
@@ -38,26 +39,26 @@
19          self.assertEqual(7, len(s))
20 
21      def test_interval_serialization_integers(self):
22          cstr = IntervalBoundConstraint(12, 13)
23          self.assertEqual(IntervalBoundConstraint.deserialize('12;13'), cstr)
24 -        self.assertEqual(cstr.serialize(), u'{"maxvalue": 13, "minvalue": 12}')
25 +        self.assertEqual(cstr.serialize(), u'{"maxvalue": 13, "minvalue": 12, "msg": null}')
26          self.assertEqual(cstr.__class__.deserialize(cstr.serialize()), cstr)
27          cstr = IntervalBoundConstraint(maxvalue=13)
28          self.assertEqual(IntervalBoundConstraint.deserialize('None;13'), cstr)
29 -        self.assertEqual(cstr.serialize(), u'{"maxvalue": 13, "minvalue": null}')
30 +        self.assertEqual(cstr.serialize(), u'{"maxvalue": 13, "minvalue": null, "msg": null}')
31          self.assertEqual(cstr.__class__.deserialize(cstr.serialize()), cstr)
32          cstr = IntervalBoundConstraint(minvalue=13)
33          self.assertEqual(IntervalBoundConstraint.deserialize('13;None'), cstr)
34 -        self.assertEqual(cstr.serialize(), u'{"maxvalue": null, "minvalue": 13}')
35 +        self.assertEqual(cstr.serialize(), u'{"maxvalue": null, "minvalue": 13, "msg": null}')
36          self.assertEqual(cstr.__class__.deserialize(cstr.serialize()), cstr)
37          self.assertRaises(AssertionError, IntervalBoundConstraint)
38 
39      def test_interval_serialization_floats(self):
40          cstr = IntervalBoundConstraint(12.13, 13.14)
41          self.assertEqual(IntervalBoundConstraint.deserialize('12.13;13.14'), cstr)
42 -        self.assertEqual(cstr.serialize(), u'{"maxvalue": 13.14, "minvalue": 12.13}')
43 +        self.assertEqual(cstr.serialize(), u'{"maxvalue": 13.14, "minvalue": 12.13, "msg": null}')
44          self.assertEqual(cstr.__class__.deserialize(cstr.serialize()), cstr)
45 
46      def test_interval_deserialization_integers(self):
47          cstr = IntervalBoundConstraint.deserialize('12;13')
48          self.assertEqual(cstr.minvalue, 12)
@@ -74,11 +75,11 @@
49          self.assertEqual(cstr.minvalue, 12.13)
50          self.assertEqual(cstr.maxvalue, 13.14)
51 
52      def test_regexp_serialization(self):
53          cstr = RegexpConstraint('[a-z]+,[A-Z]+', 12)
54 -        self.assertEqual(cstr.serialize(), '{"flags": 12, "regexp": "[a-z]+,[A-Z]+"}')
55 +        self.assertEqual(cstr.serialize(), '{"flags": 12, "msg": null, "regexp": "[a-z]+,[A-Z]+"}')
56          self.assertEqual(cstr.__class__.deserialize(cstr.serialize()), cstr)
57 
58      def test_regexp_deserialization(self):
59          cstr = RegexpConstraint.deserialize('[a-z]+,[A-Z]+,12')
60          self.assertEqual(cstr.regexp, '[a-z]+,[A-Z]+')
@@ -153,7 +154,22 @@
61          cstr = StaticVocabularyConstraint(['a, b', 'c'])
62          self.assertEqual(StaticVocabularyConstraint.deserialize(cstr.serialize()).values,
63                           ('a, b', 'c'))
64 
65 
66 +    def test_custom_message(self):
67 +        cstrs = [UniqueConstraint(msg='constraint failed, you monkey!'),
68 +                 SizeConstraint(min=0, max=42, msg='constraint failed, you monkey!'),
69 +                 RegexpConstraint('babar', 0, msg='constraint failed, you monkey!'),
70 +                 BoundaryConstraint('>', 1, msg='constraint failed, you monkey!'),
71 +                 IntervalBoundConstraint(minvalue=0, maxvalue=42, msg='constraint failed, you monkey!'),
72 +                 StaticVocabularyConstraint((1, 2, 3), msg='constraint failed, you monkey!'),
73 +                 FormatConstraint(msg='constraint failed, you monkey!')]
74 +        for cstr in cstrs:
75 +            self.set_description('%s custom message' % cstr.__class__.__name__)
76 +            yield self.assertEqual, cstr.failed_message('key', 'value'), ('constraint failed, you monkey!', {})
77 +            self.set_description('%s custom message post serialization' % cstr.__class__.__name__)
78 +            cstr = type(cstr).deserialize(cstr.serialize())
79 +            yield self.assertEqual, cstr.failed_message('key', 'value'), ('constraint failed, you monkey!', {})
80 +
81  if __name__ == '__main__':
82      unittest_main()
diff --git a/yams/constraints.py b/yams/constraints.py
@@ -76,28 +76,36 @@
83 
84  class BaseConstraint(object):
85      """base class for constraints"""
86      __implements__ = IConstraint
87 
88 +    def __init__(self, msg=None):
89 +        self.msg = msg
90 +
91      def check_consistency(self, subjschema, objschema, rdef):
92          pass
93 
94      def type(self):
95          return self.__class__.__name__
96 
97      def serialize(self):
98          """called to make persistent valuable data of a constraint"""
99 -        return None
100 +        return cstr_json_dumps({u'msg': self.msg})
101 
102      @classmethod
103      def deserialize(cls, value):
104          """called to restore serialized data of a constraint. Should return
105          a `cls` instance
106          """
107 -        return cls()
108 +        return cls(**cstr_json_loads(value))
109 
110      def failed_message(self, key, value):
111 +        if self.msg:
112 +            return self.msg, {}
113 +        return self._failed_message(key, value)
114 +
115 +    def _failed_message(self, key, value):
116          return _('%(KEY-cstr)s constraint failed for value %(KEY-value)r'), {
117              key+'-cstr': self,
118              key+'-value': value}
119 
120      def __eq__(self, other):
@@ -136,11 +144,12 @@
121 
122      if max is not None the string length must not be greater than max
123      if min is not None the string length must not be shorter than min
124      """
125 
126 -    def __init__(self, max=None, min=None):
127 +    def __init__(self, max=None, min=None, msg=None):
128 +        super(SizeConstraint, self).__init__(msg)
129          assert (max is not None or min is not None), "No max or min"
130          if min is not None:
131              assert isinstance(min, int), 'min must be an int, not %r' % min
132          if max is not None:
133              assert isinstance(max, int), 'max must be an int, not %r' % max
@@ -181,11 +190,11 @@
134          if self.min is not None:
135              if len(value) < self.min:
136                  return False
137          return True
138 
139 -    def failed_message(self, key, value):
140 +    def _failed_message(self, key, value):
141          if self.max is not None and len(value) > self.max:
142              return _('value should have maximum size of %(KEY-max)s but found %(KEY-size)s'), {
143                  key+'-max': self.max,
144                  key+'-size': len(value)}
145          if self.min is not None and len(value) < self.min:
@@ -194,11 +203,12 @@
146                  key+'-size': len(value)}
147          assert False, 'shouldnt be there'
148 
149      def serialize(self):
150          """simple text serialization"""
151 -        return cstr_json_dumps({u'min': self.min, u'max': self.max})
152 +        return cstr_json_dumps({u'min': self.min, u'max': self.max,
153 +                                u'msg': self.msg})
154 
155      @classmethod
156      def deserialize(cls, value):
157          """simple text deserialization"""
158          try:
@@ -215,18 +225,19 @@
159 
160  class RegexpConstraint(BaseConstraint):
161      """specifies a set of allowed patterns for a string value"""
162      __implements__ = IConstraint
163 
164 -    def __init__(self, regexp, flags=0):
165 +    def __init__(self, regexp, flags=0, msg=None):
166          """
167          Construct a new RegexpConstraint.
168 
169          :Parameters:
170           - `regexp`: (str) regular expression that strings must match
171           - `flags`: (int) flags that are passed to re.compile()
172          """
173 +        super(RegexpConstraint, self).__init__(msg)
174          self.regexp = regexp
175          self.flags = flags
176          self._rgx = re.compile(regexp, flags)
177 
178      def __str__(self):
@@ -242,18 +253,19 @@
179 
180      def check(self, entity, rtype, value):
181          """return true if the value maches the regular expression"""
182          return self._rgx.match(value, self.flags)
183 
184 -    def failed_message(self, key, value):
185 +    def _failed_message(self, key, value):
186          return _("%(KEY-value)r doesn't match the %(KEY-regexp)r regular expression"), {
187              key+'-value': value,
188              key+'-regexp': self.regexp}
189 
190      def serialize(self):
191          """simple text serialization"""
192 -        return cstr_json_dumps({u'regexp': self.regexp, u'flags': self.flags})
193 +        return cstr_json_dumps({u'regexp': self.regexp, u'flags': self.flags,
194 +                                u'msg': self.msg})
195 
196      @classmethod
197      def deserialize(cls, value):
198          """simple text deserialization"""
199          try:
@@ -279,11 +291,12 @@
200 
201      set a minimal or maximal value to a numerical value
202      """
203      __implements__ = IConstraint
204 
205 -    def __init__(self, op, boundary=None):
206 +    def __init__(self, op, boundary=None, msg=None):
207 +        super(BoundaryConstraint, self).__init__(msg)
208          assert op in OPERATORS, op
209          self.operator = op
210          self.boundary = boundary
211 
212      def __str__(self):
@@ -299,25 +312,26 @@
213          boundary = actual_value(self.boundary, entity)
214          if boundary is None:
215              return True
216          return OPERATORS[self.operator](value, boundary)
217 
218 -    def failed_message(self, key, value):
219 +    def _failed_message(self, key, value):
220          return "value %%(KEY-value)s must be %s %%(KEY-boundary)s" % self.operator, {
221              key+'-value': value,
222              key+'-boundary': self.boundary}
223 
224      def serialize(self):
225          """simple text serialization"""
226 -        return cstr_json_dumps({u'operator': self.operator, u'boundary': self.boundary})
227 +        return cstr_json_dumps({u'op': self.operator, u'boundary': self.boundary,
228 +                                u'msg': self.msg})
229 
230      @classmethod
231      def deserialize(cls, value):
232          """simple text deserialization"""
233          try:
234              d = cstr_json_loads(value)
235 -            return cls(d['operator'], d['boundary'])
236 +            return cls(**d)
237          except ValueError:
238              op, boundary = value.split(' ', 1)
239              return cls(op, eval(boundary))
240 
241  BoundConstraint = class_renamed('BoundConstraint', BoundaryConstraint)
@@ -334,16 +348,17 @@
242      sets a minimal and / or a maximal value to a numerical value
243      This class replaces the BoundConstraint class
244      """
245      __implements__ = IConstraint
246 
247 -    def __init__(self, minvalue=None, maxvalue=None):
248 +    def __init__(self, minvalue=None, maxvalue=None, msg=None):
249          """
250          :param minvalue: the minimal value that can be used
251          :param maxvalue: the maxvalue value that can be used
252          """
253          assert not (minvalue is None and maxvalue is None)
254 +        super(IntervalBoundConstraint, self).__init__(msg)
255          self.minvalue = minvalue
256          self.maxvalue = maxvalue
257 
258      def __str__(self):
259          return 'value [%s]' % self.serialize()
@@ -360,11 +375,11 @@
260          maxvalue = actual_value(self.maxvalue, entity)
261          if maxvalue is not None and value > maxvalue:
262              return False
263          return True
264 
265 -    def failed_message(self, key, value):
266 +    def _failed_message(self, key, value):
267          if self.minvalue is not None and value < self.minvalue:
268              return _("value %(KEY-value)s must be >= %(KEY-boundary)s"), {
269                  key+'-value': value,
270                  key+'-boundary': self.minvalue}
271          if self.maxvalue is not None and value > self.maxvalue:
@@ -373,12 +388,12 @@
272                  key+'-boundary': self.maxvalue}
273          assert False, 'shouldnt be there'
274 
275      def serialize(self):
276          """simple text serialization"""
277 -        return cstr_json_dumps({u'minvalue': self.minvalue,
278 -                                u'maxvalue': self.maxvalue})
279 +        return cstr_json_dumps({u'minvalue': self.minvalue, u'maxvalue': self.maxvalue,
280 +                                u'msg': self.msg})
281 
282      @classmethod
283      def deserialize(cls, value):
284          """simple text deserialization"""
285          try:
@@ -391,21 +406,22 @@
286 
287  class StaticVocabularyConstraint(BaseConstraint):
288      """Enforces a predefined vocabulary set for the value."""
289      __implements__ = IVocabularyConstraint
290 
291 -    def __init__(self, values):
292 +    def __init__(self, values, msg=None):
293 +        super(StaticVocabularyConstraint, self).__init__(msg)
294          self.values = tuple(values)
295 
296      def __str__(self):
297 -        return 'value in (%s)' % self.serialize()
298 +        return 'value in (%s)' % u', '.join(repr(text_type(word)) for word in self.vocabulary())
299 
300      def check(self, entity, rtype, value):
301          """return true if the value is in the specific vocabulary"""
302          return value in self.vocabulary(entity=entity)
303 
304 -    def failed_message(self, key, value):
305 +    def _failed_message(self, key, value):
306          if isinstance(value, string_types):
307              value = u'"%s"' % text_type(value)
308              choices = u', '.join('"%s"' % val for val in self.values)
309          else:
310              choices = u', '.join(text_type(val) for val in self.values)
@@ -417,18 +433,18 @@
311          """return a list of possible values for the attribute"""
312          return self.values
313 
314      def serialize(self):
315          """serialize possible values as a json object"""
316 -        return cstr_json_dumps(self.values)
317 +        return cstr_json_dumps({u'values': self.values, u'msg': self.msg})
318 
319      @classmethod
320      def deserialize(cls, value):
321          """deserialize possible values from a csv list of evaluable strings"""
322          try:
323              values = cstr_json_loads(value)
324 -            return cls(values)
325 +            return cls(**values)
326          except ValueError:
327              values = [eval(w) for w in re.split('(?<!,), ', value)]
328              if values and isinstance(values[0], string_types):
329                  values = [v.replace(',,', ',') for v in values]
330              return cls(values)
@@ -439,37 +455,22 @@
331      regular_formats = (_('text/rest'),
332                         _('text/markdown'),
333                         _('text/html'),
334                         _('text/plain'),
335                         )
336 -    def __init__(self):
337 -        self.values = self.vocabulary()
338 +
339 +    def __init__(self, msg=None, **kwargs):
340 +        values = self.regular_formats
341 +        super(FormatConstraint, self).__init__(values, msg=msg)
342 
343      def check_consistency(self, subjschema, objschema, rdef):
344          if not objschema.final:
345              raise BadSchemaDefinition("format constraint doesn't apply to non "
346                                        "final entity type")
347          if not objschema == 'String':
348              raise BadSchemaDefinition("format constraint only apply to String")
349 
350 -    def serialize(self):
351 -        """called to make persistent valuable data of a constraint"""
352 -        return None
353 -
354 -    @classmethod
355 -    def deserialize(cls, value):
356 -        """called to restore serialized data of a constraint. Should return
357 -        a `cls` instance
358 -        """
359 -        return cls()
360 -
361 -    def vocabulary(self, **kwargs):
362 -        return self.regular_formats
363 -
364 -    def __str__(self):
365 -        return 'value in (%s)' % u', '.join(repr(text_type(word)) for word in self.vocabulary())
366 -
367  FORMAT_CONSTRAINT = FormatConstraint()
368 
369 
370  class MultipleStaticVocabularyConstraint(StaticVocabularyConstraint):
371      """Enforce a list of values to be in a predefined set vocabulary."""
obsoletes