Merge lp:~james-page/charm-helpers/caching_hookenv into lp:charm-helpers

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
Reviewer Review Type Date Requested Status
Matthew Wedgwood (community) Approve
Review via email: mp+169160@code.launchpad.net

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 data to None

relation_get('missing') == None
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

Revision history for this message
Matthew Wedgwood (mew) wrote :

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

review: Approve

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-

Subscribers

People subscribed via source and target branches