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

Proposed by Francesco Banconi
Status: Merged
Merged at revision: 34
Proposed branch: lp:~frankban/juju-quickstart/env-manage-models-meta
Merge into: lp:juju-quickstart
Diff against target: 1060 lines (+730/-68)
4 files modified
quickstart/models/envs.py (+215/-6)
quickstart/models/fields.py (+41/-45)
quickstart/tests/models/test_envs.py (+385/-1)
quickstart/tests/models/test_fields.py (+89/-16)
To merge this branch: bzr merge lp:~frankban/juju-quickstart/env-manage-models-meta
Reviewer Review Type Date Requested Status
Juju GUI Hackers Pending
Review via email: mp+198756@code.launchpad.net

Description of the change

Implement the missing model bits.

Thanks to Gary for his help in defining
this approach with me in several pre-imp
calls.

See the envs module docstring for a
description of the functions implemented
in this branch.

This branch also includes some
fixes/improvements to the fields module,
e.g. the handling of default and unset values.

Tests: make check
No QA.

https://codereview.appspot.com/41350043/

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

Reviewers: mp+198756_code.launchpad.net,

Message:
Please take a look.

Description:
Implement the missing model bits.

Thanks to Gary for his help in defining
this approach with me in several pre-imp
calls.

See the envs module docstring for a
description of the functions implemented
in this branch.

This branch also includes some
fixes/improvements to the fields module,
e.g. the handling of default and unset values.

Tests: make check
No QA.

https://code.launchpad.net/~frankban/juju-quickstart/env-manage-models-meta/+merge/198756

(do not edit description out of merge proposal)

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

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

Revision history for this message
Madison Scott-Clary (makyo) wrote :

Super work, as always. LGTM, check okay.

https://codereview.appspot.com/41350043/

Revision history for this message
Benji York (benji) wrote :
Download full text (4.9 KiB)

This branch looks good. None of my comments are blockers, more like
"things you might like to consider", so this LGTM.

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

https://codereview.appspot.com/41350043/diff/1/quickstart/models/fields.py#newcode128
quickstart/models/fields.py:128: return super(StringField,
self).normalize(value or None)
If the given value is the empty string then None will be passed to
.normalize and he default will be returned. That doesn't sound right.

https://codereview.appspot.com/41350043/diff/1/quickstart/models/fields.py#newcode164
quickstart/models/fields.py:164: return self.default
So it is not possible to have an empty string as a field value? I've
seen systems with that policy before and it come back to bite them in
the long run. However, it looks like we're already there so I'm not
sure it is something we should address right this minute.

https://codereview.appspot.com/41350043/diff/1/quickstart/tests/models/test_envs.py
File quickstart/tests/models/test_envs.py (right):

https://codereview.appspot.com/41350043/diff/1/quickstart/tests/models/test_envs.py#newcode514
quickstart/tests/models/test_envs.py:514: # The local environment
metadata is correctly returned.
It would be nice to expand "correctly returned" to describe a little
more what that means.

https://codereview.appspot.com/41350043/diff/1/quickstart/tests/models/test_envs.py#newcode526
quickstart/tests/models/test_envs.py:526: # The ec2 environment metadata
is correctly returned.
Same comment for "correctly returned" as above.

https://codereview.appspot.com/41350043/diff/1/quickstart/tests/models/test_envs.py#newcode641
quickstart/tests/models/test_envs.py:641: self.assertEqual(expected,
obtained)
One of my personal preferences, for your consideration: I try to avoid
variable names in tests like "expected" and "obtained" and instead try
to communicate more about the values held. For example, above I might
call them "valid_pairs" and "mapped_pairs". That way the assertion says
more about the truth the test is trying to get at:
self.assertEqual(mapped_pairs, valid_pairs)

Note that making the asserts read this way generally means the
"obtained" value is the first argument and the "expected" argument is
second. Python's test infrastructure is fine with this, but we have to
make sure any test-specific assertion helpers are too.

https://codereview.appspot.com/41350043/diff/1/quickstart/tests/models/test_envs.py#newcode650
quickstart/tests/models/test_envs.py:650: self.assertEqual(help,
field.help)
I suspect you intended to pass field.name as the third argument to this
assert too.

https://codereview.appspot.com/41350043/diff/1/quickstart/tests/models/test_envs.py#newcode689
quickstart/tests/models/test_envs.py:689: self.assertEqual({},
envs.validate(self.env_metadata, env_data))
It took me a while to understand this test. Something like this might
have helped:

# To be valid, env_data must include the required values.
env_data = {'string-required': 'a string'}
validation_errors = envs.validate(self.env_metadata, env_data)
# No validation errors were found.
self.assertEqual(...

Read more...

46. By Francesco Banconi

Changing default values.

47. By Francesco Banconi

Bool field refactoring.

48. By Francesco Banconi

Introduce allow_mixed.

49. By Francesco Banconi

Fix tests.

50. By Francesco Banconi

Changes as per review.

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

Please take a look.

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

https://codereview.appspot.com/41350043/diff/1/quickstart/models/fields.py#newcode128
quickstart/models/fields.py:128: return super(StringField,
self).normalize(value or None)
On 2013/12/13 17:15:59, benji wrote:
> If the given value is the empty string then None will be passed to
.normalize
> and he default will be returned. That doesn't sound right.

Changed the module so that default is never returned by normalize: see
below.

https://codereview.appspot.com/41350043/diff/1/quickstart/models/fields.py#newcode164
quickstart/models/fields.py:164: return self.default
On 2013/12/13 17:15:59, benji wrote:
> So it is not possible to have an empty string as a field value? I've
seen
> systems with that policy before and it come back to bite them in the
long run.
> However, it looks like we're already there so I'm not sure it is
something we
> should address right this minute.

An IntField is always normalized to a number or None (unset). An empty
string is accepted as an input value, but what we really want is a
number or a "not set" value.

https://codereview.appspot.com/41350043/diff/1/quickstart/tests/models/test_envs.py
File quickstart/tests/models/test_envs.py (right):

https://codereview.appspot.com/41350043/diff/1/quickstart/tests/models/test_envs.py#newcode514
quickstart/tests/models/test_envs.py:514: # The local environment
metadata is correctly returned.
On 2013/12/13 17:15:59, benji wrote:
> It would be nice to expand "correctly returned" to describe a little
more what
> that means.

Done.

https://codereview.appspot.com/41350043/diff/1/quickstart/tests/models/test_envs.py#newcode526
quickstart/tests/models/test_envs.py:526: # The ec2 environment metadata
is correctly returned.
On 2013/12/13 17:15:59, benji wrote:
> Same comment for "correctly returned" as above.

Done.

https://codereview.appspot.com/41350043/diff/1/quickstart/tests/models/test_envs.py#newcode641
quickstart/tests/models/test_envs.py:641: self.assertEqual(expected,
obtained)
On 2013/12/13 17:15:59, benji wrote:
> One of my personal preferences, for your consideration: I try to avoid
variable
> names in tests like "expected" and "obtained" and instead try to
communicate
> more about the values held. For example, above I might call them
"valid_pairs"
> and "mapped_pairs". That way the assertion says more about the truth
the test
> is trying to get at: self.assertEqual(mapped_pairs, valid_pairs)

> Note that making the asserts read this way generally means the
"obtained" value
> is the first argument and the "expected" argument is second. Python's
test
> infrastructure is fine with this, but we have to make sure any
test-specific
> assertion helpers are too.

Very interesting. +1 on more meaningful assertions.

https://codereview.appspot.com/41350043/diff/1/quickstart/tests/models/test_envs.py#newcode650
quickstart/tests/models/test_envs.py:650: self.assertEqual(help,
field.help)
On 2013/12/13 17:15:59, benji wrote:
> I suspect you intended to pass field.name as the third argument to
this assert
> too.

Good catch, thanks.

https:/...

Read more...

Revision history for this message
Benji York (benji) wrote :
Download full text (6.2 KiB)

On 2013/12/16 12:12:04, frankban wrote:
> Please take a look.

Thanks for the good explanations and changes. Everything looks great.

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

https://codereview.appspot.com/41350043/diff/1/quickstart/models/fields.py#newcode128
> quickstart/models/fields.py:128: return super(StringField,
self).normalize(value
> or None)
> On 2013/12/13 17:15:59, benji wrote:
> > If the given value is the empty string then None will be passed to
.normalize
> > and he default will be returned. That doesn't sound right.

> Changed the module so that default is never returned by normalize: see
below.

https://codereview.appspot.com/41350043/diff/1/quickstart/models/fields.py#newcode164
> quickstart/models/fields.py:164: return self.default
> On 2013/12/13 17:15:59, benji wrote:
> > So it is not possible to have an empty string as a field value?
I've seen
> > systems with that policy before and it come back to bite them in the
long run.

> > However, it looks like we're already there so I'm not sure it is
something we
> > should address right this minute.

> An IntField is always normalized to a number or None (unset). An empty
string is
> accepted as an input value, but what we really want is a number or a
"not set"
> value.

Ah, gotcha.

https://codereview.appspot.com/41350043/diff/1/quickstart/tests/models/test_envs.py
> File quickstart/tests/models/test_envs.py (right):

https://codereview.appspot.com/41350043/diff/1/quickstart/tests/models/test_envs.py#newcode514
> quickstart/tests/models/test_envs.py:514: # The local environment
metadata is
> correctly returned.
> On 2013/12/13 17:15:59, benji wrote:
> > It would be nice to expand "correctly returned" to describe a little
more what
> > that means.

> Done.

https://codereview.appspot.com/41350043/diff/1/quickstart/tests/models/test_envs.py#newcode526
> quickstart/tests/models/test_envs.py:526: # The ec2 environment
metadata is
> correctly returned.
> On 2013/12/13 17:15:59, benji wrote:
> > Same comment for "correctly returned" as above.

> Done.

https://codereview.appspot.com/41350043/diff/1/quickstart/tests/models/test_envs.py#newcode641
> quickstart/tests/models/test_envs.py:641: self.assertEqual(expected,
obtained)
> On 2013/12/13 17:15:59, benji wrote:
> > One of my personal preferences, for your consideration: I try to
avoid
> variable
> > names in tests like "expected" and "obtained" and instead try to
communicate
> > more about the values held. For example, above I might call them
> "valid_pairs"
> > and "mapped_pairs". That way the assertion says more about the
truth the test
> > is trying to get at: self.assertEqual(mapped_pairs, valid_pairs)
> >
> > Note that making the asserts read this way generally means the
"obtained"
> value
> > is the first argument and the "expected" argument is second.
Python's test
> > infrastructure is fine with this, but we have to make sure any
test-specific
> > assertion helpers are too.

> Very interesting. +1 on more meaningful assertions.

https://codereview.appspot.com/41350043/diff/1/quickstart/tests/models/test_envs.py#newcode650
> quickst...

Read more...

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

*** Submitted:

Implement the missing model bits.

Thanks to Gary for his help in defining
this approach with me in several pre-imp
calls.

See the envs module docstring for a
description of the functions implemented
in this branch.

This branch also includes some
fixes/improvements to the fields module,
e.g. the handling of default and unset values.

Tests: make check
No QA.

R=matthew.scott, benji
CC=
https://codereview.appspot.com/41350043

https://codereview.appspot.com/41350043/

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

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-11 10:47:00 +0000
3+++ quickstart/models/envs.py 2013-12-16 12:11:28 +0000
4@@ -75,12 +75,6 @@
5 of the environment being modified. If None is passed, that means we are adding
6 a new environment.
7
8-XXX frankban 13-12-08: implement the missing functions:
9- - get_env_type_db()
10- - get_env_metadata(env_type_db, env_data)
11- - map_fields_to_env_data(env_metadata, env_data)
12- - validate(env_metadata, env_data)
13- - normalize(env_metadata, env_data)
14 XXX frankban 13-12-08: remove the parse_env_file function: this can be done
15 when activating models code in manage.py.
16 """
17@@ -96,8 +90,10 @@
18
19 from quickstart import (
20 serializers,
21+ settings,
22 utils,
23 )
24+from quickstart.models import fields
25
26
27 # Compile the regular expression used to parse the "juju switch" output.
28@@ -300,6 +296,219 @@
29 del env_db['default']
30
31
32+def get_env_type_db():
33+ """Return the environments meta information based on provider types.
34+
35+ The env_type_db describes Juju environments based on their provider type.
36+
37+ The returned data is a dictionary mapping provider type names to meta
38+ information. A provider named "__fallback__" is also included and can be
39+ used to minimally handle environments whose type is not currently supported
40+ by quickstart.
41+ """
42+ # Define a collection of fields shared by many environment types.
43+ provider_field = fields.StringField(
44+ 'type', label='provider type', required=True, readonly=True,
45+ help='the provider type enabled for this environment')
46+ name_field = fields.StringField(
47+ 'name', label='environment name', required=True,
48+ help='the environment name to use with Juju (arbitrary string)')
49+ admin_secret_field = fields.AutoGeneratedPasswordField(
50+ 'admin-secret', label='admin secret', required=True,
51+ help='the password used to authenticate to the environment')
52+ default_series_field = fields.ChoiceField(
53+ 'default-series', choices=settings.JUJU_DEFAULT_SERIES,
54+ label='default series', required=False,
55+ help='the default Ubuntu series to use for the bootstrap node')
56+ is_default_field = fields.BoolField(
57+ 'is-default', help='make this the default environment', required=True)
58+ # Define data structures used as part of the metadata below.
59+ ec2_regions = (
60+ 'ap-northeast-1', 'ap-southeast-1', 'ap-southeast-2',
61+ 'eu-west-1', 'sa-east-1',
62+ 'us-east-1', 'us-west-1', 'us-west-2',
63+ )
64+ # Define the env_type_db dictionary: this is done inside this function in
65+ # order to avoid instantiating fields at import time.
66+ env_type_db = {
67+ 'ec2': {
68+ 'description': (
69+ 'The ec2 provider enable you to run Juju on the EC2 cloud. '
70+ 'This process requires you to have an Amazon Web Services '
71+ '(AWS) account. If you have not signed up for one yet, it '
72+ 'can obtained at http://aws.amazon.com. '
73+ 'See https://juju.ubuntu.com/docs/config-aws.html for more '
74+ 'details on the ec2 provider configuration.'
75+ ),
76+ 'fields': (
77+ provider_field,
78+ name_field,
79+ admin_secret_field,
80+ default_series_field,
81+ fields.PasswordField(
82+ 'access-key', label='access key', required=True,
83+ help='the access key to use to authenticate to AWS'),
84+ fields.PasswordField(
85+ 'secret-key', label='secret key', required=True,
86+ help='the secret key to use to authenticate to AWS'),
87+ fields.AutoGeneratedStringField(
88+ 'control-bucket', label='control bucket', required=True,
89+ help='the globally unique S3 bucket name'),
90+ fields.ChoiceField(
91+ 'region', choices=ec2_regions, default='us-east-1',
92+ label='region', required=False,
93+ help='the ec2 region to use'),
94+ is_default_field,
95+ ),
96+ },
97+ 'local': {
98+ 'description': (
99+ 'The LXC local provider enables you to run Juju on a single '
100+ 'system like your local computer or a single server. '
101+ 'See https://juju.ubuntu.com/docs/config-LXC.html for more '
102+ 'details on the local provider configuration.'
103+ ),
104+ 'fields': (
105+ provider_field,
106+ name_field,
107+ admin_secret_field,
108+ default_series_field,
109+ fields.StringField(
110+ 'root-dir', label='root dir', required=False,
111+ default='~/.juju/local',
112+ help='the directory that is used for the storage files'),
113+ fields.IntField(
114+ 'storage-port', min_value=1, max_value=65535,
115+ label='storage port', required=False, default=8040,
116+ help='override if you have multiple local providers, '
117+ 'or if the default port is used by another program'),
118+ fields.IntField(
119+ 'shared-storage-port', min_value=1, max_value=65535,
120+ label='shared storage port', required=False, default=8041,
121+ help='override if you have multiple local providers, '
122+ 'or if the default port is used by another program'),
123+ fields.StringField(
124+ 'network-bridge', label='network bridge', required=False,
125+ default='lxcbr0', help='the LXC bridge interface to use'),
126+ is_default_field,
127+ ),
128+ },
129+ '__fallback__': {
130+ 'description': (
131+ 'This provider type is not supported by quickstart. '
132+ 'See https://juju.ubuntu.com/docs/getting-started.html for '
133+ 'a description of how to get started with Juju.'
134+ ),
135+ 'fields': (
136+ provider_field,
137+ name_field,
138+ admin_secret_field,
139+ default_series_field,
140+ is_default_field,
141+ ),
142+ }
143+ }
144+ return env_type_db
145+
146+
147+def get_supported_env_types(env_type_db):
148+ """Return a list of supported provider type names."""
149+ return [env_type for env_type in env_type_db if env_type != '__fallback__']
150+
151+
152+def get_env_metadata(env_type_db, env_data):
153+ """Return the meta information (fields, description) suitable for env_data.
154+
155+ Use the given env_type_db to retrieve the metadata corresponding to the
156+ environment type included in env_data.
157+
158+ The resulting metadata can be used to parse/validate environments or to
159+ enrich the user experience e.g. by providing additional information about
160+ the fields composing a specific environment type, or about the environment
161+ type itself.
162+ """
163+ env_type = env_data.get('type', '__fallback__')
164+ return env_type_db.get(env_type, env_type_db['__fallback__'])
165+
166+
167+def map_fields_to_env_data(env_metadata, env_data):
168+ """Return a list of (field, value) tuples.
169+
170+ Each tuple stores a field (instance of fields.Field or its subclasses)
171+ describing a specific environment key in env_data, and the corresponding
172+ value in env_data.
173+
174+ If extraneous keys (not described by env_metadata) are found in env_data,
175+ then those keys are assigned a generic default field (fields.Field).
176+ """
177+ data = copy.deepcopy(env_data)
178+ # Start building a list of (field, value) pairs preserving fields order.
179+ field_value_pairs = [
180+ (field, data.pop(field.name, None))
181+ for field in env_metadata['fields']
182+ ]
183+ # Add the remaining (unexpected) pairs using the base field type.
184+ help = 'this field is unrecognized and can be safely removed'
185+ field_value_pairs.extend(
186+ (fields.Field(key, help=help, required=False), value)
187+ for key, value in data.items()
188+ )
189+ return field_value_pairs
190+
191+
192+def validate(env_metadata, env_data):
193+ """Validate values in env_data.
194+
195+ Return a dictionary of errors mapping field names to error messages.
196+ If the environment is valid, return an empty dictionary.
197+ """
198+ errors = {}
199+ for field, value in map_fields_to_env_data(env_metadata, env_data):
200+ try:
201+ field.validate(value)
202+ except ValueError as err:
203+ errors[field.name] = bytes(err).decode('utf-8')
204+ return errors
205+
206+
207+def normalize(env_metadata, env_data):
208+ """Normalize the values in env_data.
209+
210+ Return a new env_data containing the normalized values.
211+ This function assumes env_data contains valid values.
212+ """
213+ normalized_env_data = {}
214+ for field, value in map_fields_to_env_data(env_metadata, env_data):
215+ value = field.normalize(value)
216+ # Only include the key/value pair if the corresponding field is
217+ # required or the value is set.
218+ if field.required or value is not None:
219+ normalized_env_data[field.name] = value
220+ return normalized_env_data
221+
222+
223+def get_env_short_description(env_data):
224+ """Return a short description of the given env_data.
225+
226+ The given env_data must include at least the "name" and "is-default" keys.
227+
228+ The description is like the following:
229+ "aws (type: ec2, default)" # Default ec2 environment.
230+ "lxc (type: local)" # Non-default local environment.
231+ "unknown" # An environment with no type set.
232+ """
233+ info_parts = []
234+ env_type = env_data.get('type')
235+ if env_type is not None:
236+ info_parts.append('type: {}'.format(env_type))
237+ if env_data['is-default']:
238+ info_parts.append('default')
239+ info = ''
240+ if info_parts:
241+ info = ' ({})'.format(', '.join(info_parts))
242+ return env_data['name'] + info
243+
244+
245 def parse_env_file(env_file, env_name):
246 """Parse the provided Juju environments.yaml file.
247
248
249=== modified file 'quickstart/models/fields.py'
250--- quickstart/models/fields.py 2013-12-11 17:55:31 +0000
251+++ quickstart/models/fields.py 2013-12-16 12:11:28 +0000
252@@ -26,8 +26,6 @@
253 validate the whole environment on a per-field basis.
254
255 See quickstart.models.envs.get_env_type_db for an example of how this works.
256-XXX frankban 13-12-11:
257- the function above will be implemented in the next branch.
258 """
259
260 from __future__ import unicode_literals
261@@ -57,6 +55,9 @@
262 (e.g. "Admin Secret");
263 - help: help text associated with this field
264 (e.g. "the password you use for authenticating");
265+ - default: the default value if the value is not set (None). This is
266+ used only in the validation process, and can be used by view code
267+ to display the default value for a field;
268 - required: True if this is a required field, False otherwise;
269 - readonly: True if the associated value must be considered immutable.
270
271@@ -64,7 +65,8 @@
272 - display(value): how the value should be displayed by views
273 (usually just the value itself as a unicode string is returned);
274 - normalize(value): return the normalized value, e.g. a string field
275- might return a stripped version of the input value;
276+ might return a stripped version of the input value. Returning None
277+ means the value for that field is unset;
278 - validate(value): validate the given value, raising a ValueError if
279 the input value is not valid, returning None otherwise;
280 - generate(): this optional method indicates the value associated with
281@@ -79,11 +81,13 @@
282 field_type = None
283
284 def __init__(
285- self, name, label=None, help='', required=False, readonly=False):
286+ self, name, label=None, help='', default=None, required=False,
287+ readonly=False):
288 """Initialize a field. Only the name identifier is required."""
289 self.name = name
290 self.label = name if label is None else label
291 self.help = help
292+ self.default = default
293 self.required = required
294 self.readonly = readonly
295
296@@ -105,10 +109,10 @@
297 def validate(self, value):
298 """Validate the given value.
299
300- Return a possibly normalized version of the value.
301- Raise a ValueError if the given value is not valid.
302+ Raise a ValueError if the given value is required, it is not set and
303+ no default is provided.
304 """
305- if self.required and not value:
306+ if self.required and (value is None) and (self.default is None):
307 msg = 'a value is required for the {} field'.format(self.label)
308 raise ValueError(msg.encode('utf-8'))
309
310@@ -117,10 +121,10 @@
311 """Values associated with this field must be strings."""
312
313 def normalize(self, value):
314- """Strip the string."""
315+ """Strip the string. Return None if the value is not set."""
316 if value is None:
317- return ''
318- return value.strip()
319+ return None
320+ return value.strip() or None
321
322 def validate(self, value):
323 """Check that the value is a string."""
324@@ -128,9 +132,7 @@
325 # Assume view code always works with unicode strings.
326 msg = 'the {} field requires a string value'.format(self.label)
327 raise ValueError(msg.encode('utf-8'))
328- value = self.normalize(value)
329- # The parent field ensures the value is set if required.
330- super(StringField, self).validate(value)
331+ super(StringField, self).validate(self.normalize(value))
332
333
334 class IntField(Field):
335@@ -178,11 +180,9 @@
336 except (TypeError, ValueError):
337 raise ValueError(msg.encode('utf-8'))
338 # Ensure the value is set if required.
339+ super(IntField, self).validate(value)
340 if value is None:
341- if self.required:
342- msg = 'a value is required for the {} field'.format(self.label)
343- raise ValueError(msg.encode('utf-8'))
344- return value
345+ return
346 # Ensure the value is in the given range.
347 min_value = self.min_value
348 max_value = self.max_value
349@@ -206,41 +206,23 @@
350
351 field_type = 'bool'
352
353- def __init__(self, name, default=False, **kwargs):
354- """Initialize the boolean field.
355+ def __init__(self, name, allow_mixed=True, **kwargs):
356+ """Add the allow_mixed keyword argument.
357
358- It is possible to pass a "default" keyword argument in order to specify
359- the default value (True or False) to use if the value is unset (None).
360+ By default allow_mixed is True, and that means the field can be in a
361+ "not set" state (None). This is relevant for validation and view code.
362 """
363 super(BoolField, self).__init__(name, **kwargs)
364- self.default = default
365-
366- def normalize(self, value):
367- """Return the default value if the given one is None (unset)."""
368- if value is None:
369- self.validate(self.default)
370- value = self.default
371- return value
372+ self.allow_mixed = allow_mixed
373
374 def validate(self, value):
375- """Check that the value is a boolean."""
376- value = self.normalize(value)
377- if not isinstance(value, bool):
378+ """Check that the value, if set, is a boolean."""
379+ types = (bool, type(None)) if self.allow_mixed else (bool,)
380+ if not isinstance(value, types):
381 msg = 'the {} field requires a boolean value'.format(self.label)
382 raise ValueError(msg.encode('utf-8'))
383
384
385-class AutoGeneratedStringField(StringField):
386- """Can automatically generate string values if they are not provided.
387-
388- Subclasses can override the generate method to return customized values.
389- """
390-
391- def generate(self):
392- """Generate a uuid valid value."""
393- return '{}-{}'.format(self.name[:3], uuid.uuid4().hex)
394-
395-
396 class ChoiceField(StringField):
397 """A string field whose value must be included in the given choices."""
398
399@@ -258,8 +240,11 @@
400 super(ChoiceField, self).validate(value)
401 value = self.normalize(value)
402 choices = list(self.choices)
403- if not self.required:
404- choices.append('')
405+ # If the field is not required, or if a default value is set, None is
406+ # added to the list of valid choices, so that a "not set" value is
407+ # accepted.
408+ if (not self.required) or (self.default is not None):
409+ choices.append(None)
410 if value not in choices:
411 msg = 'the {} requires the value to be one of the following: {}'
412 raise ValueError(
413@@ -267,6 +252,17 @@
414 )
415
416
417+class AutoGeneratedStringField(StringField):
418+ """Can automatically generate string values if they are not provided.
419+
420+ Subclasses can override the generate method to return customized values.
421+ """
422+
423+ def generate(self):
424+ """Generate a uuid valid value."""
425+ return '{}-{}'.format(self.name[:3], uuid.uuid4().hex)
426+
427+
428 class PasswordField(StringField):
429 """Assume values associated with this field represent sensible data."""
430
431
432=== modified file 'quickstart/tests/models/test_envs.py'
433--- quickstart/tests/models/test_envs.py 2013-12-10 14:37:58 +0000
434+++ quickstart/tests/models/test_envs.py 2013-12-16 12:11:28 +0000
435@@ -18,14 +18,19 @@
436
437 from __future__ import unicode_literals
438
439+import collections
440 import copy
441+import functools
442 import os
443 import unittest
444
445 import mock
446 import yaml
447
448-from quickstart.models import envs
449+from quickstart.models import (
450+ envs,
451+ fields,
452+)
453 from quickstart.tests import helpers
454
455
456@@ -464,6 +469,385 @@
457 self.assertNotIn('default', self.env_db)
458
459
460+class TestGetEnvTypeDb(unittest.TestCase):
461+
462+ def setUp(self):
463+ self.env_type_db = envs.get_env_type_db()
464+
465+ def assert_fields(self, expected, env_metadata):
466+ """Ensure the expected fields are defined as part of env_metadata."""
467+ obtained = [field.name for field in env_metadata['fields']]
468+ self.assertEqual(expected, obtained)
469+
470+ def assert_required_fields(self, expected, env_metadata):
471+ """Ensure the expected required fields are included in env_metadata."""
472+ obtained = [
473+ field.name for field in env_metadata['fields'] if field.required
474+ ]
475+ self.assertEqual(expected, obtained)
476+
477+ def test_required_metadata(self):
478+ # The returned data includes the required env_metadata for each
479+ # provider type key.
480+ self.assertNotEqual(0, len(self.env_type_db))
481+ for provider_type, env_metadata in self.env_type_db.items():
482+ # Check the description metadata.
483+ self.assertIn('description', env_metadata, provider_type)
484+ self.assertIsInstance(
485+ env_metadata['description'], unicode, provider_type)
486+ # Check the fields metadata.
487+ self.assertIn('fields', env_metadata)
488+ self.assertIsInstance(
489+ env_metadata['fields'], collections.Iterable, provider_type)
490+
491+ def test_fallback(self):
492+ # A fallback provider type is included.
493+ self.assertIn('__fallback__', self.env_type_db)
494+ env_metadata = self.env_type_db['__fallback__']
495+ expected = [
496+ 'type', 'name', 'admin-secret', 'default-series', 'is-default']
497+ expected_required = ['type', 'name', 'admin-secret', 'is-default']
498+ self.assert_fields(expected, env_metadata)
499+ self.assert_required_fields(expected_required, env_metadata)
500+
501+ def test_local_environment(self):
502+ # The local environment metadata includes the expected fields.
503+ self.assertIn('local', self.env_type_db)
504+ env_metadata = self.env_type_db['local']
505+ expected = [
506+ 'type', 'name', 'admin-secret', 'default-series', 'root-dir',
507+ 'storage-port', 'shared-storage-port', 'network-bridge',
508+ 'is-default']
509+ expected_required = ['type', 'name', 'admin-secret', 'is-default']
510+ self.assert_fields(expected, env_metadata)
511+ self.assert_required_fields(expected_required, env_metadata)
512+
513+ def test_ec2_environment(self):
514+ # The ec2 environment metadata includes the expected fields.
515+ self.assertIn('ec2', self.env_type_db)
516+ env_metadata = self.env_type_db['ec2']
517+ expected = [
518+ 'type', 'name', 'admin-secret', 'default-series', 'access-key',
519+ 'secret-key', 'control-bucket', 'region', 'is-default']
520+ expected_required = [
521+ 'type', 'name', 'admin-secret', 'access-key', 'secret-key',
522+ 'control-bucket', 'is-default']
523+ self.assert_fields(expected, env_metadata)
524+ self.assert_required_fields(expected_required, env_metadata)
525+
526+
527+class TestGetSupportedEnvTypes(unittest.TestCase):
528+
529+ def test_env_types(self):
530+ # All the supported env_types but the fallback one are returned.
531+ env_type_db = envs.get_env_type_db()
532+ expected = set(env_type_db)
533+ expected.remove('__fallback__')
534+ supported_env_types = envs.get_supported_env_types(env_type_db)
535+ obtained = set(supported_env_types)
536+ self.assertEqual(len(obtained), len(supported_env_types))
537+ self.assertEqual(expected, obtained)
538+
539+
540+class TestGetEnvMetadata(unittest.TestCase):
541+
542+ def setUp(self):
543+ self.env_type_db = envs.get_env_type_db()
544+
545+ def test_supported_environment(self):
546+ # The metadata for a supported environment is properly returned.
547+ env_data = {'type': 'local'}
548+ env_metadata = envs.get_env_metadata(self.env_type_db, env_data)
549+ self.assertEqual(self.env_type_db['local'], env_metadata)
550+
551+ def test_unsupported_environment(self):
552+ # The metadata for an unsupported environment is properly returned.
553+ env_data = {'type': 'no-such'}
554+ env_metadata = envs.get_env_metadata(self.env_type_db, env_data)
555+ self.assertEqual(self.env_type_db['__fallback__'], env_metadata)
556+
557+ def test_without_type(self):
558+ # The fallback metadata is also used when the env_data does not include
559+ # the provider type.
560+ env_metadata = envs.get_env_metadata(self.env_type_db, {})
561+ self.assertEqual(self.env_type_db['__fallback__'], env_metadata)
562+
563+
564+class TestMapFieldsToEnvData(unittest.TestCase):
565+
566+ def setUp(self):
567+ env_type_db = envs.get_env_type_db()
568+ self.get_meta = functools.partial(envs.get_env_metadata, env_type_db)
569+
570+ def assert_name_value_pairs(self, expected, env_data):
571+ """Ensure the expected field name/value pairs are included in env_data.
572+ """
573+ pairs = envs.map_fields_to_env_data(self.get_meta(env_data), env_data)
574+ obtained = [(field.name, value) for field, value in pairs]
575+ self.assertEqual(expected, obtained)
576+
577+ def make_valid_pairs(self):
578+ """Create and return a list of valid (field name, value) pairs."""
579+ return [
580+ ('type', 'local'),
581+ ('name', 'lxc'),
582+ ('admin-secret', 'Secret!'),
583+ ('default-series', 'saucy'),
584+ ('root-dir', '/my/juju/local/'),
585+ ('storage-port', 4242),
586+ ('shared-storage-port', 4747),
587+ ('network-bridge', 'lxcbr1'),
588+ ('is-default', True),
589+ ]
590+
591+ def test_valid_env_data(self):
592+ # The field/value pairs are correctly returned.
593+ expected = self.make_valid_pairs()
594+ env_data = dict(expected)
595+ self.assert_name_value_pairs(expected, env_data)
596+
597+ def test_missing_pairs(self):
598+ # None values are returned if a defined field is missing in env_data.
599+ expected = [
600+ ('type', 'local'),
601+ ('name', 'lxc'),
602+ ('admin-secret', None),
603+ ('default-series', None),
604+ ('root-dir', None),
605+ ('storage-port', None),
606+ ('shared-storage-port', None),
607+ ('network-bridge', None),
608+ ('is-default', None),
609+ ]
610+ env_data = {'type': 'local', 'name': 'lxc'}
611+ self.assert_name_value_pairs(expected, env_data)
612+
613+ def test_unexpected_pairs(self):
614+ # Additional unexpected field/value pairs are returned as well.
615+ expected_pairs = self.make_valid_pairs()
616+ unexpected_pairs = [
617+ ('registry', 'USS Enterprise (NCC-1701-D)'),
618+ ('class', 'Galaxy'),
619+ ('years-of-service', 8),
620+ ('crashed', True),
621+ ('cloaking-device', None),
622+ ]
623+ env_data = dict(expected_pairs + unexpected_pairs)
624+ pairs = envs.map_fields_to_env_data(self.get_meta(env_data), env_data)
625+ # The expected fields are correctly returned.
626+ mapped_pairs = [
627+ (field.name, value) for field, value in pairs[:len(expected_pairs)]
628+ ]
629+ self.assertEqual(expected_pairs, mapped_pairs)
630+ # Pairs also include the unexpected fields.
631+ unexpected_dict = dict(unexpected_pairs)
632+ remaining_pairs = pairs[len(expected_pairs):]
633+ self.assertEqual(len(unexpected_dict), len(remaining_pairs))
634+ help = 'this field is unrecognized and can be safely removed'
635+ for field, value in remaining_pairs:
636+ self.assertEqual(unexpected_dict[field.name], value, field.name)
637+ self.assertFalse(field.required, field.name)
638+ self.assertEqual(help, field.help, field.name)
639+
640+
641+class ValidateNormalizeTestsMixin(object):
642+ """Shared utilities for tests exercising "validate" and "normalize"."""
643+
644+ def setUp(self):
645+ # Set up metadata to work with.
646+ choices = ('trick', 'treat')
647+ self.env_metadata = {
648+ 'fields': (
649+ fields.StringField('string-required', required=True),
650+ fields.StringField('string-default', default='boo!'),
651+ fields.IntField('int-optional'),
652+ fields.IntField('int-range', min_value=42, max_value=47),
653+ fields.BoolField('bool-true', default=True),
654+ fields.ChoiceField('choice-optional', choices=choices)
655+ )
656+ }
657+ super(ValidateNormalizeTestsMixin, self).setUp()
658+
659+
660+class TestValidate(ValidateNormalizeTestsMixin, unittest.TestCase):
661+
662+ def test_valid(self):
663+ # An empty errors dict is returned if the env_data is valid.
664+ env_data = {
665+ 'string-required': 'a string',
666+ 'string-default': 'another string',
667+ 'int-optional': -42,
668+ 'int-range': 42,
669+ 'bool-true': False,
670+ 'choice-optional': 'treat',
671+ }
672+ self.assertEqual({}, envs.validate(self.env_metadata, env_data))
673+
674+ def test_valid_only_required(self):
675+ # To be valid, env_data must at least include the required values.
676+ env_data = {'string-required': 'a string'}
677+ validation_errors = envs.validate(self.env_metadata, env_data)
678+ # No validation errors were found.
679+ self.assertEqual(validation_errors, {})
680+
681+ def test_not_valid(self):
682+ # An errors dict is returned if the env_data is not valid.
683+ env_data = {
684+ 'string-required': ' ',
685+ 'string-default': 42,
686+ 'int-optional': 'not-an-int',
687+ 'int-range': 1000,
688+ 'bool-true': [],
689+ 'choice-optional': 'toy',
690+ }
691+ expected = {
692+ 'string-required': (
693+ 'a value is required for the string-required field'),
694+ 'string-default': (
695+ 'the string-default field requires a string value'),
696+ 'int-optional': 'the int-optional field requires an integer value',
697+ 'int-range': 'the int-range value must be in the 42-47 range',
698+ 'bool-true': 'the bool-true field requires a boolean value',
699+ 'choice-optional': ('the choice-optional requires the value to be '
700+ 'one of the following: trick, treat'),
701+ }
702+ self.assertEqual(expected, envs.validate(self.env_metadata, env_data))
703+
704+ def test_required_field_not_found(self):
705+ # An error is returned if required fields are not included in env_data.
706+ expected = {
707+ 'string-required': (
708+ 'a value is required for the string-required field'),
709+ }
710+ self.assertEqual(expected, envs.validate(self.env_metadata, {}))
711+
712+ def test_optional_invalid_field(self):
713+ # Even if there is just one invalid field, and even if that field is
714+ # optional, the error is still reported in the errors dict.
715+ env_data = {
716+ 'string-required': 'a string',
717+ 'int-optional': False,
718+ }
719+ expected = {
720+ 'int-optional': 'the int-optional field requires an integer value',
721+ }
722+ self.assertEqual(expected, envs.validate(self.env_metadata, env_data))
723+
724+
725+class TestNormalize(ValidateNormalizeTestsMixin, unittest.TestCase):
726+
727+ def test_normalized_data(self):
728+ # The given env_data is properly normalized.
729+ env_data = {
730+ 'string-required': ' a string\n',
731+ 'string-default': '\t another one',
732+ 'int-optional': '-42',
733+ 'int-range': 42.2,
734+ 'bool-true': False,
735+ 'choice-optional': ' trick ',
736+ }
737+ expected = {
738+ 'string-required': 'a string',
739+ 'string-default': 'another one',
740+ 'int-optional': -42,
741+ 'int-range': 42,
742+ 'bool-true': False,
743+ 'choice-optional': 'trick',
744+ }
745+ self.assertEqual(expected, envs.normalize(self.env_metadata, env_data))
746+
747+ def test_already_normalized(self):
748+ # The normalization process produces the same env_data if the input
749+ # data is already normalized.
750+ env_data = {
751+ 'string-required': 'a string',
752+ 'int-optional': 42,
753+ }
754+ normalized_data = envs.normalize(self.env_metadata, env_data)
755+ # The same data is returned.
756+ self.assertEqual(env_data, normalized_data)
757+ # However, the returned data is a different object.
758+ self.assertIsNot(env_data, normalized_data)
759+
760+ def test_multiline_values_preserved(self):
761+ # The normalization process preserves multi-line values.
762+ env_data = {'string-required': 'first line\nsecond line'}
763+ normalized_data = envs.normalize(self.env_metadata, env_data)
764+ self.assertEqual(env_data, normalized_data)
765+
766+ def test_exclude_fields(self):
767+ # The normalization process excludes fields if they are not required
768+ # and the corresponding values are not set or not changed.
769+ env_data = {
770+ # Since this field is required, it is included even if not set.
771+ 'string-required': '',
772+ # Even if this value is the default one, it is included because
773+ # it is explicitly set by the user.
774+ 'string-default': 'boo!',
775+ # Since this field has a value, it is included even if optional.
776+ 'int-optional': 42,
777+ # Since the value is unset and the field optional, it is excluded.
778+ 'int-range': None,
779+ # False is a valid set value for boolean fields. For this reason,
780+ # it is included.
781+ 'bool-true': False,
782+ # The choice optional field is not in the input data. For this
783+ # reason the field is excluded.
784+ }
785+ expected = {
786+ 'string-required': None,
787+ 'string-default': 'boo!',
788+ 'int-optional': 42,
789+ 'bool-true': False,
790+ }
791+ normalized_data = envs.normalize(self.env_metadata, env_data)
792+ self.assertEqual(expected, normalized_data)
793+
794+ def test_original_not_mutated(self):
795+ # The original env_data is not modified in the process.
796+ env_data = {
797+ 'string-required': ' a string\n',
798+ 'string-default': None,
799+ 'bool-true': None,
800+ 'choice-optional': ' trick ',
801+ }
802+ original = env_data.copy()
803+ expected = {
804+ 'string-required': 'a string',
805+ 'choice-optional': 'trick',
806+ }
807+ normalized_data = envs.normalize(self.env_metadata, env_data)
808+ self.assertEqual(expected, normalized_data)
809+ self.assertEqual(original, env_data)
810+
811+
812+class TestGetEnvShortDescription(unittest.TestCase):
813+
814+ def test_env(self):
815+ # The env description includes the environment name and type.
816+ env_data = {'name': 'lxc', 'type': 'local', 'is-default': False}
817+ description = envs.get_env_short_description(env_data)
818+ self.assertEqual('lxc (type: local)', description)
819+
820+ def test_default_env(self):
821+ # A default environment is properly described.
822+ env_data = {'name': 'lxc', 'type': 'local', 'is-default': True}
823+ description = envs.get_env_short_description(env_data)
824+ self.assertEqual('lxc (type: local, default)', description)
825+
826+ def test_env_without_type(self):
827+ # Without the type we can only show the environment name.
828+ env_data = {'name': 'lxc', 'is-default': False}
829+ description = envs.get_env_short_description(env_data)
830+ self.assertEqual('lxc', description)
831+
832+ def test_default_env_without_type(self):
833+ # This would be embarrassing.
834+ env_data = {'name': 'lxc', 'type': None, 'is-default': True}
835+ description = envs.get_env_short_description(env_data)
836+ self.assertEqual('lxc (default)', description)
837+
838+
839 class TestParseEnvFile(
840 helpers.EnvFileTestsMixin, helpers.ValueErrorTestsMixin,
841 unittest.TestCase):
842
843=== modified file 'quickstart/tests/models/test_fields.py'
844--- quickstart/tests/models/test_fields.py 2013-12-11 17:45:06 +0000
845+++ quickstart/tests/models/test_fields.py 2013-12-16 12:11:28 +0000
846@@ -46,10 +46,11 @@
847 # The field attributes are properly stored in the field instance.
848 field = self.field_class(
849 'first-name', label='first name', help='your first name',
850- required=True, readonly=False)
851+ default='default', required=True, readonly=False)
852 self.assertEqual('first-name', field.name)
853 self.assertEqual('first name', field.label)
854 self.assertEqual('your first name', field.help)
855+ self.assertEqual('default', field.default)
856 self.assertTrue(field.required)
857 self.assertFalse(field.readonly)
858
859@@ -59,6 +60,7 @@
860 self.assertEqual('last-name', field.name)
861 self.assertEqual('last-name', field.label)
862 self.assertEqual('', field.help)
863+ self.assertIsNone(field.default)
864 self.assertFalse(field.required)
865 self.assertFalse(field.readonly)
866
867@@ -81,7 +83,7 @@
868 field_class = fields.Field
869
870 def test_normalization(self):
871- # The base field normalization is a no-op.
872+ # The base field normalization is a no-op if the value is set.
873 field = self.field_class('email')
874 for value in (None, 42, True, 'a unicode string'):
875 self.assertEqual(value, field.normalize(value), value)
876@@ -93,7 +95,7 @@
877 self.assertIsNone(field.validate(value), value)
878
879 def test_validation_not_required(self):
880- # If the field is not required no errors are raised.
881+ # If the field is not required, no errors are raised.
882 field = self.field_class('email', required=False)
883 for value in ('', False, None):
884 with self.assert_not_raises(ValueError, value):
885@@ -103,9 +105,15 @@
886 # A ValueError is raised by required fields if the value is not set.
887 field = self.field_class('email', label='email address', required=True)
888 expected = 'a value is required for the email address field'
889- for value in ('', False, None):
890- with self.assert_value_error(expected):
891- field.validate(value)
892+ with self.assert_value_error(expected):
893+ field.validate(None)
894+
895+ def test_validation_with_default(self):
896+ # The validation succeeds if the value is unset but a default one is
897+ # available.
898+ field = self.field_class('answer', default=42, required=True)
899+ with self.assert_not_raises(ValueError):
900+ field.validate(None)
901
902
903 class TestStringField(
904@@ -116,13 +124,14 @@
905 def test_normalization(self):
906 # The string field normalization returns the stripped string value.
907 field = self.field_class('email')
908- for value in ('a value', '\t tabs and spaces ', ' ', 'newlines\n\n'):
909+ for value in ('a value', '\t tabs and spaces ', 'newlines\n\n'):
910 self.assertEqual(value.strip(), field.normalize(value), value)
911
912 def test_none_normalization(self):
913- # The string field normalization turns None values into empty strings.
914+ # The string field normalization returns None if the value is not set.
915 field = self.field_class('email')
916- self.assertEqual('', field.normalize(None))
917+ for value in ('', ' ', '\n', ' \t ', None):
918+ self.assertIsNone(field.normalize(value), value)
919
920 def test_validation_success(self):
921 # The validation succeeds if the value is set.
922@@ -131,7 +140,7 @@
923 self.assertIsNone(field.validate(value), value)
924
925 def test_validation_not_required(self):
926- # If the field is not required no errors are raised.
927+ # If the field is not required, no errors are raised.
928 field = self.field_class('email', required=False)
929 for value in ('', None, ' ', '\t\n'):
930 with self.assert_not_raises(ValueError, value):
931@@ -153,6 +162,14 @@
932 with self.assert_value_error(expected):
933 field.validate(value)
934
935+ def test_validation_with_default(self):
936+ # The validation succeeds if the value is unset but a default one is
937+ # available.
938+ field = self.field_class(
939+ 'email', default='email@example.com', required=True)
940+ with self.assert_not_raises(ValueError):
941+ field.validate(None)
942+
943
944 class TestIntField(
945 FieldTestsMixin, helpers.ValueErrorTestsMixin, unittest.TestCase):
946@@ -171,6 +188,11 @@
947 for value in (None, '', ' ', '\t\n'):
948 self.assertIsNone(field.normalize(value), value)
949
950+ def test_zero_normalization(self):
951+ # The zero value is not considered unset.
952+ field = self.field_class('tcp-port')
953+ self.assertEqual(0, field.normalize(0))
954+
955 def test_validation_success(self):
956 # The value as an integer number is returned if the value is valid.
957 field = self.field_class('tcp-port')
958@@ -193,7 +215,7 @@
959 field.validate(value)
960
961 def test_validation_not_required(self):
962- # If the field is not required no errors are raised.
963+ # If the field is not required, no errors are raised.
964 field = self.field_class('tcp-port', required=False)
965 for value in ('', None, ' ', '\t\n'):
966 with self.assert_not_raises(ValueError, value):
967@@ -235,12 +257,29 @@
968 with self.assert_value_error(expected):
969 field.validate(27)
970
971+ def test_validation_with_default(self):
972+ # The validation succeeds if the value is unset but a default one is
973+ # available.
974+ field = self.field_class('tcp-port', default=8888, required=True)
975+ with self.assert_not_raises(ValueError):
976+ field.validate(None)
977+
978
979 class TestBoolField(
980 FieldTestsMixin, helpers.ValueErrorTestsMixin, unittest.TestCase):
981
982 field_class = fields.BoolField
983
984+ def test_default_attributes(self):
985+ # Only the name identifier is required when instantiating a field.
986+ field = self.field_class('is-public')
987+ self.assertEqual('is-public', field.name)
988+ self.assertEqual('is-public', field.label)
989+ self.assertEqual('', field.help)
990+ self.assertFalse(field.default)
991+ self.assertFalse(field.required)
992+ self.assertFalse(field.readonly)
993+
994 def test_normalization(self):
995 # The bool field normalization returns the value itself.
996 field = self.field_class('is-public')
997@@ -248,11 +287,9 @@
998 self.assertFalse(field.normalize(False))
999
1000 def test_none_normalization(self):
1001- # The string field normalization turns None values into the default.
1002- field_true = self.field_class('is-public', default=True)
1003- field_false = self.field_class('is-private', default=False)
1004- self.assertTrue(field_true.normalize(None))
1005- self.assertFalse(field_false.normalize(None))
1006+ # The string field normalization returns None if the value is not set.
1007+ field = self.field_class('is-public')
1008+ self.assertIsNone(field.normalize(None))
1009
1010 def test_validation_success(self):
1011 # The validation succeeds if the value is boolean.
1012@@ -269,6 +306,34 @@
1013 with self.assert_value_error(expected):
1014 field.validate(value)
1015
1016+ def test_validation_not_required(self):
1017+ # If the field is not required, no errors are raised.
1018+ field = self.field_class('is-public', required=False)
1019+ with self.assert_not_raises(ValueError):
1020+ field.validate(None)
1021+
1022+ def test_validation_error_required(self):
1023+ # If the value is not set, the default value will be used.
1024+ field = self.field_class(
1025+ 'is-public', label='is public', default=False, required=True)
1026+ with self.assert_not_raises(ValueError):
1027+ field.validate(None)
1028+
1029+ def test_validation_allow_mixed(self):
1030+ # The validation succeed with a None value if the field allows mixed
1031+ # state: True/False or None (unset).
1032+ field = self.field_class('is-public', allow_mixed=True)
1033+ with self.assert_not_raises(ValueError):
1034+ field.validate(None)
1035+
1036+ def test_validation_no_mixed_state(self):
1037+ # The boolean field cannot be unset if mixed state is not allowed.
1038+ field = self.field_class(
1039+ 'is-public', label='is public', allow_mixed=False)
1040+ expected = 'the is public field requires a boolean value'
1041+ with self.assert_value_error(expected):
1042+ field.validate(None)
1043+
1044
1045 class TestAutoGeneratedStringField(TestStringField):
1046
1047@@ -311,6 +376,14 @@
1048 with self.assert_value_error(expected):
1049 field.validate('resistance is futile')
1050
1051+ def test_validation_with_default(self):
1052+ # The validation succeeds if the value is unset but a default one is
1053+ # available.
1054+ field = self.field_class(
1055+ 'word', choices=self.choices, default='voyages', required=True)
1056+ with self.assert_not_raises(ValueError):
1057+ field.validate(None)
1058+
1059
1060 class TestPasswordField(TestStringField):
1061

Subscribers

People subscribed via source and target branches