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