Merge lp:~frankban/juju-quickstart/env-manage-models-meta into lp:juju-quickstart
- env-manage-models-meta
- Merge into trunk
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 |
Related bugs: |
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
Juju GUI Hackers | Pending | ||
Review via email: mp+198756@code.launchpad.net |
Commit message
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.
Francesco Banconi (frankban) wrote : | # |
Madison Scott-Clary (makyo) wrote : | # |
Super work, as always. LGTM, check okay.
Benji York (benji) wrote : | # |
This branch looks good. None of my comments are blockers, more like
"things you might like to consider", so this LGTM.
https:/
File quickstart/
https:/
quickstart/
self).normalize
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:/
quickstart/
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:/
File quickstart/
https:/
quickstart/
metadata is correctly returned.
It would be nice to expand "correctly returned" to describe a little
more what that means.
https:/
quickstart/
is correctly returned.
Same comment for "correctly returned" as above.
https:/
quickstart/
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.assertEqua
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:/
quickstart/
field.help)
I suspect you intended to pass field.name as the third argument to this
assert too.
https:/
quickstart/
envs.validate(
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(
# No validation errors were found.
self.assertEqua
- 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.
Francesco Banconi (frankban) wrote : | # |
Please take a look.
https:/
File quickstart/
https:/
quickstart/
self).normalize
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:/
quickstart/
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:/
File quickstart/
https:/
quickstart/
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:/
quickstart/
is correctly returned.
On 2013/12/13 17:15:59, benji wrote:
> Same comment for "correctly returned" as above.
Done.
https:/
quickstart/
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.assertEqua
> 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:/
quickstart/
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:/...
Benji York (benji) wrote : | # |
On 2013/12/16 12:12:04, frankban wrote:
> Please take a look.
Thanks for the good explanations and changes. Everything looks great.
https:/
> File quickstart/
https:/
> quickstart/
self).normalize
> 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:/
> quickstart/
> 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:/
> File quickstart/
https:/
> quickstart/
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:/
> quickstart/
metadata is
> correctly returned.
> On 2013/12/13 17:15:59, benji wrote:
> > Same comment for "correctly returned" as above.
> Done.
https:/
> quickstart/
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.assertEqua
> >
> > 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:/
> quickst...
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:/
Francesco Banconi (frankban) wrote : | # |
Thank you!
Preview Diff
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 |
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): models/ envs.py models/ fields. py tests/models/ test_envs. py tests/models/ test_fields. py
A [revision details]
M quickstart/
M quickstart/
M quickstart/
M quickstart/