Merge lp:~frankban/juju-quickstart/env-manage-fields into lp:juju-quickstart
- env-manage-fields
- Merge into trunk
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 |
Related bugs: |
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
Juju GUI Hackers | Pending | ||
Review via email:
|
Commit message
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.
![](/+icing/build/overlay/assets/skins/sam/images/close.gif)
Francesco Banconi (frankban) wrote : | # |
![](/+icing/build/overlay/assets/skins/sam/images/close.gif)
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:/
File quickstart/
https:/
quickstart/
must be considered immutable.
This definition seems backwards. s/immutable/
https:/
quickstart/
Should there be a trap for that situation? A _validated flag?
![](/+icing/build/overlay/assets/skins/sam/images/close.gif)
Gary Poster (gary) 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. 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:/
File quickstart/
https:/
quickstart/
sequence of fields. Those fields describe
...associated with a sequence of fields.
https:/
quickstart/
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:/
quickstart/
widget.
Which effectively means multi-line string, right? Might as well be
explicit about it.
https:/
quickstart/
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:/
quickstart/
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:/
quickstart/
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:/
quickstart/
When would this happen? Or is this just insurance?
![](/+icing/build/overlay/assets/skins/sam/images/close.gif)
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/
- 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.
![](/+icing/build/overlay/assets/skins/sam/images/close.gif)
Francesco Banconi (frankban) wrote : | # |
*** 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:/
https:/
File quickstart/
https:/
quickstart/
sequence of fields. Those fields describe
On 2013/12/11 14:15:21, gary.poster wrote:
> ...associated with a sequence of fields.
Done.
https:/
quickstart/
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:/
quickstart/
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:/
quickstart/
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:/
quickstart/
must be considered immutable.
On 2013/12/11 13:41:16, bac wrote:
> This definition seems backwards. s/immutable/
Done.
https:/
quickstart/
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
https:/
quickstart/
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:/
quickstart/
![](/+icing/build/overlay/assets/skins/sam/images/close.gif)
Francesco Banconi (frankban) wrote : | # |
Thank you both for the great reviews!
Preview Diff
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 |
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): models/ envs.py models/ fields. py settings. py tests/models/ test_fields. py
A [revision details]
M quickstart/
A quickstart/
M quickstart/
A quickstart/