Merge lp:~frankban/juju-quickstart/env-manage-fields into lp:juju-quickstart

Proposed by Francesco Banconi
Status: Merged
Merged at revision: 33
Proposed branch: lp:~frankban/juju-quickstart/env-manage-fields
Merge into: lp:juju-quickstart
Diff against target: 657 lines (+625/-1)
4 files modified
quickstart/models/envs.py (+1/-1)
quickstart/models/fields.py (+280/-0)
quickstart/settings.py (+3/-0)
quickstart/tests/models/test_fields.py (+341/-0)
To merge this branch: bzr merge lp:~frankban/juju-quickstart/env-manage-fields
Reviewer Review Type Date Requested Status
Juju GUI Hackers Pending
Review via email: mp+198537@code.launchpad.net

Description of the change

Env management: fields.

See the module docstring for an
explanation of how those fields
will be used.

Tests: `make check`.
No QA required.

https://codereview.appspot.com/37820046/

To post a comment you must log in.
Revision history for this message
Francesco Banconi (frankban) wrote :

Reviewers: mp+198537_code.launchpad.net,

Message:
Please take a look.

Description:
Env management: fields.

See the module docstring for an
explanation of how those fields
will be used.

Tests: `make check`.
No QA required.

https://code.launchpad.net/~frankban/juju-quickstart/env-manage-fields/+merge/198537

(do not edit description out of merge proposal)

Please review this at https://codereview.appspot.com/37820046/

Affected files (+623, -1 lines):
   A [revision details]
   M quickstart/models/envs.py
   A quickstart/models/fields.py
   M quickstart/settings.py
   A quickstart/tests/models/test_fields.py

Revision history for this message
Brad Crittenden (bac) wrote :

The code LGTM with a few changes.

I am a bit confused as to where we're going with this work. It feels a
little like you're reinventing Zope. :) Let's not do that.

https://codereview.appspot.com/37820046/diff/1/quickstart/models/fields.py
File quickstart/models/fields.py (right):

https://codereview.appspot.com/37820046/diff/1/quickstart/models/fields.py#newcode63
quickstart/models/fields.py:63: - editable: True if the associated value
must be considered immutable.
This definition seems backwards. s/immutable/mutable/ ?

https://codereview.appspot.com/37820046/diff/1/quickstart/models/fields.py#newcode77
quickstart/models/fields.py:77: been previously validated.
Should there be a trap for that situation? A _validated flag?

https://codereview.appspot.com/37820046/

Revision history for this message
Gary Poster (gary) wrote :
Download full text (5.7 KiB)

LGTM and nice work, with consideration of various fairly trivial
comments.

I'm interested in bac's comments. Calling something Zope-like these
days is unfortunately not a complement. Perhaps he's referring to using
classes for fields? Certainly these fields could be data as well: your
classes really simply have some flags and some functions, and the
validation functions could be composed (more?) attractively
functionally, to my eyes. That said, while I am intrigued by the idea
of simplifying the fields into data, and think it might be a win, I
don't think it will be a big enough win to justify refactoring at this
time; and I think people with an OOP bent might not like the refactoring
at all.

Moreover, maybe that's not anywhere close to what bac means?

https://codereview.appspot.com/37820046/diff/1/quickstart/models/fields.py
File quickstart/models/fields.py (right):

https://codereview.appspot.com/37820046/diff/1/quickstart/models/fields.py#newcode24
quickstart/models/fields.py:24: each provider type is associated to a
sequence of fields. Those fields describe
...associated with a sequence of fields.

https://codereview.appspot.com/37820046/diff/1/quickstart/models/fields.py#newcode49
quickstart/models/fields.py:49: - autogenerated: indicates this is a
string field that can be
It strikes me that this functionality is along a different logical axis
than field type. I'd be tempted to have the code look for a generate
method, for instance, as a flag: if it exists, it can autogenerate (a
string, a number, whatever). I don't think this is very important: just
a suggestion.

https://codereview.appspot.com/37820046/diff/1/quickstart/models/fields.py#newcode53
quickstart/models/fields.py:53: edited using the usual input edit
widget.
Which effectively means multi-line string, right? Might as well be
explicit about it.

https://codereview.appspot.com/37820046/diff/1/quickstart/models/fields.py#newcode56
quickstart/models/fields.py:56: - name: the name identifying a specific
piece of information
Is this...

the key identifying a specific piece of information in the environments
data

...or not? Might be good to clarify, either way.

https://codereview.appspot.com/37820046/diff/1/quickstart/models/fields.py#newcode83
quickstart/models/fields.py:83: self, name, label=None, help='',
required=True, editable=True):
I generally prefer flags to default to false, but I think this is
probably the right compromise between clarity and convenience for
required and editable...unless we can choose different names?

optional=False, readonly=False

readonly conveys the idea well. Less sure about "optional"

https://codereview.appspot.com/37820046/diff/1/quickstart/models/fields.py#newcode84
quickstart/models/fields.py:84: """Initialize a field, only the name
identifier is required."""
Run-on sentence. Any of the following are better

Initialize a field. Only...
Initialize a field; only...

I prefer the period in this case.

https://codereview.appspot.com/37820046/diff/1/quickstart/models/fields.py#newcode100
quickstart/models/fields.py:100: if isinstance(value, str):
When would this happen? Or is this just insurance?

https://codereview.appspot.com/3782...

Read more...

Revision history for this message
Gary Poster (gary) wrote :

On 2013/12/11 14:15:21, gary.poster wrote:
> LGTM and nice work, with consideration of various fairly trivial
comments.

> I'm interested in bac's comments. Calling something Zope-like these
days is
> unfortunately not a complement.

s/complement/compliment/ :-P

https://codereview.appspot.com/37820046/

38. By Francesco Banconi

Changes as per review.

39. By Francesco Banconi

Gettng rid of the autogenerated field_type.

40. By Francesco Banconi

Some other changes as per review.

41. By Francesco Banconi

Changes as per review.

Revision history for this message
Francesco Banconi (frankban) wrote :
Download full text (5.7 KiB)

*** Submitted:

Env management: fields.

See the module docstring for an
explanation of how those fields
will be used.

Tests: `make check`.
No QA required.

R=bac, gary.poster
CC=
https://codereview.appspot.com/37820046

https://codereview.appspot.com/37820046/diff/1/quickstart/models/fields.py
File quickstart/models/fields.py (right):

https://codereview.appspot.com/37820046/diff/1/quickstart/models/fields.py#newcode24
quickstart/models/fields.py:24: each provider type is associated to a
sequence of fields. Those fields describe
On 2013/12/11 14:15:21, gary.poster wrote:
> ...associated with a sequence of fields.

Done.

https://codereview.appspot.com/37820046/diff/1/quickstart/models/fields.py#newcode49
quickstart/models/fields.py:49: - autogenerated: indicates this is a
string field that can be
On 2013/12/11 14:15:21, gary.poster wrote:
> It strikes me that this functionality is along a different logical
axis than
> field type. I'd be tempted to have the code look for a generate
method, for
> instance, as a flag: if it exists, it can autogenerate (a string, a
number,
> whatever). I don't think this is very important: just a suggestion.

Good idea, done.

https://codereview.appspot.com/37820046/diff/1/quickstart/models/fields.py#newcode53
quickstart/models/fields.py:53: edited using the usual input edit
widget.
On 2013/12/11 14:15:21, gary.poster wrote:
> Which effectively means multi-line string, right? Might as well be
explicit
> about it.

Right, fixed.

https://codereview.appspot.com/37820046/diff/1/quickstart/models/fields.py#newcode56
quickstart/models/fields.py:56: - name: the name identifying a specific
piece of information
On 2013/12/11 14:15:21, gary.poster wrote:
> Is this...

> the key identifying a specific piece of information in the
environments data

> ...or not? Might be good to clarify, either way.

Done.

https://codereview.appspot.com/37820046/diff/1/quickstart/models/fields.py#newcode63
quickstart/models/fields.py:63: - editable: True if the associated value
must be considered immutable.
On 2013/12/11 13:41:16, bac wrote:
> This definition seems backwards. s/immutable/mutable/ ?

Done.

https://codereview.appspot.com/37820046/diff/1/quickstart/models/fields.py#newcode77
quickstart/models/fields.py:77: been previously validated.
On 2013/12/11 13:41:16, bac wrote:
> Should there be a trap for that situation? A _validated flag?

Not sure. We don't have state in those fields, e.g.:
field = MyField('foo')
field.validate(42)
field.normalize(None)

https://codereview.appspot.com/37820046/diff/1/quickstart/models/fields.py#newcode83
quickstart/models/fields.py:83: self, name, label=None, help='',
required=True, editable=True):
On 2013/12/11 14:15:21, gary.poster wrote:
> I generally prefer flags to default to false, but I think this is
probably the
> right compromise between clarity and convenience for required and
> editable...unless we can choose different names?

> optional=False, readonly=False

> readonly conveys the idea well. Less sure about "optional"

As agreedd, we now use required=False, readonly=False.

https://codereview.appspot.com/37820046/diff/1/quickstart/models/fields.py#newcode84
quickstart/models/f...

Read more...

Revision history for this message
Francesco Banconi (frankban) wrote :

Thank you both for the great reviews!

https://codereview.appspot.com/37820046/

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'quickstart/models/envs.py'
2--- quickstart/models/envs.py 2013-12-10 15:43:33 +0000
3+++ quickstart/models/envs.py 2013-12-11 17:45:26 +0000
4@@ -96,7 +96,7 @@
5
6 from quickstart import (
7 serializers,
8- utils
9+ utils,
10 )
11
12
13
14=== added file 'quickstart/models/fields.py'
15--- quickstart/models/fields.py 1970-01-01 00:00:00 +0000
16+++ quickstart/models/fields.py 2013-12-11 17:45:26 +0000
17@@ -0,0 +1,280 @@
18+# This file is part of the Juju Quickstart Plugin, which lets users set up a
19+# Juju environment in very few steps (https://launchpad.net/juju-quickstart).
20+# Copyright (C) 2013 Canonical Ltd.
21+#
22+# This program is free software: you can redistribute it and/or modify it under
23+# the terms of the GNU Affero General Public License version 3, as published by
24+# the Free Software Foundation.
25+#
26+# This program is distributed in the hope that it will be useful, but WITHOUT
27+# ANY WARRANTY; without even the implied warranties of MERCHANTABILITY,
28+# SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
29+# Affero General Public License for more details.
30+#
31+# You should have received a copy of the GNU Affero General Public License
32+# along with this program. If not, see <http://www.gnu.org/licenses/>.
33+
34+"""Juju Quickstart field definitions.
35+
36+A field is a simple object describing a value, e.g. a label or a help text to
37+be associated to that value. A field also provides the logic to display,
38+validate and normalize the given data.
39+
40+This module is useful as part of the environments metadata definition, in which
41+each provider type is associated with a sequence of fields. Those fields
42+describe how an environment of that type should look like, and provide a way to
43+validate the whole environment on a per-field basis.
44+
45+See quickstart.models.envs.get_env_type_db for an example of how this works.
46+XXX frankban 13-12-11:
47+ the function above will be implemented in the next branch.
48+"""
49+
50+from __future__ import unicode_literals
51+
52+import uuid
53+
54+
55+class Field(object):
56+ """Describe a piece of information.
57+
58+ Also provide the logic to display, normalize and validate input data.
59+
60+ Field subclasses can define a "field_type" class attribute that can be
61+ used by view code to choose an appropriate widget to use for that type of
62+ field instances, e.g. a "bool" field type indicates that a checkbox is
63+ appropriate when editing that field value. The following field types are
64+ defined by fields in this module:
65+ - bool: as mentioned, values are expected to be boolean values;
66+ If field_type is not specified, view code assumes the values can be
67+ edited using the usual input edit widget which handles multi-line strings.
68+
69+ Field instances have the following attributes:
70+ - name: the key identifying a specific piece of information. In the
71+ environments context this can be, for instance, "admin-secret" or
72+ "default-series";
73+ - label: a human friendly string identifying this field
74+ (e.g. "Admin Secret");
75+ - help: help text associated with this field
76+ (e.g. "the password you use for authenticating");
77+ - required: True if this is a required field, False otherwise;
78+ - readonly: True if the associated value must be considered immutable.
79+
80+ Field instances also expose the following methods:
81+ - display(value): how the value should be displayed by views
82+ (usually just the value itself as a unicode string is returned);
83+ - normalize(value): return the normalized value, e.g. a string field
84+ might return a stripped version of the input value;
85+ - validate(value): validate the given value, raising a ValueError if
86+ the input value is not valid, returning None otherwise;
87+ - generate(): this optional method indicates the value associated with
88+ this field can be optionally automatically generated by view code.
89+ When implemented, this method must return a suitable generated value.
90+
91+ Note that it is not safe to call normalize on a value if that value has not
92+ been previously validated.
93+ """
94+
95+ # Since this is the default field the type is not specified.
96+ field_type = None
97+
98+ def __init__(
99+ self, name, label=None, help='', required=False, readonly=False):
100+ """Initialize a field. Only the name identifier is required."""
101+ self.name = name
102+ self.label = name if label is None else label
103+ self.help = help
104+ self.required = required
105+ self.readonly = readonly
106+
107+ def __repr__(self):
108+ name = self.name.encode('utf-8')
109+ return b'<{}: {}>'.format(self.__class__.__name__, name)
110+
111+ def display(self, value):
112+ """Return a value to display.
113+
114+ Override this method to change how the value is displayed in view code.
115+ """
116+ return unicode(value)
117+
118+ def normalize(self, value):
119+ """Return a normalized version of the given value."""
120+ return value
121+
122+ def validate(self, value):
123+ """Validate the given value.
124+
125+ Return a possibly normalized version of the value.
126+ Raise a ValueError if the given value is not valid.
127+ """
128+ if self.required and not value:
129+ msg = 'a value is required for the {} field'.format(self.label)
130+ raise ValueError(msg.encode('utf-8'))
131+
132+
133+class StringField(Field):
134+ """Values associated with this field must be strings."""
135+
136+ def normalize(self, value):
137+ """Strip the string."""
138+ if value is None:
139+ return ''
140+ return value.strip()
141+
142+ def validate(self, value):
143+ """Check that the value is a string."""
144+ if not isinstance(value, (unicode, type(None))):
145+ # Assume view code always works with unicode strings.
146+ msg = 'the {} field requires a string value'.format(self.label)
147+ raise ValueError(msg.encode('utf-8'))
148+ value = self.normalize(value)
149+ # The parent field ensures the value is set if required.
150+ super(StringField, self).validate(value)
151+
152+
153+class IntField(Field):
154+ """Values associated with this field must be integers."""
155+
156+ def __init__(self, name, min_value=None, max_value=None, **kwargs):
157+ """Initialize an integer field.
158+
159+ The "min_value" and "max_value" keyword arguments, if provided, are
160+ used in the validation process.
161+ """
162+ super(IntField, self).__init__(name, **kwargs)
163+ self.min_value = min_value
164+ self.max_value = max_value
165+
166+ def normalize(self, value):
167+ """Return the value as an integer.
168+
169+ Return None if the value is an empty string or None. In these cases,
170+ the field value is considered not set.
171+ """
172+ if isinstance(value, unicode):
173+ value = value.strip()
174+ if value in ('', None):
175+ return None
176+ return int(value)
177+
178+ def validate(self, value):
179+ """Validate the integer value.
180+
181+ Raise a ValueError if:
182+ - the normalized value is None but the value is required;
183+ - the normalized field is set but it is not an integer number;
184+ - the normalized field is a number but not in the range defined
185+ by self.min_value and self.max_value.
186+ """
187+ label = self.label
188+ # Ensure the value, if set, is an integer.
189+ msg = 'the {} field requires an integer value'.format(label)
190+ # Avoid implicit boolean to integer conversion.
191+ if isinstance(value, bool):
192+ raise ValueError(msg.encode('utf-8'))
193+ try:
194+ value = self.normalize(value)
195+ except (TypeError, ValueError):
196+ raise ValueError(msg.encode('utf-8'))
197+ # Ensure the value is set if required.
198+ if value is None:
199+ if self.required:
200+ msg = 'a value is required for the {} field'.format(self.label)
201+ raise ValueError(msg.encode('utf-8'))
202+ return value
203+ # Ensure the value is in the given range.
204+ min_value = self.min_value
205+ max_value = self.max_value
206+ if (min_value is not None) and (max_value is not None):
207+ if not (min_value <= value <= max_value):
208+ msg = 'the {} value must be in the {}-{} range'.format(
209+ label, min_value, max_value)
210+ raise ValueError(msg.encode('utf-8'))
211+ elif min_value is not None:
212+ if value < min_value:
213+ msg = 'the {} value must be >= {}'.format(label, min_value)
214+ raise ValueError(msg.encode('utf-8'))
215+ elif max_value is not None:
216+ if value > max_value:
217+ msg = 'the {} value must be <= {}'.format(label, max_value)
218+ raise ValueError(msg.encode('utf-8'))
219+
220+
221+class BoolField(Field):
222+ """Values associated with this field must be booleans."""
223+
224+ field_type = 'bool'
225+
226+ def __init__(self, name, default=False, **kwargs):
227+ """Initialize the boolean field.
228+
229+ It is possible to pass a "default" keyword argument in order to specify
230+ the default value (True or False) to use if the value is unset (None).
231+ """
232+ super(BoolField, self).__init__(name, **kwargs)
233+ self.default = default
234+
235+ def normalize(self, value):
236+ """Return the default value if the given one is None (unset)."""
237+ if value is None:
238+ value = self.default
239+ return value
240+
241+ def validate(self, value):
242+ """Check that the value is a boolean."""
243+ value = self.normalize(value)
244+ if not isinstance(value, bool):
245+ msg = 'the {} field requires a boolean value'.format(self.label)
246+ raise ValueError(msg.encode('utf-8'))
247+
248+
249+class AutoGeneratedStringField(StringField):
250+ """Can automatically generate string values if they are not provided.
251+
252+ Subclasses can override the generate method to return customized values.
253+ """
254+
255+ def generate(self):
256+ """Generate a uuid valid value."""
257+ return '{}-{}'.format(self.name[:3], uuid.uuid4().hex)
258+
259+
260+class ChoiceField(StringField):
261+ """A string field whose value must be included in the given choices."""
262+
263+ def __init__(self, name, choices=(), **kwargs):
264+ """Initialize the choices field with the given choices."""
265+ super(ChoiceField, self).__init__(name, **kwargs)
266+ self.choices = tuple(choices)
267+
268+ def validate(self, value):
269+ """Check the field is set if required.
270+
271+ If the field is set, also check it is included in self.choices.
272+ """
273+ # The parent field ensures the value is set if required.
274+ super(ChoiceField, self).validate(value)
275+ value = self.normalize(value)
276+ choices = list(self.choices)
277+ if not self.required:
278+ choices.append('')
279+ if value not in choices:
280+ msg = 'the {} requires the value to be one of the following: {}'
281+ raise ValueError(
282+ msg.format(self.label, ', '.join(self.choices)).encode('utf-8')
283+ )
284+
285+
286+class PasswordField(StringField):
287+ """Assume values associated with this field represent sensible data."""
288+
289+ def display(self, value):
290+ """Obfuscate the value."""
291+ if value:
292+ return '*****'
293+ return 'None'
294+
295+
296+class AutoGeneratedPasswordField(AutoGeneratedStringField, PasswordField):
297+ """Values are passwords which can be automatically generated."""
298
299=== modified file 'quickstart/settings.py'
300--- quickstart/settings.py 2013-12-06 10:07:40 +0000
301+++ quickstart/settings.py 2013-12-11 17:45:26 +0000
302@@ -31,6 +31,9 @@
303 # The quickstart app short description.
304 DESCRIPTION = 'set up a Juju environment (including the GUI) in very few steps'
305
306+# The possible values for the environments.yaml default-series field.
307+JUJU_DEFAULT_SERIES = ('precise', 'quantal', 'raring', 'saucy')
308+
309 # Retrieve the current juju-core home.
310 JUJU_HOME = os.getenv('JUJU_HOME', '~/.juju')
311
312
313=== added file 'quickstart/tests/models/test_fields.py'
314--- quickstart/tests/models/test_fields.py 1970-01-01 00:00:00 +0000
315+++ quickstart/tests/models/test_fields.py 2013-12-11 17:45:26 +0000
316@@ -0,0 +1,341 @@
317+# This file is part of the Juju Quickstart Plugin, which lets users set up a
318+# Juju environment in very few steps (https://launchpad.net/juju-quickstart).
319+# Copyright (C) 2013 Canonical Ltd.
320+#
321+# This program is free software: you can redistribute it and/or modify it under
322+# the terms of the GNU Affero General Public License version 3, as published by
323+# the Free Software Foundation.
324+#
325+# This program is distributed in the hope that it will be useful, but WITHOUT
326+# ANY WARRANTY; without even the implied warranties of MERCHANTABILITY,
327+# SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
328+# Affero General Public License for more details.
329+#
330+# You should have received a copy of the GNU Affero General Public License
331+# along with this program. If not, see <http://www.gnu.org/licenses/>.
332+
333+"""Tests for the Juju Quickstart field definitions."""
334+
335+from __future__ import unicode_literals
336+
337+from contextlib import contextmanager
338+import unittest
339+
340+from quickstart.models import fields
341+from quickstart.tests import helpers
342+
343+
344+class FieldTestsMixin(object):
345+ """Define a collection of tests shared by all fields.
346+
347+ Subclasses must define a field_class attribute.
348+ """
349+
350+ @contextmanager
351+ def assert_not_raises(self, exception, message=None):
352+ """Ensure the given exception is not raised in the code block."""
353+ try:
354+ yield
355+ except exception as err:
356+ msg = b'unexpected {}: {}'.format(err.__class__.__name__, err)
357+ if message:
358+ msg += b' ({!r})'.format(message)
359+ self.fail(msg)
360+
361+ def test_attributes(self):
362+ # The field attributes are properly stored in the field instance.
363+ field = self.field_class(
364+ 'first-name', label='first name', help='your first name',
365+ required=True, readonly=False)
366+ self.assertEqual('first-name', field.name)
367+ self.assertEqual('first name', field.label)
368+ self.assertEqual('your first name', field.help)
369+ self.assertTrue(field.required)
370+ self.assertFalse(field.readonly)
371+
372+ def test_default_attributes(self):
373+ # Only the name identifier is required when instantiating a field.
374+ field = self.field_class('last-name')
375+ self.assertEqual('last-name', field.name)
376+ self.assertEqual('last-name', field.label)
377+ self.assertEqual('', field.help)
378+ self.assertFalse(field.required)
379+ self.assertFalse(field.readonly)
380+
381+ def test_field_representation(self):
382+ # A field object is properly represented.
383+ field = self.field_class('email')
384+ expected = b'<{}: email>'.format(self.field_class.__name__)
385+ self.assertEqual(expected, repr(field))
386+
387+ def test_display(self):
388+ # A field is able to display values.
389+ field = self.field_class('phone-number')
390+ for value in (None, 42, True, 'a unicode string'):
391+ self.assertEqual(unicode(value), field.display(value), value)
392+
393+
394+class TestField(
395+ FieldTestsMixin, helpers.ValueErrorTestsMixin, unittest.TestCase):
396+
397+ field_class = fields.Field
398+
399+ def test_normalization(self):
400+ # The base field normalization is a no-op.
401+ field = self.field_class('email')
402+ for value in (None, 42, True, 'a unicode string'):
403+ self.assertEqual(value, field.normalize(value), value)
404+
405+ def test_validation_success(self):
406+ # The validation succeeds if the value is set.
407+ field = self.field_class('email')
408+ for value in (42, True, 'a unicode string', ' '):
409+ self.assertIsNone(field.validate(value), value)
410+
411+ def test_validation_not_required(self):
412+ # If the field is not required no errors are raised.
413+ field = self.field_class('email', required=False)
414+ for value in ('', False, None):
415+ with self.assert_not_raises(ValueError, value):
416+ field.validate(value)
417+
418+ def test_validation_error_required(self):
419+ # A ValueError is raised by required fields if the value is not set.
420+ field = self.field_class('email', label='email address', required=True)
421+ expected = 'a value is required for the email address field'
422+ for value in ('', False, None):
423+ with self.assert_value_error(expected):
424+ field.validate(value)
425+
426+
427+class TestStringField(
428+ FieldTestsMixin, helpers.ValueErrorTestsMixin, unittest.TestCase):
429+
430+ field_class = fields.StringField
431+
432+ def test_normalization(self):
433+ # The string field normalization returns the stripped string value.
434+ field = self.field_class('email')
435+ for value in ('a value', '\t tabs and spaces ', ' ', 'newlines\n\n'):
436+ self.assertEqual(value.strip(), field.normalize(value), value)
437+
438+ def test_none_normalization(self):
439+ # The string field normalization turns None values into empty strings.
440+ field = self.field_class('email')
441+ self.assertEqual('', field.normalize(None))
442+
443+ def test_validation_success(self):
444+ # The validation succeeds if the value is set.
445+ field = self.field_class('email')
446+ for value in ('a value', '\t tabs and spaces ', 'newlines\n\n'):
447+ self.assertIsNone(field.validate(value), value)
448+
449+ def test_validation_not_required(self):
450+ # If the field is not required no errors are raised.
451+ field = self.field_class('email', required=False)
452+ for value in ('', None, ' ', '\t\n'):
453+ with self.assert_not_raises(ValueError, value):
454+ field.validate(value)
455+
456+ def test_validation_error_required(self):
457+ # A ValueError is raised by required fields if the value is not set.
458+ field = self.field_class('email', label='email address', required=True)
459+ expected = 'a value is required for the email address field'
460+ for value in ('', None, ' ', '\t\n'):
461+ with self.assert_value_error(expected):
462+ field.validate(value)
463+
464+ def test_validation_error_not_a_string(self):
465+ # A ValueError is raised by string fields if the value is not a string.
466+ field = self.field_class('email', label='email address')
467+ expected = 'the email address field requires a string value'
468+ for value in (42, False, []):
469+ with self.assert_value_error(expected):
470+ field.validate(value)
471+
472+
473+class TestIntField(
474+ FieldTestsMixin, helpers.ValueErrorTestsMixin, unittest.TestCase):
475+
476+ field_class = fields.IntField
477+
478+ def test_normalization(self):
479+ # The int field normalization returns the values as integers.
480+ field = self.field_class('tcp-port')
481+ for value in (42, 42.0, '42', '\t42 ', '42\n\n'):
482+ self.assertEqual(42, field.normalize(value), value)
483+
484+ def test_none_normalization(self):
485+ # The int field normalization returns None if the value is not set.
486+ field = self.field_class('tcp-port')
487+ for value in (None, '', ' ', '\t\n'):
488+ self.assertIsNone(field.normalize(value), value)
489+
490+ def test_validation_success(self):
491+ # The value as an integer number is returned if the value is valid.
492+ field = self.field_class('tcp-port')
493+ for value in (42, 42.0, '42', '\t42 ', '42\n\n'):
494+ with self.assert_not_raises(ValueError, value):
495+ field.validate(value)
496+
497+ def test_validation_success_zero(self):
498+ # The zero value is not considered "unset".
499+ field = self.field_class('tcp-port')
500+ with self.assert_not_raises(ValueError):
501+ field.validate(0)
502+
503+ def test_validation_success_in_range(self):
504+ # The value as an integer number is returned if the value is valid and
505+ # is in the specified range of min/max values.
506+ field = self.field_class('tcp-port', min_value=42, max_value=47)
507+ for value in (42, 42.0, '42', '\t42 ', '42\n\n'):
508+ with self.assert_not_raises(ValueError, value):
509+ field.validate(value)
510+
511+ def test_validation_not_required(self):
512+ # If the field is not required no errors are raised.
513+ field = self.field_class('tcp-port', required=False)
514+ for value in ('', None, ' ', '\t\n'):
515+ with self.assert_not_raises(ValueError, value):
516+ self.assertIsNone(field.validate(value), value)
517+
518+ def test_validation_error_required(self):
519+ # A ValueError is raised by required fields if the value is not set.
520+ field = self.field_class('tcp-port', label='TCP port', required=True)
521+ expected = 'a value is required for the TCP port field'
522+ for value in ('', None, ' ', '\t\n'):
523+ with self.assert_value_error(expected):
524+ field.validate(value)
525+
526+ def test_validation_error_not_a_number(self):
527+ # A ValueError is raised by int fields if the value is not a number.
528+ field = self.field_class('tcp-port', label='TCP port')
529+ expected = 'the TCP port field requires an integer value'
530+ for value in ('a string', False, {}, []):
531+ with self.assert_value_error(expected):
532+ field.validate(value)
533+
534+ def test_validation_error_min_value(self):
535+ # A ValueError is raised if value < min_value.
536+ field = self.field_class('tcp-port', min_value=42, label='TCP port')
537+ with self.assert_value_error('the TCP port value must be >= 42'):
538+ field.validate(27)
539+
540+ def test_validation_error_max_value(self):
541+ # A ValueError is raised if value > max_value.
542+ field = self.field_class('tcp-port', max_value=42, label='TCP port')
543+ with self.assert_value_error('the TCP port value must be <= 42'):
544+ field.validate(47)
545+
546+ def test_validation_error_range(self):
547+ # A ValueError is raised if not min_value <= value <= max_value.
548+ field = self.field_class(
549+ 'tcp-port', min_value=42, max_value=47, label='TCP port')
550+ expected = 'the TCP port value must be in the 42-47 range'
551+ with self.assert_value_error(expected):
552+ field.validate(27)
553+
554+
555+class TestBoolField(
556+ FieldTestsMixin, helpers.ValueErrorTestsMixin, unittest.TestCase):
557+
558+ field_class = fields.BoolField
559+
560+ def test_normalization(self):
561+ # The bool field normalization returns the value itself.
562+ field = self.field_class('is-public')
563+ self.assertTrue(field.normalize(True))
564+ self.assertFalse(field.normalize(False))
565+
566+ def test_none_normalization(self):
567+ # The string field normalization turns None values into the default.
568+ field_true = self.field_class('is-public', default=True)
569+ field_false = self.field_class('is-private', default=False)
570+ self.assertTrue(field_true.normalize(None))
571+ self.assertFalse(field_false.normalize(None))
572+
573+ def test_validation_success(self):
574+ # The validation succeeds if the value is boolean.
575+ field = self.field_class('is-public')
576+ with self.assert_not_raises(ValueError):
577+ field.validate(True)
578+ field.validate(False)
579+
580+ def test_validation_error_not_a_boolean(self):
581+ # A ValueError is raised by string fields if the value is not a bool.
582+ field = self.field_class('is-public', label='is public')
583+ expected = 'the is public field requires a boolean value'
584+ for value in (42, 'a string', []):
585+ with self.assert_value_error(expected):
586+ field.validate(value)
587+
588+
589+class TestAutoGeneratedStringField(TestStringField):
590+
591+ field_class = fields.AutoGeneratedStringField
592+
593+ def test_generate(self):
594+ # The autogenerated field can generate random values.
595+ field = self.field_class('auto')
596+ value1 = field.generate()
597+ value2 = field.generate()
598+ # The generated values are unicode strings.
599+ self.assertIsInstance(value1, unicode)
600+ self.assertIsInstance(value2, unicode)
601+ # The generated values are not empty.
602+ self.assertNotEqual(0, len(value1))
603+ self.assertNotEqual(0, len(value2))
604+ # The generated values are different to each other.
605+ self.assertNotEqual(value1, value2)
606+
607+
608+class TestChoiceField(TestStringField):
609+
610+ field_class = fields.ChoiceField
611+ choices = ('these', 'are', 'the', 'voyages')
612+
613+ def test_validation_success(self):
614+ # No errors are raised if the value is included in the choices.
615+ field = self.field_class('word', choices=self.choices)
616+ for value in self.choices:
617+ with self.assert_not_raises(ValueError, value):
618+ field.validate(value)
619+
620+ def test_validation_error_not_in_choices(self):
621+ # A ValueError is raised by choice fields if the value is not included
622+ # in the specified choices/
623+ field = self.field_class(
624+ 'word', choices=self.choices, label='selected word')
625+ expected = ('the selected word requires the value to be one of the '
626+ 'following: these, are, the, voyages')
627+ with self.assert_value_error(expected):
628+ field.validate('resistance is futile')
629+
630+
631+class TestPasswordField(TestStringField):
632+
633+ field_class = fields.PasswordField
634+
635+ def test_display(self):
636+ # A placeholder value is displayed.
637+ field = self.field_class('passwd')
638+ for value in (42, True, 'a unicode string'):
639+ self.assertEqual('*****', field.display(value), value)
640+
641+ def test_display_bytes(self):
642+ # A placeholder value is still displayed.
643+ snowman = b'Here is a snowman\xc2\xa1: \xe2\x98\x83'
644+ field = self.field_class('passwd')
645+ self.assertEqual('*****', field.display(snowman))
646+
647+ def test_display_no_values(self):
648+ # Do not display the placeholder if the value is not set.
649+ field = self.field_class('passwd')
650+ for value in (None, False, ''):
651+ self.assertEqual('None', field.display(value), value)
652+
653+
654+class TestAutoGeneratedPasswordField(
655+ TestAutoGeneratedStringField, TestPasswordField):
656+
657+ field_class = fields.AutoGeneratedPasswordField

Subscribers

People subscribed via source and target branches