Merge lp:~johnsca/charm-helpers/action-metadata-cli-helpers into lp:charm-helpers

Proposed by Cory Johns
Status: Merged
Merged at revision: 416
Proposed branch: lp:~johnsca/charm-helpers/action-metadata-cli-helpers
Merge into: lp:charm-helpers
Diff against target: 642 lines (+386/-36)
6 files modified
charmhelpers/cli/__init__.py (+35/-4)
charmhelpers/cli/commands.py (+2/-1)
charmhelpers/core/hookenv.py (+102/-4)
charmhelpers/core/unitdata.py (+61/-17)
tests/cli/test_cmdline.py (+56/-9)
tests/core/test_hookenv.py (+130/-1)
To merge this branch: bzr merge lp:~johnsca/charm-helpers/action-metadata-cli-helpers
Reviewer Review Type Date Requested Status
Marco Ceppi Approve
Review via email: mp+266252@code.launchpad.net

Description of the change

Added some new helpers for actions, metadata introspection, and CLI, that became useful when working on charms.reactive.

To post a comment you must log in.
418. By Cory Johns

Made import of cli subcommands from hookenv explicit

419. By Cory Johns

Add ability to override unitdata DB file via env, and added unsetrange

Revision history for this message
Marco Ceppi (marcoceppi) wrote :

LGTM

review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'charmhelpers/cli/__init__.py'
2--- charmhelpers/cli/__init__.py 2015-01-22 06:06:03 +0000
3+++ charmhelpers/cli/__init__.py 2015-07-29 16:32:12 +0000
4@@ -20,6 +20,8 @@
5
6 from six.moves import zip
7
8+from charmhelpers.core import unitdata
9+
10
11 class OutputFormatter(object):
12 def __init__(self, outfile=sys.stdout):
13@@ -53,6 +55,8 @@
14
15 def raw(self, output):
16 """Output data as raw string (default)"""
17+ if isinstance(output, (list, tuple)):
18+ output = '\n'.join(map(str, output))
19 self.outfile.write(str(output))
20
21 def py(self, output):
22@@ -91,6 +95,7 @@
23 argument_parser = None
24 subparsers = None
25 formatter = None
26+ exit_code = 0
27
28 def __init__(self):
29 if not self.argument_parser:
30@@ -115,6 +120,21 @@
31 return decorated
32 return wrapper
33
34+ def test_command(self, decorated):
35+ """
36+ Subcommand is a boolean test function, so bool return values should be
37+ converted to a 0/1 exit code.
38+ """
39+ decorated._cli_test_command = True
40+ return decorated
41+
42+ def no_output(self, decorated):
43+ """
44+ Subcommand is not expected to return a value, so don't print a spurious None.
45+ """
46+ decorated._cli_no_output = True
47+ return decorated
48+
49 def subcommand_builder(self, command_name, description=None):
50 """
51 Decorate a function that builds a subcommand. Builders should accept a
52@@ -133,11 +153,22 @@
53 argspec = inspect.getargspec(arguments.func)
54 vargs = []
55 kwargs = {}
56+ for arg in argspec.args:
57+ vargs.append(getattr(arguments, arg))
58 if argspec.varargs:
59- vargs = getattr(arguments, argspec.varargs)
60- for arg in argspec.args:
61- kwargs[arg] = getattr(arguments, arg)
62- self.formatter.format_output(arguments.func(*vargs, **kwargs), arguments.format)
63+ vargs.extend(getattr(arguments, argspec.varargs))
64+ if argspec.keywords:
65+ for kwarg in argspec.keywords.items():
66+ kwargs[kwarg] = getattr(arguments, kwarg)
67+ output = arguments.func(*vargs, **kwargs)
68+ if getattr(arguments.func, '_cli_test_command', False):
69+ self.exit_code = 0 if output else 1
70+ output = ''
71+ if getattr(arguments.func, '_cli_no_output', False):
72+ output = ''
73+ self.formatter.format_output(output, arguments.format)
74+ if unitdata._KV:
75+ unitdata._KV.flush()
76
77
78 cmdline = CommandLine()
79
80=== modified file 'charmhelpers/cli/commands.py'
81--- charmhelpers/cli/commands.py 2015-05-13 20:44:19 +0000
82+++ charmhelpers/cli/commands.py 2015-07-29 16:32:12 +0000
83@@ -24,8 +24,9 @@
84 from . import CommandLine # noqa
85
86 """
87-Import the sub-modules to be included by chlp.
88+Import the sub-modules which have decorated subcommands to register with chlp.
89 """
90 import host # noqa
91 import benchmark # noqa
92 import unitdata # noqa
93+from charmhelpers.core import hookenv # noqa
94
95=== modified file 'charmhelpers/core/hookenv.py'
96--- charmhelpers/core/hookenv.py 2015-07-22 05:58:46 +0000
97+++ charmhelpers/core/hookenv.py 2015-07-29 16:32:12 +0000
98@@ -34,6 +34,8 @@
99 import tempfile
100 from subprocess import CalledProcessError
101
102+from charmhelpers.cli import cmdline
103+
104 import six
105 if not six.PY3:
106 from UserDict import UserDict
107@@ -173,9 +175,20 @@
108 return os.environ.get('JUJU_RELATION', None)
109
110
111-def relation_id():
112- """The relation ID for the current relation hook"""
113- return os.environ.get('JUJU_RELATION_ID', None)
114+@cmdline.subcommand()
115+@cached
116+def relation_id(relation_name=None, service_or_unit=None):
117+ """The relation ID for the current or a specified relation"""
118+ if not relation_name and not service_or_unit:
119+ return os.environ.get('JUJU_RELATION_ID', None)
120+ elif relation_name and service_or_unit:
121+ service_name = service_or_unit.split('/')[0]
122+ for relid in relation_ids(relation_name):
123+ remote_service = remote_service_name(relid)
124+ if remote_service == service_name:
125+ return relid
126+ else:
127+ raise ValueError('Must specify neither or both of relation_name and service_or_unit')
128
129
130 def local_unit():
131@@ -188,14 +201,27 @@
132 return os.environ.get('JUJU_REMOTE_UNIT', None)
133
134
135+@cmdline.subcommand()
136 def service_name():
137 """The name service group this unit belongs to"""
138 return local_unit().split('/')[0]
139
140
141+@cmdline.subcommand()
142+@cached
143+def remote_service_name(relid=None):
144+ """The remote service name for a given relation-id (or the current relation)"""
145+ if relid is None:
146+ unit = remote_unit()
147+ else:
148+ units = related_units(relid)
149+ unit = units[0] if units else None
150+ return unit.split('/')[0] if unit else None
151+
152+
153 def hook_name():
154 """The name of the currently executing hook"""
155- return os.path.basename(sys.argv[0])
156+ return os.environ.get('JUJU_HOOK_NAME', os.path.basename(sys.argv[0]))
157
158
159 class Config(dict):
160@@ -469,6 +495,63 @@
161
162
163 @cached
164+def relation_to_interface(relation_name):
165+ """
166+ Given the name of a relation, return the interface that relation uses.
167+
168+ :returns: The interface name, or ``None``.
169+ """
170+ return relation_to_role_and_interface(relation_name)[1]
171+
172+
173+@cached
174+def relation_to_role_and_interface(relation_name):
175+ """
176+ Given the name of a relation, return the role and the name of the interface
177+ that relation uses (where role is one of ``provides``, ``requires``, or ``peer``).
178+
179+ :returns: A tuple containing ``(role, interface)``, or ``(None, None)``.
180+ """
181+ _metadata = metadata()
182+ for role in ('provides', 'requires', 'peer'):
183+ interface = _metadata.get(role, {}).get(relation_name, {}).get('interface')
184+ if interface:
185+ return role, interface
186+ return None, None
187+
188+
189+@cached
190+def role_and_interface_to_relations(role, interface_name):
191+ """
192+ Given a role and interface name, return a list of relation names for the
193+ current charm that use that interface under that role (where role is one
194+ of ``provides``, ``requires``, or ``peer``).
195+
196+ :returns: A list of relation names.
197+ """
198+ _metadata = metadata()
199+ results = []
200+ for relation_name, relation in _metadata.get(role, {}).items():
201+ if relation['interface'] == interface_name:
202+ results.append(relation_name)
203+ return results
204+
205+
206+@cached
207+def interface_to_relations(interface_name):
208+ """
209+ Given an interface, return a list of relation names for the current
210+ charm that use that interface.
211+
212+ :returns: A list of relation names.
213+ """
214+ results = []
215+ for role in ('provides', 'requires', 'peer'):
216+ results.extend(role_and_interface_to_relations(role, interface_name))
217+ return results
218+
219+
220+@cached
221 def charm_name():
222 """Get the name of the current charm as is specified on metadata.yaml"""
223 return metadata().get('name')
224@@ -644,6 +727,21 @@
225 subprocess.check_call(['action-fail', message])
226
227
228+def action_name():
229+ """Get the name of the currently executing action."""
230+ return os.environ.get('JUJU_ACTION_NAME')
231+
232+
233+def action_uuid():
234+ """Get the UUID of the currently executing action."""
235+ return os.environ.get('JUJU_ACTION_UUID')
236+
237+
238+def action_tag():
239+ """Get the tag for the currently executing action."""
240+ return os.environ.get('JUJU_ACTION_TAG')
241+
242+
243 def status_set(workload_state, message):
244 """Set the workload state with a message
245
246
247=== modified file 'charmhelpers/core/unitdata.py'
248--- charmhelpers/core/unitdata.py 2015-03-18 15:51:22 +0000
249+++ charmhelpers/core/unitdata.py 2015-07-29 16:32:12 +0000
250@@ -152,6 +152,7 @@
251 import collections
252 import contextlib
253 import datetime
254+import itertools
255 import json
256 import os
257 import pprint
258@@ -164,8 +165,7 @@
259 class Storage(object):
260 """Simple key value database for local unit state within charms.
261
262- Modifications are automatically committed at hook exit. That's
263- currently regardless of exit code.
264+ Modifications are not persisted unless :meth:`flush` is called.
265
266 To support dicts, lists, integer, floats, and booleans values
267 are automatically json encoded/decoded.
268@@ -173,8 +173,11 @@
269 def __init__(self, path=None):
270 self.db_path = path
271 if path is None:
272- self.db_path = os.path.join(
273- os.environ.get('CHARM_DIR', ''), '.unit-state.db')
274+ if 'UNIT_STATE_DB' in os.environ:
275+ self.db_path = os.environ['UNIT_STATE_DB']
276+ else:
277+ self.db_path = os.path.join(
278+ os.environ.get('CHARM_DIR', ''), '.unit-state.db')
279 self.conn = sqlite3.connect('%s' % self.db_path)
280 self.cursor = self.conn.cursor()
281 self.revision = None
282@@ -189,15 +192,8 @@
283 self.conn.close()
284 self._closed = True
285
286- def _scoped_query(self, stmt, params=None):
287- if params is None:
288- params = []
289- return stmt, params
290-
291 def get(self, key, default=None, record=False):
292- self.cursor.execute(
293- *self._scoped_query(
294- 'select data from kv where key=?', [key]))
295+ self.cursor.execute('select data from kv where key=?', [key])
296 result = self.cursor.fetchone()
297 if not result:
298 return default
299@@ -206,33 +202,81 @@
300 return json.loads(result[0])
301
302 def getrange(self, key_prefix, strip=False):
303- stmt = "select key, data from kv where key like '%s%%'" % key_prefix
304- self.cursor.execute(*self._scoped_query(stmt))
305+ """
306+ Get a range of keys starting with a common prefix as a mapping of
307+ keys to values.
308+
309+ :param str key_prefix: Common prefix among all keys
310+ :param bool strip: Optionally strip the common prefix from the key
311+ names in the returned dict
312+ :return dict: A (possibly empty) dict of key-value mappings
313+ """
314+ self.cursor.execute("select key, data from kv where key like ?",
315+ ['%s%%' % key_prefix])
316 result = self.cursor.fetchall()
317
318 if not result:
319- return None
320+ return {}
321 if not strip:
322 key_prefix = ''
323 return dict([
324 (k[len(key_prefix):], json.loads(v)) for k, v in result])
325
326 def update(self, mapping, prefix=""):
327+ """
328+ Set the values of multiple keys at once.
329+
330+ :param dict mapping: Mapping of keys to values
331+ :param str prefix: Optional prefix to apply to all keys in `mapping`
332+ before setting
333+ """
334 for k, v in mapping.items():
335 self.set("%s%s" % (prefix, k), v)
336
337 def unset(self, key):
338+ """
339+ Remove a key from the database entirely.
340+ """
341 self.cursor.execute('delete from kv where key=?', [key])
342 if self.revision and self.cursor.rowcount:
343 self.cursor.execute(
344 'insert into kv_revisions values (?, ?, ?)',
345 [key, self.revision, json.dumps('DELETED')])
346
347+ def unsetrange(self, keys=None, prefix=""):
348+ """
349+ Remove a range of keys starting with a common prefix, from the database
350+ entirely.
351+
352+ :param list keys: List of keys to remove.
353+ :param str prefix: Optional prefix to apply to all keys in ``keys``
354+ before removing.
355+ """
356+ if keys is not None:
357+ keys = ['%s%s' % (prefix, key) for key in keys]
358+ self.cursor.execute('delete from kv where key in (%s)' % ','.join(['?'] * len(keys)), keys)
359+ if self.revision and self.cursor.rowcount:
360+ self.cursor.execute(
361+ 'insert into kv_revisions values %s' % ','.join(['(?, ?, ?)'] * len(keys)),
362+ list(itertools.chain.from_iterable((key, self.revision, json.dumps('DELETED')) for key in keys)))
363+ else:
364+ self.cursor.execute('delete from kv where key like ?',
365+ ['%s%%' % prefix])
366+ if self.revision and self.cursor.rowcount:
367+ self.cursor.execute(
368+ 'insert into kv_revisions values (?, ?, ?)',
369+ ['%s%%' % prefix, self.revision, json.dumps('DELETED')])
370+
371 def set(self, key, value):
372+ """
373+ Set a value in the database.
374+
375+ :param str key: Key to set the value for
376+ :param value: Any JSON-serializable value to be set
377+ """
378 serialized = json.dumps(value)
379
380- self.cursor.execute(
381- 'select data from kv where key=?', [key])
382+ self.cursor.execute('select data from kv where key=?', [key])
383 exists = self.cursor.fetchone()
384
385 # Skip mutations to the same value
386
387=== modified file 'tests/cli/test_cmdline.py'
388--- tests/cli/test_cmdline.py 2014-11-25 15:04:52 +0000
389+++ tests/cli/test_cmdline.py 2015-07-29 16:32:12 +0000
390@@ -5,6 +5,7 @@
391 from mock import (
392 patch,
393 MagicMock,
394+ ANY,
395 )
396 import json
397 from pprint import pformat
398@@ -87,15 +88,61 @@
399 @self.cl.subcommand()
400 def bar(x, y=None, *vargs):
401 "A function that does work."
402- self.bar_called = True
403- return "qux"
404-
405- args = ['foo', 'bar', 'baz']
406- self.cl.formatter = MagicMock()
407- with patch("sys.argv", args):
408- self.cl.run()
409- self.assertTrue(self.bar_called)
410- self.assertTrue(self.cl.formatter.format_output.called)
411+ self.assertEqual(x, 'baz')
412+ self.assertEqual(y, 'why')
413+ self.assertEqual(vargs, ('mux', 'zob'))
414+ self.bar_called = True
415+ return "qux"
416+
417+ args = ['chlp', 'bar', '--y', 'why', 'baz', 'mux', 'zob']
418+ self.cl.formatter = MagicMock()
419+ with patch("sys.argv", args):
420+ with patch("charmhelpers.core.unitdata._KV") as _KV:
421+ self.cl.run()
422+ assert _KV.flush.called
423+ self.assertTrue(self.bar_called)
424+ self.cl.formatter.format_output.assert_called_once_with('qux', ANY)
425+
426+ def test_no_output(self):
427+ self.bar_called = False
428+
429+ @self.cl.subcommand()
430+ @self.cl.no_output
431+ def bar(x, y=None, *vargs):
432+ "A function that does work."
433+ self.bar_called = True
434+ return "qux"
435+
436+ args = ['foo', 'bar', 'baz']
437+ self.cl.formatter = MagicMock()
438+ with patch("sys.argv", args):
439+ self.cl.run()
440+ self.assertTrue(self.bar_called)
441+ self.cl.formatter.format_output.assert_called_once_with('', ANY)
442+
443+ def test_test_command(self):
444+ self.bar_called = False
445+ self.bar_result = True
446+
447+ @self.cl.subcommand()
448+ @self.cl.test_command
449+ def bar(x, y=None, *vargs):
450+ "A function that does work."
451+ self.bar_called = True
452+ return self.bar_result
453+
454+ args = ['foo', 'bar', 'baz']
455+ self.cl.formatter = MagicMock()
456+ with patch("sys.argv", args):
457+ self.cl.run()
458+ self.assertTrue(self.bar_called)
459+ self.assertEqual(self.cl.exit_code, 0)
460+ self.cl.formatter.format_output.assert_called_once_with('', ANY)
461+
462+ self.bar_result = False
463+ with patch("sys.argv", args):
464+ self.cl.run()
465+ self.assertEqual(self.cl.exit_code, 1)
466
467
468 class OutputFormatterTest(TestCase):
469
470=== modified file 'tests/core/test_hookenv.py'
471--- tests/core/test_hookenv.py 2015-07-22 05:58:46 +0000
472+++ tests/core/test_hookenv.py 2015-07-29 16:32:12 +0000
473@@ -854,14 +854,27 @@
474 'env': 'some-environment',
475 })
476
477+ @patch('charmhelpers.core.hookenv.remote_service_name')
478+ @patch('charmhelpers.core.hookenv.relation_ids')
479 @patch('charmhelpers.core.hookenv.os')
480- def test_gets_the_relation_id(self, os_):
481+ def test_gets_the_relation_id(self, os_, relation_ids, remote_service_name):
482 os_.environ = {
483 'JUJU_RELATION_ID': 'foo',
484 }
485
486 self.assertEqual(hookenv.relation_id(), 'foo')
487
488+ relation_ids.return_value = ['r:1', 'r:2']
489+ remote_service_name.side_effect = ['other', 'service']
490+ self.assertEqual(hookenv.relation_id('rel', 'service/0'), 'r:2')
491+ relation_ids.assert_called_once_with('rel')
492+ self.assertEqual(remote_service_name.call_args_list, [
493+ call('r:1'),
494+ call('r:2'),
495+ ])
496+ remote_service_name.side_effect = ['other', 'service']
497+ self.assertEqual(hookenv.relation_id('rel', 'service'), 'r:2')
498+
499 @patch('charmhelpers.core.hookenv.os')
500 def test_relation_id_none_if_no_env(self, os_):
501 os_.environ = {}
502@@ -993,6 +1006,7 @@
503 hookenv.relation_set(foo=None)
504 check_call_.assert_called_with(['relation-set', 'foo='])
505
506+ @patch('charmhelpers.core.hookenv.local_unit', MagicMock())
507 @patch('os.remove')
508 @patch('subprocess.check_output')
509 @patch('subprocess.check_call')
510@@ -1021,6 +1035,7 @@
511 self.assertEqual("{foo: bar}", f.read().strip())
512 remove.assert_called_with(temp_file)
513
514+ @patch('charmhelpers.core.hookenv.local_unit', MagicMock())
515 @patch('os.remove')
516 @patch('subprocess.check_output')
517 @patch('subprocess.check_call')
518@@ -1265,6 +1280,20 @@
519 _unit.return_value = 'mysql/3'
520 self.assertEqual(hookenv.service_name(), 'mysql')
521
522+ @patch('charmhelpers.core.hookenv.related_units')
523+ @patch('charmhelpers.core.hookenv.remote_unit')
524+ def test_gets_remote_service_name(self, remote_unit, related_units):
525+ remote_unit.return_value = 'mysql/3'
526+ related_units.return_value = ['pgsql/0', 'pgsql/1']
527+ self.assertEqual(hookenv.remote_service_name(), 'mysql')
528+ self.assertEqual(hookenv.remote_service_name('pgsql:1'), 'pgsql')
529+
530+ def test_gets_hook_name(self):
531+ with patch.dict(os.environ, JUJU_HOOK_NAME='hook'):
532+ self.assertEqual(hookenv.hook_name(), 'hook')
533+ with patch('sys.argv', ['other-hook']):
534+ self.assertEqual(hookenv.hook_name(), 'other-hook')
535+
536 @patch('subprocess.check_output')
537 def test_action_get_with_key(self, check_output):
538 action_data = 'bar'
539@@ -1369,3 +1398,103 @@
540 self.assertTrue(hookenv.has_juju_version('1.24-beta5'))
541 self.assertTrue(hookenv.has_juju_version('1.24-beta5.1'))
542 self.assertTrue(hookenv.has_juju_version('1.18-backport6'))
543+
544+ @patch.object(hookenv, 'relation_to_role_and_interface')
545+ def test_relation_to_interface(self, rtri):
546+ rtri.return_value = (None, 'foo')
547+ self.assertEqual(hookenv.relation_to_interface('rel'), 'foo')
548+
549+ @patch.object(hookenv, 'metadata')
550+ def test_relation_to_role_and_interface(self, metadata):
551+ metadata.return_value = {
552+ 'provides': {
553+ 'pro-rel': {
554+ 'interface': 'pro-int',
555+ },
556+ 'pro-rel2': {
557+ 'interface': 'pro-int',
558+ },
559+ },
560+ 'requires': {
561+ 'req-rel': {
562+ 'interface': 'req-int',
563+ },
564+ },
565+ 'peer': {
566+ 'pee-rel': {
567+ 'interface': 'pee-int',
568+ },
569+ },
570+ }
571+ rtri = hookenv.relation_to_role_and_interface
572+ self.assertEqual(rtri('pro-rel'), ('provides', 'pro-int'))
573+ self.assertEqual(rtri('req-rel'), ('requires', 'req-int'))
574+ self.assertEqual(rtri('pee-rel'), ('peer', 'pee-int'))
575+
576+ @patch.object(hookenv, 'metadata')
577+ def test_role_and_interface_to_relations(self, metadata):
578+ metadata.return_value = {
579+ 'provides': {
580+ 'pro-rel': {
581+ 'interface': 'pro-int',
582+ },
583+ 'pro-rel2': {
584+ 'interface': 'pro-int',
585+ },
586+ },
587+ 'requires': {
588+ 'req-rel': {
589+ 'interface': 'int',
590+ },
591+ },
592+ 'peer': {
593+ 'pee-rel': {
594+ 'interface': 'int',
595+ },
596+ },
597+ }
598+ ritr = hookenv.role_and_interface_to_relations
599+ assertItemsEqual = getattr(self, 'assertItemsEqual', getattr(self, 'assertCountEqual', None))
600+ assertItemsEqual(ritr('provides', 'pro-int'), ['pro-rel', 'pro-rel2'])
601+ assertItemsEqual(ritr('requires', 'int'), ['req-rel'])
602+ assertItemsEqual(ritr('peer', 'int'), ['pee-rel'])
603+
604+ @patch.object(hookenv, 'metadata')
605+ def test_interface_to_relations(self, metadata):
606+ metadata.return_value = {
607+ 'provides': {
608+ 'pro-rel': {
609+ 'interface': 'pro-int',
610+ },
611+ 'pro-rel2': {
612+ 'interface': 'pro-int',
613+ },
614+ },
615+ 'requires': {
616+ 'req-rel': {
617+ 'interface': 'req-int',
618+ },
619+ },
620+ 'peer': {
621+ 'pee-rel': {
622+ 'interface': 'pee-int',
623+ },
624+ },
625+ }
626+ itr = hookenv.interface_to_relations
627+ assertItemsEqual = getattr(self, 'assertItemsEqual', getattr(self, 'assertCountEqual', None))
628+ assertItemsEqual(itr('pro-int'), ['pro-rel', 'pro-rel2'])
629+ assertItemsEqual(itr('req-int'), ['req-rel'])
630+ assertItemsEqual(itr('pee-int'), ['pee-rel'])
631+
632+ def test_action_name(self):
633+ with patch.dict('os.environ', JUJU_ACTION_NAME='action-jack'):
634+ self.assertEqual(hookenv.action_name(), 'action-jack')
635+
636+ def test_action_uuid(self):
637+ with patch.dict('os.environ', JUJU_ACTION_UUID='action-jack'):
638+ self.assertEqual(hookenv.action_uuid(), 'action-jack')
639+
640+ def test_action_tag(self):
641+ with patch.dict('os.environ', JUJU_ACTION_TAG='action-jack'):
642+ self.assertEqual(hookenv.action_tag(), 'action-jack')

Subscribers

People subscribed via source and target branches