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
=== modified file 'charmhelpers/core/hookenv.py'
--- charmhelpers/core/hookenv.py 2013-05-31 10:27:31 +0000
+++ charmhelpers/core/hookenv.py 2013-06-13 19:27:26 +0000
@@ -17,6 +17,44 @@
17DEBUG = "DEBUG"17DEBUG = "DEBUG"
18MARKER = object()18MARKER = object()
1919
20cache = {}
21
22
23def cached(func):
24 ''' Cache return values for multiple executions of func + args
25
26 For example:
27
28 @cached
29 def unit_get(attribute):
30 pass
31
32 unit_get('test')
33
34 will cache the result of unit_get + 'test' for future calls.
35 '''
36 def wrapper(*args, **kwargs):
37 global cache
38 key = str((func, args, kwargs))
39 try:
40 return cache[key]
41 except KeyError:
42 res = func(*args, **kwargs)
43 cache[key] = res
44 return res
45 return wrapper
46
47
48def flush(key):
49 ''' Flushes any entries from function cache where the
50 key is found in the function+args '''
51 flush_list = []
52 for item in cache:
53 if key in item:
54 flush_list.append(item)
55 for item in flush_list:
56 del cache[item]
57
2058
21def log(message, level=None):59def log(message, level=None):
22 "Write a message to the juju log"60 "Write a message to the juju log"
@@ -96,6 +134,7 @@
96 return os.environ['JUJU_REMOTE_UNIT']134 return os.environ['JUJU_REMOTE_UNIT']
97135
98136
137@cached
99def config(scope=None):138def config(scope=None):
100 "Juju charm configuration"139 "Juju charm configuration"
101 config_cmd_line = ['config-get']140 config_cmd_line = ['config-get']
@@ -103,13 +142,14 @@
103 config_cmd_line.append(scope)142 config_cmd_line.append(scope)
104 config_cmd_line.append('--format=json')143 config_cmd_line.append('--format=json')
105 try:144 try:
106 config_data = json.loads(subprocess.check_output(config_cmd_line))145 return Serializable(json.loads(
107 except (ValueError, OSError, subprocess.CalledProcessError) as err:146 subprocess.check_output(config_cmd_line)
108 log(str(err), level=ERROR)147 ))
109 raise148 except ValueError:
110 return Serializable(config_data)149 return None
111150
112151
152@cached
113def relation_get(attribute=None, unit=None, rid=None):153def relation_get(attribute=None, unit=None, rid=None):
114 _args = ['relation-get', '--format=json']154 _args = ['relation-get', '--format=json']
115 if rid:155 if rid:
@@ -119,7 +159,7 @@
119 if unit:159 if unit:
120 _args.append(unit)160 _args.append(unit)
121 try:161 try:
122 return json.loads(subprocess.check_output(_args))162 return Serializable(json.loads(subprocess.check_output(_args)))
123 except ValueError:163 except ValueError:
124 return None164 return None
125165
@@ -133,8 +173,11 @@
133 for k, v in kwargs.items():173 for k, v in kwargs.items():
134 relation_cmd_line.append('{}={}'.format(k, v))174 relation_cmd_line.append('{}={}'.format(k, v))
135 subprocess.check_call(relation_cmd_line)175 subprocess.check_call(relation_cmd_line)
136176 # Flush cache of any relation-gets for local unit
137177 flush(local_unit())
178
179
180@cached
138def relation_ids(reltype=None):181def relation_ids(reltype=None):
139 "A list of relation_ids"182 "A list of relation_ids"
140 reltype = reltype or relation_type()183 reltype = reltype or relation_type()
@@ -145,6 +188,7 @@
145 return []188 return []
146189
147190
191@cached
148def related_units(relid=None):192def related_units(relid=None):
149 "A list of related units"193 "A list of related units"
150 relid = relid or relation_id()194 relid = relid or relation_id()
@@ -154,6 +198,7 @@
154 return json.loads(subprocess.check_output(units_cmd_line))198 return json.loads(subprocess.check_output(units_cmd_line))
155199
156200
201@cached
157def relation_for_unit(unit=None, rid=None):202def relation_for_unit(unit=None, rid=None):
158 "Get the json represenation of a unit's relation"203 "Get the json represenation of a unit's relation"
159 unit = unit or remote_unit()204 unit = unit or remote_unit()
@@ -165,6 +210,7 @@
165 return Serializable(relation)210 return Serializable(relation)
166211
167212
213@cached
168def relations_for_id(relid=None):214def relations_for_id(relid=None):
169 "Get relations of a specific relation ID"215 "Get relations of a specific relation ID"
170 relation_data = []216 relation_data = []
@@ -176,6 +222,7 @@
176 return relation_data222 return relation_data
177223
178224
225@cached
179def relations_of_type(reltype=None):226def relations_of_type(reltype=None):
180 "Get relations of a specific type"227 "Get relations of a specific type"
181 relation_data = []228 relation_data = []
@@ -187,13 +234,14 @@
187 return relation_data234 return relation_data
188235
189236
237@cached
190def relation_types():238def relation_types():
191 "Get a list of relation types supported by this charm"239 "Get a list of relation types supported by this charm"
192 charmdir = os.environ.get('CHARM_DIR', '')240 charmdir = os.environ.get('CHARM_DIR', '')
193 mdf = open(os.path.join(charmdir, 'metadata.yaml'))241 mdf = open(os.path.join(charmdir, 'metadata.yaml'))
194 md = yaml.safe_load(mdf)242 md = yaml.safe_load(mdf)
195 rel_types = []243 rel_types = []
196 for key in ('provides','requires','peers'):244 for key in ('provides', 'requires', 'peers'):
197 section = md.get(key)245 section = md.get(key)
198 if section:246 if section:
199 rel_types.extend(section.keys())247 rel_types.extend(section.keys())
@@ -201,6 +249,7 @@
201 return rel_types249 return rel_types
202250
203251
252@cached
204def relations():253def relations():
205 rels = {}254 rels = {}
206 for reltype in relation_types():255 for reltype in relation_types():
@@ -229,9 +278,13 @@
229 subprocess.check_call(_args)278 subprocess.check_call(_args)
230279
231280
281@cached
232def unit_get(attribute):282def unit_get(attribute):
233 _args = ['unit-get', attribute]283 _args = ['unit-get', '--format=json', attribute]
234 return subprocess.check_output(_args).strip()284 try:
285 return json.loads(subprocess.check_output(_args))
286 except ValueError:
287 return None
235288
236289
237def unit_private_ip():290def unit_private_ip():
238291
=== modified file 'tests/core/test_hookenv.py'
--- tests/core/test_hookenv.py 2013-05-31 10:27:31 +0000
+++ tests/core/test_hookenv.py 2013-06-13 19:27:26 +0000
@@ -22,6 +22,7 @@
22 interface: mock22 interface: mock
23"""23"""
2424
25
25class SerializableTest(TestCase):26class SerializableTest(TestCase):
26 def test_serializes_object_to_json(self):27 def test_serializes_object_to_json(self):
27 foo = {28 foo = {
@@ -45,6 +46,14 @@
4546
46 self.assertEqual(wrapped.bar, 'baz')47 self.assertEqual(wrapped.bar, 'baz')
4748
49 def test_raises_error_from_inner_object_as_dict(self):
50 foo = {
51 'bar': 'baz',
52 }
53 wrapped = hookenv.Serializable(foo)
54
55 self.assertRaises(AttributeError, getattr, wrapped, 'baz')
56
48 def test_dict_methods_from_inner_object(self):57 def test_dict_methods_from_inner_object(self):
49 foo = {58 foo = {
50 'bar': 'baz',59 'bar': 'baz',
@@ -80,6 +89,11 @@
8089
8190
82class HelpersTest(TestCase):91class HelpersTest(TestCase):
92 def setUp(self):
93 super(HelpersTest, self).setUp()
94 # Reset hookenv cache for each test
95 hookenv.cache = {}
96
83 @patch('subprocess.call')97 @patch('subprocess.call')
84 def test_logs_messages_to_juju_with_default_level(self, mock_call):98 def test_logs_messages_to_juju_with_default_level(self, mock_call):
85 hookenv.log('foo')99 hookenv.log('foo')
@@ -111,21 +125,22 @@
111125
112 @patch('subprocess.check_output')126 @patch('subprocess.check_output')
113 def test_gets_charm_config_with_scope(self, check_output):127 def test_gets_charm_config_with_scope(self, check_output):
114 config_data = {'foo': 'bar'}128 config_data = 'bar'
115 check_output.return_value = json.dumps(config_data)129 check_output.return_value = json.dumps(config_data)
116130
117 result = hookenv.config(scope='baz')131 result = hookenv.config(scope='baz')
118132
119 self.assertEqual(result.foo, 'bar')133 self.assertEqual(result, 'bar')
120 check_output.assert_called_with(['config-get', 'baz', '--format=json'])134 check_output.assert_called_with(['config-get', 'baz', '--format=json'])
121135
122 @patch('subprocess.check_output')136 @patch('subprocess.check_output')
123 @patch('charmhelpers.core.hookenv.log')137 def test_gets_missing_charm_config_with_scope(self, check_output):
124 def test_logs_and_reraises_on_config_error(self, log, check_output):138 check_output.return_value = ''
125 error = 'some error'139
126 check_output.side_effect = ValueError(error)140 result = hookenv.config(scope='baz')
127141
128 self.assertRaisesRegexp(ValueError, error, hookenv.config)142 self.assertEqual(result, None)
143 check_output.assert_called_with(['config-get', 'baz', '--format=json'])
129144
130 @patch('charmhelpers.core.hookenv.os')145 @patch('charmhelpers.core.hookenv.os')
131 def test_gets_the_local_unit(self, os_):146 def test_gets_the_local_unit(self, os_):
@@ -392,9 +407,9 @@
392 @patch('charmhelpers.core.hookenv.relation_get')407 @patch('charmhelpers.core.hookenv.relation_get')
393 def test_gets_relations(self, relation_get, related_units,408 def test_gets_relations(self, relation_get, related_units,
394 relation_ids, relation_types):409 relation_ids, relation_types):
395 relation_types.return_value = ['t1','t2']410 relation_types.return_value = ['t1', 't2']
396 relation_ids.return_value = ['i1']411 relation_ids.return_value = ['i1']
397 related_units.return_value = ['u1','u2']412 related_units.return_value = ['u1', 'u2']
398 relation_get.return_value = {'key': 'val'}413 relation_get.return_value = {'key': 'val'}
399414
400 result = hookenv.relations()415 result = hookenv.relations()
@@ -414,7 +429,6 @@
414 },429 },
415 })430 })
416431
417
418 @patch('charmhelpers.core.hookenv.config')432 @patch('charmhelpers.core.hookenv.config')
419 @patch('charmhelpers.core.hookenv.relation_type')433 @patch('charmhelpers.core.hookenv.relation_type')
420 @patch('charmhelpers.core.hookenv.local_unit')434 @patch('charmhelpers.core.hookenv.local_unit')
@@ -460,59 +474,77 @@
460474
461 @patch('subprocess.check_output')475 @patch('subprocess.check_output')
462 def test_gets_relation(self, check_output):476 def test_gets_relation(self, check_output):
463 json_string = '{"foo": "BAR"}'477 data = {"foo": "BAR"}
464 check_output.return_value = json_string478 check_output.return_value = json.dumps(data)
465
466 result = hookenv.relation_get()479 result = hookenv.relation_get()
467480
468 self.assertEqual(result['foo'], 'BAR')481 self.assertEqual(result.foo, 'BAR')
469 check_output.assert_called_with(['relation-get', '--format=json', '-'])482 check_output.assert_called_with(['relation-get', '--format=json', '-'])
470483
471 @patch('subprocess.check_output')484 @patch('subprocess.check_output')
472 def test_gets_relation_with_scope(self, check_output):485 def test_gets_relation_with_scope(self, check_output):
473 json_string = '{"foo": "BAR"}'486 check_output.return_value = json.dumps('bar')
474 check_output.return_value = json_string487
475488 result = hookenv.relation_get(attribute='baz-scope')
476 result = hookenv.relation_get(attribute='baz-scope')489
477490 self.assertEqual(result, 'bar')
478 self.assertEqual(result['foo'], 'BAR')491 check_output.assert_called_with(['relation-get', '--format=json',
492 'baz-scope'])
493
494 @patch('subprocess.check_output')
495 def test_gets_missing_relation_with_scope(self, check_output):
496 check_output.return_value = ""
497
498 result = hookenv.relation_get(attribute='baz-scope')
499
500 self.assertEqual(result, None)
479 check_output.assert_called_with(['relation-get', '--format=json',501 check_output.assert_called_with(['relation-get', '--format=json',
480 'baz-scope'])502 'baz-scope'])
481503
482 @patch('subprocess.check_output')504 @patch('subprocess.check_output')
483 def test_gets_relation_with_unit_name(self, check_output):505 def test_gets_relation_with_unit_name(self, check_output):
484 json_string = '{"foo": "BAR"}'506 check_output.return_value = json.dumps('BAR')
485 check_output.return_value = json_string
486507
487 result = hookenv.relation_get(attribute='baz-scope', unit='baz-unit')508 result = hookenv.relation_get(attribute='baz-scope', unit='baz-unit')
488509
489 self.assertEqual(result['foo'], 'BAR')510 self.assertEqual(result, 'BAR')
490 check_output.assert_called_with(['relation-get', '--format=json',511 check_output.assert_called_with(['relation-get', '--format=json',
491 'baz-scope', 'baz-unit'])512 'baz-scope', 'baz-unit'])
492513
514 @patch('charmhelpers.core.hookenv.local_unit')
515 @patch('subprocess.check_call')
516 @patch('subprocess.check_output')
517 def test_relation_set_flushes_local_unit_cache(self, check_output,
518 check_call, local_unit):
519 check_output.return_value = json.dumps('BAR')
520 local_unit.return_value = 'baz_unit'
521 hookenv.relation_get(attribute='baz_scope', unit='baz_unit')
522 hookenv.relation_get(attribute='bar_scope')
523 self.assertTrue(len(hookenv.cache) == 2)
524 hookenv.relation_set(baz_scope='hello')
525 # relation_set should flush any entries for local_unit
526 self.assertTrue(len(hookenv.cache) == 1)
527
493 @patch('subprocess.check_output')528 @patch('subprocess.check_output')
494 def test_gets_relation_with_relation_id(self, check_output):529 def test_gets_relation_with_relation_id(self, check_output):
495 json_string = '{"foo": "BAR"}'530 check_output.return_value = json.dumps('BAR')
496 check_output.return_value = json_string
497531
498 result = hookenv.relation_get(attribute='baz-scope', unit='baz-unit',532 result = hookenv.relation_get(attribute='baz-scope', unit='baz-unit',
499 rid=123)533 rid=123)
500534
501 self.assertEqual(result['foo'], 'BAR')535 self.assertEqual(result, 'BAR')
502 check_output.assert_called_with(['relation-get', '--format=json', '-r',536 check_output.assert_called_with(['relation-get', '--format=json', '-r',
503 123, 'baz-scope', 'baz-unit'])537 123, 'baz-scope', 'baz-unit'])
504538
505 @patch('subprocess.check_call')539 @patch('subprocess.check_call')
506 def test_sets_relation_with_kwargs(self, check_call_):540 def test_sets_relation_with_kwargs(self, check_call_):
507 hookenv.relation_set(foo="bar")541 hookenv.relation_set(foo="bar")
508 check_call_.assert_called_with(['relation-set','foo=bar'])542 check_call_.assert_called_with(['relation-set', 'foo=bar'])
509
510543
511 @patch('subprocess.check_call')544 @patch('subprocess.check_call')
512 def test_sets_relation_with_dict(self, check_call_):545 def test_sets_relation_with_dict(self, check_call_):
513 hookenv.relation_set(relation_settings={"foo":"bar"})546 hookenv.relation_set(relation_settings={"foo": "bar"})
514 check_call_.assert_called_with(['relation-set','foo=bar'])547 check_call_.assert_called_with(['relation-set', 'foo=bar'])
515
516548
517 @patch('subprocess.check_call')549 @patch('subprocess.check_call')
518 def test_sets_relation_with_relation_id(self, check_call_):550 def test_sets_relation_with_relation_id(self, check_call_):
@@ -520,7 +552,6 @@
520 check_call_.assert_called_with(['relation-set', '-r', 'foo',552 check_call_.assert_called_with(['relation-set', '-r', 'foo',
521 'bar=baz'])553 'bar=baz'])
522554
523
524 def test_lists_relation_types(self):555 def test_lists_relation_types(self):
525 open_ = mock_open()556 open_ = mock_open()
526 open_.return_value = StringIO(CHARM_METADATA)557 open_.return_value = StringIO(CHARM_METADATA)
@@ -528,36 +559,61 @@
528 with patch.dict('os.environ', {'CHARM_DIR': '/var/empty'}):559 with patch.dict('os.environ', {'CHARM_DIR': '/var/empty'}):
529 reltypes = set(hookenv.relation_types())560 reltypes = set(hookenv.relation_types())
530 open_.assert_called_once_with('/var/empty/metadata.yaml')561 open_.assert_called_once_with('/var/empty/metadata.yaml')
531 self.assertEqual(set(('testreqs','testprov','testpeer')), reltypes)562 self.assertEqual(set(('testreqs', 'testprov', 'testpeer')), reltypes)
532
533563
534 @patch('subprocess.check_call')564 @patch('subprocess.check_call')
535 def test_opens_port(self, check_call_):565 def test_opens_port(self, check_call_):
536 hookenv.open_port(443, "TCP")566 hookenv.open_port(443, "TCP")
537 hookenv.open_port(80)567 hookenv.open_port(80)
538 hookenv.open_port(100, "UDP")568 hookenv.open_port(100, "UDP")
539 calls = [call(['open-port','443/TCP']),569 calls = [call(['open-port', '443/TCP']),
540 call(['open-port','80/TCP']),570 call(['open-port', '80/TCP']),
541 call(['open-port','100/UDP']),571 call(['open-port', '100/UDP']),
542 ]572 ]
543 check_call_.assert_has_calls(calls)573 check_call_.assert_has_calls(calls)
544574
545
546 @patch('subprocess.check_call')575 @patch('subprocess.check_call')
547 def test_closes_port(self, check_call_):576 def test_closes_port(self, check_call_):
548 hookenv.close_port(443, "TCP")577 hookenv.close_port(443, "TCP")
549 hookenv.close_port(80)578 hookenv.close_port(80)
550 hookenv.close_port(100, "UDP")579 hookenv.close_port(100, "UDP")
551 calls = [call(['close-port','443/TCP']),580 calls = [call(['close-port', '443/TCP']),
552 call(['close-port','80/TCP']),581 call(['close-port', '80/TCP']),
553 call(['close-port','100/UDP']),582 call(['close-port', '100/UDP']),
554 ]583 ]
555 check_call_.assert_has_calls(calls)584 check_call_.assert_has_calls(calls)
556585
557 @patch('subprocess.check_output')586 @patch('subprocess.check_output')
558 def test_gets_unit_attribute(self, check_output_):587 def test_gets_unit_attribute(self, check_output_):
559 hookenv.unit_get('foo')588 check_output_.return_value = json.dumps('bar')
560 check_output_.assert_called_with(['unit-get', 'foo'])589 self.assertEqual(hookenv.unit_get('foo'), 'bar')
590 check_output_.assert_called_with(['unit-get', '--format=json', 'foo'])
591
592 @patch('subprocess.check_output')
593 def test_gets_missing_unit_attribute(self, check_output_):
594 check_output_.return_value = ""
595 self.assertEqual(hookenv.unit_get('foo'), None)
596 check_output_.assert_called_with(['unit-get', '--format=json', 'foo'])
597
598 def test_cached_decorator(self):
599 calls = []
600 values = {
601 'hello': 'world',
602 'foo': 'bar',
603 'baz': None
604 }
605
606 @hookenv.cached
607 def cache_function(attribute):
608 calls.append(attribute)
609 return values[attribute]
610
611 self.assertEquals(cache_function('hello'), 'world')
612 self.assertEquals(cache_function('hello'), 'world')
613 self.assertEquals(cache_function('foo'), 'bar')
614 self.assertEquals(cache_function('baz'), None)
615 self.assertEquals(cache_function('baz'), None)
616 self.assertEquals(calls, ['hello', 'foo', 'baz'])
561617
562618
563class HooksTest(TestCase):619class HooksTest(TestCase):
@@ -604,4 +660,3 @@
604 self.assertRaises(hookenv.UnregisteredHookError, hooks.execute,660 self.assertRaises(hookenv.UnregisteredHookError, hooks.execute,
605 ['brew'])661 ['brew'])
606 self.assertEqual(execs, [True])662 self.assertEqual(execs, [True])
607

Subscribers

People subscribed via source and target branches