don't translate validation error right away, let one i18n them later by calling exception.tr(trfunc). Closes #109550

To do so:

  • backward compatible change to ESchema.check method (no more necessary to give a translation function)
  • add extra arguments to ValidationError, a dictionary containing substitution that should be inserted in error messages after their translation
  • a list of key in the above dictionary whose value should be translated
  • change prototype of the failed_message constraint method so it may return strings that will be translated later (by being inserted in the above dictionary)
authorSylvain Thénault <sylvain.thenault@logilab.fr>
changeset23a10f049447
branchdefault
phasepublic
hiddenno
parent revision#6f5320caff62 only set transformed value if different from the original value to ease potential client caching handling or whatever (eg EditedEntity)
child revision#6142f6bb1694 [error] apply renaming of `tr` to `translate`
files modified by this revision
_exceptions.py
constraints.py
schema.py
test/unittest_schema.py
# HG changeset patch
# User Sylvain Thénault <sylvain.thenault@logilab.fr>
# Date 1351153771 -7200
# Thu Oct 25 10:29:31 2012 +0200
# Node ID 23a10f0494473d0ae3489e0a33d13591f41a35b1
# Parent 6f5320caff626e1ebbf3dca0e6d16f207b8bfc0d
don't translate validation error right away, let one i18n them later by calling exception.tr(trfunc). Closes #109550

To do so:

* backward compatible change to ESchema.check method (no more necessary to give a
translation function)

* add extra arguments to ValidationError, a dictionary containing substitution that
should be inserted in error messages after their translation

* a list of key in the above dictionary whose value should be translated

* change prototype of the failed_message constraint method so it may return
strings that will be translated later (by being inserted in the above dictionary)

diff --git a/_exceptions.py b/_exceptions.py
@@ -1,6 +1,6 @@
1 -# copyright 2004-2010 LOGILAB S.A. (Paris, FRANCE), all rights reserved.
2 +# copyright 2004-2012 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
@@ -13,13 +13,12 @@
8  # A PARTICULAR PURPOSE.  See the GNU Lesser General Public License for more
9  # details.
10  #
11  # You should have received a copy of the GNU Lesser General Public License along
12  # with yams. If not, see <http://www.gnu.org/licenses/>.
13 -"""Exceptions shared by different ER-Schema modules.
14 +"""YAMS exception classes"""
15 
16 -"""
17  __docformat__ = "restructuredtext en"
18 
19  class SchemaError(Exception):
20      """base class for schema exceptions"""
21 
@@ -64,28 +63,67 @@
22          msgs.append(' '.join(self.args[args_offset:]))
23          return ''.join(msgs)
24 
25 
26  class ValidationError(SchemaError):
27 -    """validation error details the reason(s) why the validation failed
28 +    """Validation error details the reason(s) why the validation failed.
29 +
30 +    Arguments are:
31 +
32 +    * `entity`: the entity that could not be validated; actual type depends on
33 +      the client library
34 
35 -    :type entity: EntityType
36 -    :param entity: the entity that could not be validated
37 +    * `errors`: errors dictionary, None key used for global error, other keys
38 +      should be attribute/relation of the entity, qualified as subject/object
39 +      using :func:`yams.role_name`.  Values are the message associated to the
40 +      keys, and may include interpolation string starting with '%(KEY-' where
41 +      'KEY' will be replaced by the associated key once the message has been
42 +      translated. This allows predictable/translatable message and avoid args
43 +      conflict if used for several keys.
44 
45 -    :type explanation: dict
46 -    :param explanation: pairs of (attribute, error)
47 +    * `msgargs`: dictionary of substitutions to be inserted in error
48 +      messages once translated (only if msgargs is given)
49 +
50 +    * `i18nvalues`: list of keys in msgargs whose value should be translated
51 +
52 +    Translation will be done **in-place** by calling :meth:`translate`.
53      """
54 
55 -    def __init__(self, entity, explanation):
56 +    def __init__(self, entity, errors, msgargs=None, i18nvalues=None):
57          # set args so ValidationError are serializable through pyro
58 -        SchemaError.__init__(self, entity, explanation)
59 +        SchemaError.__init__(self, entity, errors)
60          self.entity = entity
61 -        assert isinstance(explanation, dict), \
62 -            'validation error explanation must be a dict'
63 -        self.errors = explanation
64 +        assert isinstance(errors, dict), 'validation errors must be a dict'
65 +        self.errors = errors
66 +        self.msgargs = msgargs
67 +        self.i18nvalues = i18nvalues
68 +        self._translated = False
69 
70      def __unicode__(self):
71 +        if not self._translated:
72 +            self.tr(unicode)
73          if len(self.errors) == 1:
74              attr, error = self.errors.items()[0]
75              return u'%s (%s): %s' % (self.entity, attr, error)
76          errors = '\n'.join('* %s: %s' % (k, v) for k, v in self.errors.items())
77          return u'%s:\n%s' % (self.entity, errors)
78 +
79 +    def translate(self, _):
80 +        """Translate and interpolate messsages in the errors dictionary, using
81 +        the given translation function.
82 +
83 +        If no substitution has been given, suppose msg is already translated for
84 +        bw compat, so no translation occurs.
85 +
86 +        This method may only be called once.
87 +        """
88 +        assert not self._translated
89 +        self._translated = True
90 +        if self.msgargs is not None:
91 +            if self.i18nvalues:
92 +                for key in self.i18nvalues:
93 +                    self.msgargs[key] = _(self.msgargs[key])
94 +            for key, msg in self.errors.iteritems():
95 +                msg = _(msg).replace('%(KEY-', '%('+key+'-')
96 +                self.errors[key] = msg % self.msgargs
97 +
98 +
diff --git a/constraints.py b/constraints.py
@@ -1,6 +1,6 @@
99 -# copyright 2004-2011 LOGILAB S.A. (Paris, FRANCE), all rights reserved.
100 +# copyright 2004-2012 LOGILAB S.A. (Paris, FRANCE), all rights reserved.
101  # contact http://www.logilab.fr/ -- mailto:contact@logilab.fr
102  #
103  # This file is part of yams.
104  #
105  # yams is free software: you can redistribute it and/or modify it under the
@@ -51,13 +51,14 @@
106          """called to restore serialized data of a constraint. Should return
107          a `cls` instance
108          """
109          return cls()
110 
111 -    def failed_message(self, value, _=unicode):
112 -        return _('%(cstr)s constraint failed for value %(value)r') % {
113 -            'cstr': self, 'value': value}
114 +    def failed_message(self, key, value):
115 +        return _('%(KEY-cstr)s constraint failed for value %(KEY-value)r'), {
116 +            key+'-cstr': self,
117 +            key+'-value': value}
118 
119  # possible constraints ########################################################
120 
121  class UniqueConstraint(BaseConstraint):
122      """object of relation must be unique"""
@@ -121,17 +122,19 @@
123          if self.min is not None:
124              if len(value) < self.min:
125                  return False
126          return True
127 
128 -    def failed_message(self, value, _=unicode):
129 +    def failed_message(self, key, value):
130          if self.max is not None and len(value) > self.max:
131 -            return _('value should have maximum size of %s but found %s') % (
132 -                self.max, len(value))
133 +            return _('value should have maximum size of %(KEY-max)s but found %(KEY-size)s'), {
134 +                key+'-max': self.max,
135 +                key+'-size': len(value)}
136          if self.min is not None and len(value) < self.min:
137 -            return _('value should have minimum size of %s but found %s') % (
138 -                self.min, len(value))
139 +            return _('value should have minimum size of %(KEY-min)s but found %(KEY-size)s'), {
140 +                key+'-min': self.min,
141 +                key+'-size': len(value)}
142          assert False, 'shouldnt be there'
143 
144      def serialize(self):
145          """simple text serialization"""
146          if self.max and self.min:
@@ -180,13 +183,14 @@
147 
148      def check(self, entity, rtype, value):
149          """return true if the value maches the regular expression"""
150          return self._rgx.match(value, self.flags)
151 
152 -    def failed_message(self, value, _=unicode):
153 -        return _("%(value)r doesn't match the %(regexp)r regular expression") % {
154 -            'value': value, 'regexp': self.regexp}
155 +    def failed_message(self, key, value):
156 +        return _("%(KEY-value)r doesn't match the %(KEY-regexp)r regular expression"), {
157 +            key+'-value': value,
158 +            key+'-regexp': self.regexp}
159 
160      def serialize(self):
161          """simple text serialization"""
162          return u'%s,%s' % (self.regexp, self.flags)
163 
@@ -230,13 +234,15 @@
164      def check(self, entity, rtype, value):
165          """return true if the value satisfy the constraint, else false"""
166          boundary = actual_value(self.boundary, entity)
167          return OPERATORS[self.operator](value, boundary)
168 
169 -    def failed_message(self, value, _=unicode):
170 -        return _("value %(value)s must be %(op)s %(boundary)s") % {
171 -            'value': value, 'op': self.operator, 'boundary': self.boundary}
172 +    def failed_message(self, key, value):
173 +        return _("value %(KEY-value)s must be %(KEY-op)s %(KEY-boundary)s"), {
174 +            key+'-value': value,
175 +            key+'-op': self.operator,
176 +            key+'-boundary': self.boundary}
177 
178      def serialize(self):
179          """simple text serialization"""
180          return u'%s %s' % (self.operator, self.boundary)
181 
@@ -282,17 +288,19 @@
182          maxvalue = actual_value(self.maxvalue, entity)
183          if maxvalue is not None and value > maxvalue:
184              return False
185          return True
186 
187 -    def failed_message(self, value, _=unicode):
188 +    def failed_message(self, key, value):
189          if self.minvalue is not None and value < self.minvalue:
190 -            return _("value %(value)s must be >= %(boundary)s") % {
191 -                'value': value, 'boundary': self.minvalue}
192 +            return _("value %(KEY-value)s must be >= %(KEY-boundary)s"), {
193 +                key+'-value': value,
194 +                key+'-boundary': self.minvalue}
195          if self.maxvalue is not None and value > self.maxvalue:
196 -            return _("value %(value)s must be <= %(boundary)s") % {
197 -                'value': value, 'boundary': self.maxvalue}
198 +            return _("value %(KEY-value)s must be <= %(KEY-boundary)s"), {
199 +                key+'-value': value,
200 +                key+'-boundary': self.maxvalue}
201          assert False, 'shouldnt be there'
202 
203      def serialize(self):
204          """simple text serialization"""
205          return u'%s;%s' % (self.minvalue, self.maxvalue)
@@ -316,18 +324,19 @@
206 
207      def check(self, entity, rtype, value):
208          """return true if the value is in the specific vocabulary"""
209          return value in self.vocabulary(entity=entity)
210 
211 -    def failed_message(self, value, _=unicode):
212 +    def failed_message(self, key, value):
213          if isinstance(value, basestring):
214              value = '"%s"' % unicode(value)
215              choices = ', '.join('"%s"' % val for val in self.values)
216          else:
217              choices = ', '.join(unicode(val) for val in self.values)
218 -        return _('invalid value %(value)s, it must be one of %(choices)s') % {
219 -            'value': value, 'choices': choices}
220 +        return _('invalid value %(KEY-value)s, it must be one of %(KEY-choices)s'), {
221 +            key+'-value': value,
222 +            key+'-choices': choices}
223 
224      def vocabulary(self, **kwargs):
225          """return a list of possible values for the attribute"""
226          return self.values
227 
diff --git a/schema.py b/schema.py
@@ -16,10 +16,11 @@
228  # You should have received a copy of the GNU Lesser General Public License along
229  # with yams. If not, see <http://www.gnu.org/licenses/>.
230  """Classes to define generic Entities/Relations schemas."""
231 
232  __docformat__ = "restructuredtext en"
233 +_ = unicode
234 
235  import warnings
236  from copy import deepcopy
237  from decimal import Decimal
238 
@@ -508,15 +509,21 @@
239                      return True
240          return False
241 
242      ## validation ######################
243 
244 -    def check(self, entity, creation=False, _=unicode, relations=None):
245 +    def check(self, entity, creation=False, _=None, relations=None):
246          """check the entity and raises an ValidationError exception if it
247          contains some invalid fields (ie some constraints failed)
248          """
249 +        if _ is not None:
250 +            warnings.warn('[yams 0.36] _ argument is deprecated, remove it',
251 +                          DeprecationWarning, stacklevel=2)
252 +        _ = unicode
253          errors = {}
254 +        msgargs = {}
255 +        i18nvalues = []
256          relations = relations or self.subject_relations()
257          for rschema in relations:
258              if not rschema.final:
259                  continue
260              aschema = self.destination(rschema)
@@ -539,13 +546,16 @@
261              # skip other constraint if value is None and None is allowed
262              if value is None:
263                  if required:
264                      errors[qname] = _('required attribute')
265                  continue
266 +            rtype = rschema.type
267              if not aschema.check_value(value):
268 -                errors[qname] = _('incorrect value (%(value)s) for type "%(type)s"') % {
269 -                    'value':value, 'type': _(aschema.type)}
270 +                errors[qname] = _('incorrect value (%(KEY-value)r) for type "%(KEY-type)s"')
271 +                msgargs[qname+'-value'] = value
272 +                msgargs[qname+'-type'] = aschema.type
273 +                i18nvalues.append(qname+'-type')
274                  if isinstance(value, str) and aschema == 'String':
275                      errors[qname] += '; you might want to try unicode'
276                  continue
277              # ensure value has the correct python type
278              nvalue = aschema.convert_value(value)
@@ -554,13 +564,15 @@
279                  # this <entity> thing
280                  entity[rschema] = value = nvalue
281              # check arbitrary constraints
282              for constraint in rdef.constraints:
283                  if not constraint.check(entity, rschema, value):
284 -                    errors[qname] = constraint.failed_message(value, _)
285 +                    msg, args = constraint.failed_message(qname, value)
286 +                    errors[qname] = msg
287 +                    msgargs.update(args)
288          if errors:
289 -            raise ValidationError(entity, errors)
290 +            raise ValidationError(entity, errors, msgargs, i18nvalues)
291 
292      def check_value(self, value):
293          """check the value of a final entity (ie a const value)"""
294          return self.field_checkers[self](self, value)
295 
diff --git a/test/unittest_schema.py b/test/unittest_schema.py
@@ -1,7 +1,7 @@
296  # -*- coding: iso-8859-1 -*-
297 -# copyright 2004-2011 LOGILAB S.A. (Paris, FRANCE), all rights reserved.
298 +# copyright 2004-2012 LOGILAB S.A. (Paris, FRANCE), all rights reserved.
299  # contact http://www.logilab.fr/ -- mailto:contact@logilab.fr
300  #
301  # This file is part of yams.
302  #
303  # yams is free software: you can redistribute it and/or modify it under the
@@ -432,11 +432,37 @@
304          """check bad values of entity raises ValidationError exception"""
305          for etype, val_list in ATTRIBUTE_BAD_VALUES:
306              eschema = schema.eschema(etype)
307              # check attribute values one each time...
308              for item in val_list:
309 -                self.assertRaises(ValidationError, eschema.check, dict([item]))
310 +                with self.assertRaises(ValidationError) as cm:
311 +                    eschema.check(dict([item]))
312 +                # check calling tr works properly
313 +                cm.exception.translate(unicode)
314 +
315 +    def test_validation_error_translation(self):
316 +        """check bad values of entity raises ValidationError exception"""
317 +        eschema = schema.eschema('Person')
318 +        with self.assertRaises(ValidationError) as cm:
319 +            eschema.check({'nom': 1, 'promo': 2})
320 +        cm.exception.translate(unicode)
321 +        self.assertEqual(cm.exception.errors,
322 +                         {'nom-subject': u'incorrect value (1) for type "String"',
323 +                          'promo-subject': u'incorrect value (2) for type "String"'})
324 +        with self.assertRaises(ValidationError) as cm:
325 +            eschema.check({'nom': u'x'*21, 'prenom': u'x'*65})
326 +        cm.exception.translate(unicode)
327 +        self.assertEqual(cm.exception.errors,
328 +                         {'nom-subject': u'value should have maximum size of 20 but found 21',
329 +                          'prenom-subject': u'value should have maximum size of 64 but found 65'})
330 +
331 +        with self.assertRaises(ValidationError) as cm:
332 +            eschema.check({'tel': 1000000, 'fax': 1000001})
333 +        cm.exception.translate(unicode)
334 +        self.assertEqual(cm.exception.errors,
335 +                         {'fax-subject': u'value 1000001 must be <= 999999',
336 +                          'tel-subject': u'value 1000000 must be <= 999999'})
337 
338      def test_pickle(self):
339          """schema should be pickeable"""
340          import pickle
341          picklefile = mktemp()