Merge lp:~james-page/charm-helpers/caching_hookenv into lp:charm-helpers
- caching_hookenv
- Merge into devel
Proposed by
James Page
Status: | Merged |
---|---|
Merged at revision: | 31 |
Proposed branch: | lp:~james-page/charm-helpers/caching_hookenv |
Merge into: | lp:charm-helpers |
Diff against target: |
448 lines (+166/-58) 2 files modified
charmhelpers/core/hookenv.py (+66/-13) tests/core/test_hookenv.py (+100/-45) |
To merge this branch: | bzr merge lp:~james-page/charm-helpers/caching_hookenv |
Related bugs: |
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
Matthew Wedgwood (community) | Approve | ||
Review via email: mp+169160@code.launchpad.net |
Commit message
Description of the change
Various refactoring + additions to hookenv
1) cached decorator
Allows caching of function + args to avoid repeated calls out the cli
to juju commands.
2) Normalizing missing unit/config/
relation_
config('missing') == None
unit_get('missing') == None
3) Normalized use of the Serializable object a bit
And added a unit test to cover missing attribute lookups
Question: maybe this should actually return 'None' rather than throwing
an exception inline with 2).
4) Tweaks some tests to be closer to juju behaviour with json
5) Tidied misc pep8/pylint warnings.
To post a comment you must log in.
- 29. By James Page
-
Also cache config helper
- 30. By James Page
-
Flush key for local_unit on relation_set
Preview Diff
[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1 | === modified file 'charmhelpers/core/hookenv.py' |
2 | --- charmhelpers/core/hookenv.py 2013-05-31 10:27:31 +0000 |
3 | +++ charmhelpers/core/hookenv.py 2013-06-13 19:27:26 +0000 |
4 | @@ -17,6 +17,44 @@ |
5 | DEBUG = "DEBUG" |
6 | MARKER = object() |
7 | |
8 | +cache = {} |
9 | + |
10 | + |
11 | +def cached(func): |
12 | + ''' Cache return values for multiple executions of func + args |
13 | + |
14 | + For example: |
15 | + |
16 | + @cached |
17 | + def unit_get(attribute): |
18 | + pass |
19 | + |
20 | + unit_get('test') |
21 | + |
22 | + will cache the result of unit_get + 'test' for future calls. |
23 | + ''' |
24 | + def wrapper(*args, **kwargs): |
25 | + global cache |
26 | + key = str((func, args, kwargs)) |
27 | + try: |
28 | + return cache[key] |
29 | + except KeyError: |
30 | + res = func(*args, **kwargs) |
31 | + cache[key] = res |
32 | + return res |
33 | + return wrapper |
34 | + |
35 | + |
36 | +def flush(key): |
37 | + ''' Flushes any entries from function cache where the |
38 | + key is found in the function+args ''' |
39 | + flush_list = [] |
40 | + for item in cache: |
41 | + if key in item: |
42 | + flush_list.append(item) |
43 | + for item in flush_list: |
44 | + del cache[item] |
45 | + |
46 | |
47 | def log(message, level=None): |
48 | "Write a message to the juju log" |
49 | @@ -96,6 +134,7 @@ |
50 | return os.environ['JUJU_REMOTE_UNIT'] |
51 | |
52 | |
53 | +@cached |
54 | def config(scope=None): |
55 | "Juju charm configuration" |
56 | config_cmd_line = ['config-get'] |
57 | @@ -103,13 +142,14 @@ |
58 | config_cmd_line.append(scope) |
59 | config_cmd_line.append('--format=json') |
60 | try: |
61 | - config_data = json.loads(subprocess.check_output(config_cmd_line)) |
62 | - except (ValueError, OSError, subprocess.CalledProcessError) as err: |
63 | - log(str(err), level=ERROR) |
64 | - raise |
65 | - return Serializable(config_data) |
66 | - |
67 | - |
68 | + return Serializable(json.loads( |
69 | + subprocess.check_output(config_cmd_line) |
70 | + )) |
71 | + except ValueError: |
72 | + return None |
73 | + |
74 | + |
75 | +@cached |
76 | def relation_get(attribute=None, unit=None, rid=None): |
77 | _args = ['relation-get', '--format=json'] |
78 | if rid: |
79 | @@ -119,7 +159,7 @@ |
80 | if unit: |
81 | _args.append(unit) |
82 | try: |
83 | - return json.loads(subprocess.check_output(_args)) |
84 | + return Serializable(json.loads(subprocess.check_output(_args))) |
85 | except ValueError: |
86 | return None |
87 | |
88 | @@ -133,8 +173,11 @@ |
89 | for k, v in kwargs.items(): |
90 | relation_cmd_line.append('{}={}'.format(k, v)) |
91 | subprocess.check_call(relation_cmd_line) |
92 | - |
93 | - |
94 | + # Flush cache of any relation-gets for local unit |
95 | + flush(local_unit()) |
96 | + |
97 | + |
98 | +@cached |
99 | def relation_ids(reltype=None): |
100 | "A list of relation_ids" |
101 | reltype = reltype or relation_type() |
102 | @@ -145,6 +188,7 @@ |
103 | return [] |
104 | |
105 | |
106 | +@cached |
107 | def related_units(relid=None): |
108 | "A list of related units" |
109 | relid = relid or relation_id() |
110 | @@ -154,6 +198,7 @@ |
111 | return json.loads(subprocess.check_output(units_cmd_line)) |
112 | |
113 | |
114 | +@cached |
115 | def relation_for_unit(unit=None, rid=None): |
116 | "Get the json represenation of a unit's relation" |
117 | unit = unit or remote_unit() |
118 | @@ -165,6 +210,7 @@ |
119 | return Serializable(relation) |
120 | |
121 | |
122 | +@cached |
123 | def relations_for_id(relid=None): |
124 | "Get relations of a specific relation ID" |
125 | relation_data = [] |
126 | @@ -176,6 +222,7 @@ |
127 | return relation_data |
128 | |
129 | |
130 | +@cached |
131 | def relations_of_type(reltype=None): |
132 | "Get relations of a specific type" |
133 | relation_data = [] |
134 | @@ -187,13 +234,14 @@ |
135 | return relation_data |
136 | |
137 | |
138 | +@cached |
139 | def relation_types(): |
140 | "Get a list of relation types supported by this charm" |
141 | charmdir = os.environ.get('CHARM_DIR', '') |
142 | mdf = open(os.path.join(charmdir, 'metadata.yaml')) |
143 | md = yaml.safe_load(mdf) |
144 | rel_types = [] |
145 | - for key in ('provides','requires','peers'): |
146 | + for key in ('provides', 'requires', 'peers'): |
147 | section = md.get(key) |
148 | if section: |
149 | rel_types.extend(section.keys()) |
150 | @@ -201,6 +249,7 @@ |
151 | return rel_types |
152 | |
153 | |
154 | +@cached |
155 | def relations(): |
156 | rels = {} |
157 | for reltype in relation_types(): |
158 | @@ -229,9 +278,13 @@ |
159 | subprocess.check_call(_args) |
160 | |
161 | |
162 | +@cached |
163 | def unit_get(attribute): |
164 | - _args = ['unit-get', attribute] |
165 | - return subprocess.check_output(_args).strip() |
166 | + _args = ['unit-get', '--format=json', attribute] |
167 | + try: |
168 | + return json.loads(subprocess.check_output(_args)) |
169 | + except ValueError: |
170 | + return None |
171 | |
172 | |
173 | def unit_private_ip(): |
174 | |
175 | === modified file 'tests/core/test_hookenv.py' |
176 | --- tests/core/test_hookenv.py 2013-05-31 10:27:31 +0000 |
177 | +++ tests/core/test_hookenv.py 2013-06-13 19:27:26 +0000 |
178 | @@ -22,6 +22,7 @@ |
179 | interface: mock |
180 | """ |
181 | |
182 | + |
183 | class SerializableTest(TestCase): |
184 | def test_serializes_object_to_json(self): |
185 | foo = { |
186 | @@ -45,6 +46,14 @@ |
187 | |
188 | self.assertEqual(wrapped.bar, 'baz') |
189 | |
190 | + def test_raises_error_from_inner_object_as_dict(self): |
191 | + foo = { |
192 | + 'bar': 'baz', |
193 | + } |
194 | + wrapped = hookenv.Serializable(foo) |
195 | + |
196 | + self.assertRaises(AttributeError, getattr, wrapped, 'baz') |
197 | + |
198 | def test_dict_methods_from_inner_object(self): |
199 | foo = { |
200 | 'bar': 'baz', |
201 | @@ -80,6 +89,11 @@ |
202 | |
203 | |
204 | class HelpersTest(TestCase): |
205 | + def setUp(self): |
206 | + super(HelpersTest, self).setUp() |
207 | + # Reset hookenv cache for each test |
208 | + hookenv.cache = {} |
209 | + |
210 | @patch('subprocess.call') |
211 | def test_logs_messages_to_juju_with_default_level(self, mock_call): |
212 | hookenv.log('foo') |
213 | @@ -111,21 +125,22 @@ |
214 | |
215 | @patch('subprocess.check_output') |
216 | def test_gets_charm_config_with_scope(self, check_output): |
217 | - config_data = {'foo': 'bar'} |
218 | + config_data = 'bar' |
219 | check_output.return_value = json.dumps(config_data) |
220 | |
221 | result = hookenv.config(scope='baz') |
222 | |
223 | - self.assertEqual(result.foo, 'bar') |
224 | + self.assertEqual(result, 'bar') |
225 | check_output.assert_called_with(['config-get', 'baz', '--format=json']) |
226 | |
227 | @patch('subprocess.check_output') |
228 | - @patch('charmhelpers.core.hookenv.log') |
229 | - def test_logs_and_reraises_on_config_error(self, log, check_output): |
230 | - error = 'some error' |
231 | - check_output.side_effect = ValueError(error) |
232 | - |
233 | - self.assertRaisesRegexp(ValueError, error, hookenv.config) |
234 | + def test_gets_missing_charm_config_with_scope(self, check_output): |
235 | + check_output.return_value = '' |
236 | + |
237 | + result = hookenv.config(scope='baz') |
238 | + |
239 | + self.assertEqual(result, None) |
240 | + check_output.assert_called_with(['config-get', 'baz', '--format=json']) |
241 | |
242 | @patch('charmhelpers.core.hookenv.os') |
243 | def test_gets_the_local_unit(self, os_): |
244 | @@ -392,9 +407,9 @@ |
245 | @patch('charmhelpers.core.hookenv.relation_get') |
246 | def test_gets_relations(self, relation_get, related_units, |
247 | relation_ids, relation_types): |
248 | - relation_types.return_value = ['t1','t2'] |
249 | + relation_types.return_value = ['t1', 't2'] |
250 | relation_ids.return_value = ['i1'] |
251 | - related_units.return_value = ['u1','u2'] |
252 | + related_units.return_value = ['u1', 'u2'] |
253 | relation_get.return_value = {'key': 'val'} |
254 | |
255 | result = hookenv.relations() |
256 | @@ -414,7 +429,6 @@ |
257 | }, |
258 | }) |
259 | |
260 | - |
261 | @patch('charmhelpers.core.hookenv.config') |
262 | @patch('charmhelpers.core.hookenv.relation_type') |
263 | @patch('charmhelpers.core.hookenv.local_unit') |
264 | @@ -460,59 +474,77 @@ |
265 | |
266 | @patch('subprocess.check_output') |
267 | def test_gets_relation(self, check_output): |
268 | - json_string = '{"foo": "BAR"}' |
269 | - check_output.return_value = json_string |
270 | - |
271 | + data = {"foo": "BAR"} |
272 | + check_output.return_value = json.dumps(data) |
273 | result = hookenv.relation_get() |
274 | |
275 | - self.assertEqual(result['foo'], 'BAR') |
276 | + self.assertEqual(result.foo, 'BAR') |
277 | check_output.assert_called_with(['relation-get', '--format=json', '-']) |
278 | |
279 | @patch('subprocess.check_output') |
280 | def test_gets_relation_with_scope(self, check_output): |
281 | - json_string = '{"foo": "BAR"}' |
282 | - check_output.return_value = json_string |
283 | - |
284 | - result = hookenv.relation_get(attribute='baz-scope') |
285 | - |
286 | - self.assertEqual(result['foo'], 'BAR') |
287 | + check_output.return_value = json.dumps('bar') |
288 | + |
289 | + result = hookenv.relation_get(attribute='baz-scope') |
290 | + |
291 | + self.assertEqual(result, 'bar') |
292 | + check_output.assert_called_with(['relation-get', '--format=json', |
293 | + 'baz-scope']) |
294 | + |
295 | + @patch('subprocess.check_output') |
296 | + def test_gets_missing_relation_with_scope(self, check_output): |
297 | + check_output.return_value = "" |
298 | + |
299 | + result = hookenv.relation_get(attribute='baz-scope') |
300 | + |
301 | + self.assertEqual(result, None) |
302 | check_output.assert_called_with(['relation-get', '--format=json', |
303 | 'baz-scope']) |
304 | |
305 | @patch('subprocess.check_output') |
306 | def test_gets_relation_with_unit_name(self, check_output): |
307 | - json_string = '{"foo": "BAR"}' |
308 | - check_output.return_value = json_string |
309 | + check_output.return_value = json.dumps('BAR') |
310 | |
311 | result = hookenv.relation_get(attribute='baz-scope', unit='baz-unit') |
312 | |
313 | - self.assertEqual(result['foo'], 'BAR') |
314 | + self.assertEqual(result, 'BAR') |
315 | check_output.assert_called_with(['relation-get', '--format=json', |
316 | 'baz-scope', 'baz-unit']) |
317 | |
318 | + @patch('charmhelpers.core.hookenv.local_unit') |
319 | + @patch('subprocess.check_call') |
320 | + @patch('subprocess.check_output') |
321 | + def test_relation_set_flushes_local_unit_cache(self, check_output, |
322 | + check_call, local_unit): |
323 | + check_output.return_value = json.dumps('BAR') |
324 | + local_unit.return_value = 'baz_unit' |
325 | + hookenv.relation_get(attribute='baz_scope', unit='baz_unit') |
326 | + hookenv.relation_get(attribute='bar_scope') |
327 | + self.assertTrue(len(hookenv.cache) == 2) |
328 | + hookenv.relation_set(baz_scope='hello') |
329 | + # relation_set should flush any entries for local_unit |
330 | + self.assertTrue(len(hookenv.cache) == 1) |
331 | + |
332 | @patch('subprocess.check_output') |
333 | def test_gets_relation_with_relation_id(self, check_output): |
334 | - json_string = '{"foo": "BAR"}' |
335 | - check_output.return_value = json_string |
336 | + check_output.return_value = json.dumps('BAR') |
337 | |
338 | result = hookenv.relation_get(attribute='baz-scope', unit='baz-unit', |
339 | rid=123) |
340 | |
341 | - self.assertEqual(result['foo'], 'BAR') |
342 | + self.assertEqual(result, 'BAR') |
343 | check_output.assert_called_with(['relation-get', '--format=json', '-r', |
344 | 123, 'baz-scope', 'baz-unit']) |
345 | |
346 | @patch('subprocess.check_call') |
347 | def test_sets_relation_with_kwargs(self, check_call_): |
348 | hookenv.relation_set(foo="bar") |
349 | - check_call_.assert_called_with(['relation-set','foo=bar']) |
350 | - |
351 | + check_call_.assert_called_with(['relation-set', 'foo=bar']) |
352 | |
353 | @patch('subprocess.check_call') |
354 | def test_sets_relation_with_dict(self, check_call_): |
355 | - hookenv.relation_set(relation_settings={"foo":"bar"}) |
356 | - check_call_.assert_called_with(['relation-set','foo=bar']) |
357 | - |
358 | + hookenv.relation_set(relation_settings={"foo": "bar"}) |
359 | + check_call_.assert_called_with(['relation-set', 'foo=bar']) |
360 | |
361 | @patch('subprocess.check_call') |
362 | def test_sets_relation_with_relation_id(self, check_call_): |
363 | @@ -520,7 +552,6 @@ |
364 | check_call_.assert_called_with(['relation-set', '-r', 'foo', |
365 | 'bar=baz']) |
366 | |
367 | - |
368 | def test_lists_relation_types(self): |
369 | open_ = mock_open() |
370 | open_.return_value = StringIO(CHARM_METADATA) |
371 | @@ -528,36 +559,61 @@ |
372 | with patch.dict('os.environ', {'CHARM_DIR': '/var/empty'}): |
373 | reltypes = set(hookenv.relation_types()) |
374 | open_.assert_called_once_with('/var/empty/metadata.yaml') |
375 | - self.assertEqual(set(('testreqs','testprov','testpeer')), reltypes) |
376 | - |
377 | + self.assertEqual(set(('testreqs', 'testprov', 'testpeer')), reltypes) |
378 | |
379 | @patch('subprocess.check_call') |
380 | def test_opens_port(self, check_call_): |
381 | hookenv.open_port(443, "TCP") |
382 | hookenv.open_port(80) |
383 | hookenv.open_port(100, "UDP") |
384 | - calls = [call(['open-port','443/TCP']), |
385 | - call(['open-port','80/TCP']), |
386 | - call(['open-port','100/UDP']), |
387 | + calls = [call(['open-port', '443/TCP']), |
388 | + call(['open-port', '80/TCP']), |
389 | + call(['open-port', '100/UDP']), |
390 | ] |
391 | check_call_.assert_has_calls(calls) |
392 | |
393 | - |
394 | @patch('subprocess.check_call') |
395 | def test_closes_port(self, check_call_): |
396 | hookenv.close_port(443, "TCP") |
397 | hookenv.close_port(80) |
398 | hookenv.close_port(100, "UDP") |
399 | - calls = [call(['close-port','443/TCP']), |
400 | - call(['close-port','80/TCP']), |
401 | - call(['close-port','100/UDP']), |
402 | + calls = [call(['close-port', '443/TCP']), |
403 | + call(['close-port', '80/TCP']), |
404 | + call(['close-port', '100/UDP']), |
405 | ] |
406 | check_call_.assert_has_calls(calls) |
407 | |
408 | @patch('subprocess.check_output') |
409 | def test_gets_unit_attribute(self, check_output_): |
410 | - hookenv.unit_get('foo') |
411 | - check_output_.assert_called_with(['unit-get', 'foo']) |
412 | + check_output_.return_value = json.dumps('bar') |
413 | + self.assertEqual(hookenv.unit_get('foo'), 'bar') |
414 | + check_output_.assert_called_with(['unit-get', '--format=json', 'foo']) |
415 | + |
416 | + @patch('subprocess.check_output') |
417 | + def test_gets_missing_unit_attribute(self, check_output_): |
418 | + check_output_.return_value = "" |
419 | + self.assertEqual(hookenv.unit_get('foo'), None) |
420 | + check_output_.assert_called_with(['unit-get', '--format=json', 'foo']) |
421 | + |
422 | + def test_cached_decorator(self): |
423 | + calls = [] |
424 | + values = { |
425 | + 'hello': 'world', |
426 | + 'foo': 'bar', |
427 | + 'baz': None |
428 | + } |
429 | + |
430 | + @hookenv.cached |
431 | + def cache_function(attribute): |
432 | + calls.append(attribute) |
433 | + return values[attribute] |
434 | + |
435 | + self.assertEquals(cache_function('hello'), 'world') |
436 | + self.assertEquals(cache_function('hello'), 'world') |
437 | + self.assertEquals(cache_function('foo'), 'bar') |
438 | + self.assertEquals(cache_function('baz'), None) |
439 | + self.assertEquals(cache_function('baz'), None) |
440 | + self.assertEquals(calls, ['hello', 'foo', 'baz']) |
441 | |
442 | |
443 | class HooksTest(TestCase): |
444 | @@ -604,4 +660,3 @@ |
445 | self.assertRaises(hookenv.UnregisteredHookError, hooks.execute, |
446 | ['brew']) |
447 | self.assertEqual(execs, [True]) |
448 | - |
Good stuff.
I have very slight reservations about the way that flush works because it's very imprecise about the keys it removes. It doesn't matter now, but it could be confusing if someone were to extend the module in the future. It's also arguable whether it would really matter if the cache was cleared unexpectedly.
+1