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

Subscribers

People subscribed via source and target branches