Merge lp:~tvansteenburgh/charm-helpers/config-implicit-save into lp:charm-helpers

Proposed by Tim Van Steenburgh
Status: Merged
Merged at revision: 200
Proposed branch: lp:~tvansteenburgh/charm-helpers/config-implicit-save
Merge into: lp:charm-helpers
Diff against target: 278 lines (+87/-27)
6 files modified
charmhelpers/core/hookenv.py (+28/-15)
charmhelpers/core/services/base.py (+3/-0)
docs/examples/config.rst (+0/-8)
tests/contrib/ansible/test_ansible.py (+5/-2)
tests/core/test_hookenv.py (+31/-0)
tests/core/test_services.py (+20/-2)
To merge this branch: bzr merge lp:~tvansteenburgh/charm-helpers/config-implicit-save
Reviewer Review Type Date Requested Status
Stuart Bishop (community) Approve
Review via email: mp+224678@code.launchpad.net

Description of the change

Make Config implicitly save itself instead of requiring charm author to config.save() in every hook.

To post a comment you must log in.
Revision history for this message
Stuart Bishop (stub) wrote :

I don't think using the destructor is the right thing to do. It will cause the changes to be persisted even if the hook fails.

The sanest place I can think of to save the config only on successful termination is in the @hooks.hook decorator.

Revision history for this message
Tim Van Steenburgh (tvansteenburgh) wrote :

> I don't think using the destructor is the right thing to do. It will cause the
> changes to be persisted even if the hook fails.
>
> The sanest place I can think of to save the config only on successful
> termination is in the @hooks.hook decorator.

Good observation. Doing the save in the @hooks.hook will be difficult since there is not a global Config object.

Can you think of a downside of doing this instead:

```
def __del__(self):
    if self.implicit_save and not sys.exc_type:
        self.save()
```

Revision history for this message
Stuart Bishop (stub) wrote :

There actually *is* a global config object, because the config() method is using the @cached decorator. This could even be made explicit.

I didn't know you could check for sys.exc_type to catch this case (I imagine you would need to ignore SystemExit?). In any case, I don't think it is a good idea here; I have hooks that catch exceptions, log nice error messages, and sys.exit(1).

Revision history for this message
Stuart Bishop (stub) wrote :

... although if you *can* sniff for sys.exc_* reliably, and see a SystemExit, it may be good enough to save only if a) no exception b) its a SystemExit with a 0 return code

Revision history for this message
Tim Van Steenburgh (tvansteenburgh) wrote :

One other idea: do the save in Config.__setitem__()

I like this for two reasons:

1. No sys.exc_type voodoo
2. Keeps code local to the Config object, no coupling to or dependency on @hooks.hook

The downsides are:

1. May save() multiple times. Performance hit for writing file multiple times.
2. save()s are immediate, no rollback if exception occurs.

I think #1 is minor, and #2 is actually consistent with the way many charms have handled persisting config thus far - by writing dot files.

I think the pros outweigh the cons but would appreciate other opinions.

Revision history for this message
Tim Van Steenburgh (tvansteenburgh) wrote :

In retrospect I guess this is no different than the original idea of doing the save in the destructor. :/

175. By Tim Van Steenburgh

Merge from trunk

176. By Tim Van Steenburgh

Change how Config is implicitly saved

Call Config.save() automatically after hook execution if the hook
code is running in the context of a @hook decorator or the
Services Framework.

177. By Tim Van Steenburgh

Merge from lp:charm-helpers

178. By Tim Van Steenburgh

Remove no-op tests

Revision history for this message
Tim Van Steenburgh (tvansteenburgh) wrote :

After discussion with stub and cory_fu, the following changes were made:

* Don't implicitly save in __del__ since that will fire even after exceptions occur.

Instead:

* Save in Hooks.execute(), if the hook code is running in the context of a @hook decorated function, or
* Save in ServiceManager.manage(), if hook code is running in the context of the Services Framework

In other scenarios, client code would need to call Config.save() explicitly. Implicit saves can still be disabled by setting `Config.implicit_save = False`.

The important changes are on r176. The other commits are just merges from trunk and cleanup.

Revision history for this message
Stuart Bishop (stub) wrote :

All looks good to me. Thanks for this - it will let me drop a chunk of code in the PostgreSQL charm I've been wanting to drop.

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 2014-08-19 14:04:37 +0000
+++ charmhelpers/core/hookenv.py 2014-08-26 15:16:14 +0000
@@ -156,12 +156,15 @@
156156
157157
158class Config(dict):158class Config(dict):
159 """A Juju charm config dictionary that can write itself to159 """A dictionary representation of the charm's config.yaml, with some
160 disk (as json) and track which values have changed since160 extra features:
161 the previous hook invocation.161
162162 - See which values in the dictionary have changed since the previous hook.
163 Do not instantiate this object directly - instead call163 - For values that have changed, see what the previous value was.
164 ``hookenv.config()``164 - Store arbitrary data for use in a later hook.
165
166 NOTE: Do not instantiate this object directly - instead call
167 ``hookenv.config()``, which will return an instance of :class:`Config`.
165168
166 Example usage::169 Example usage::
167170
@@ -170,8 +173,8 @@
170 >>> config = hookenv.config()173 >>> config = hookenv.config()
171 >>> config['foo']174 >>> config['foo']
172 'bar'175 'bar'
176 >>> # store a new key/value for later use
173 >>> config['mykey'] = 'myval'177 >>> config['mykey'] = 'myval'
174 >>> config.save()
175178
176179
177 >>> # user runs `juju set mycharm foo=baz`180 >>> # user runs `juju set mycharm foo=baz`
@@ -188,22 +191,23 @@
188 >>> # keys/values that we add are preserved across hooks191 >>> # keys/values that we add are preserved across hooks
189 >>> config['mykey']192 >>> config['mykey']
190 'myval'193 'myval'
191 >>> # don't forget to save at the end of hook!
192 >>> config.save()
193194
194 """195 """
195 CONFIG_FILE_NAME = '.juju-persistent-config'196 CONFIG_FILE_NAME = '.juju-persistent-config'
196197
197 def __init__(self, *args, **kw):198 def __init__(self, *args, **kw):
198 super(Config, self).__init__(*args, **kw)199 super(Config, self).__init__(*args, **kw)
200 self.implicit_save = True
199 self._prev_dict = None201 self._prev_dict = None
200 self.path = os.path.join(charm_dir(), Config.CONFIG_FILE_NAME)202 self.path = os.path.join(charm_dir(), Config.CONFIG_FILE_NAME)
201 if os.path.exists(self.path):203 if os.path.exists(self.path):
202 self.load_previous()204 self.load_previous()
203205
204 def load_previous(self, path=None):206 def load_previous(self, path=None):
205 """Load previous copy of config from disk so that current values207 """Load previous copy of config from disk.
206 can be compared to previous values.208
209 In normal usage you don't need to call this method directly - it
210 is called automatically at object initialization.
207211
208 :param path:212 :param path:
209213
@@ -218,8 +222,8 @@
218 self._prev_dict = json.load(f)222 self._prev_dict = json.load(f)
219223
220 def changed(self, key):224 def changed(self, key):
221 """Return true if the value for this key has changed since225 """Return True if the current value for this key is different from
222 the last save.226 the previous value.
223227
224 """228 """
225 if self._prev_dict is None:229 if self._prev_dict is None:
@@ -228,7 +232,7 @@
228232
229 def previous(self, key):233 def previous(self, key):
230 """Return previous value for this key, or None if there234 """Return previous value for this key, or None if there
231 is no "previous" value.235 is no previous value.
232236
233 """237 """
234 if self._prev_dict:238 if self._prev_dict:
@@ -238,7 +242,13 @@
238 def save(self):242 def save(self):
239 """Save this config to disk.243 """Save this config to disk.
240244
241 Preserves items in _prev_dict that do not exist in self.245 If the charm is using the :mod:`Services Framework <services.base>`
246 or :meth:'@hook <Hooks.hook>' decorator, this
247 is called automatically at the end of successful hook execution.
248 Otherwise, it should be called directly by user code.
249
250 To disable automatic saves, set ``implicit_save=False`` on this
251 instance.
242252
243 """253 """
244 if self._prev_dict:254 if self._prev_dict:
@@ -478,6 +488,9 @@
478 hook_name = os.path.basename(args[0])488 hook_name = os.path.basename(args[0])
479 if hook_name in self._hooks:489 if hook_name in self._hooks:
480 self._hooks[hook_name]()490 self._hooks[hook_name]()
491 cfg = config()
492 if cfg.implicit_save:
493 cfg.save()
481 else:494 else:
482 raise UnregisteredHookError(hook_name)495 raise UnregisteredHookError(hook_name)
483496
484497
=== modified file 'charmhelpers/core/services/base.py'
--- charmhelpers/core/services/base.py 2014-08-18 17:21:31 +0000
+++ charmhelpers/core/services/base.py 2014-08-26 15:16:14 +0000
@@ -118,6 +118,9 @@
118 else:118 else:
119 self.provide_data()119 self.provide_data()
120 self.reconfigure_services()120 self.reconfigure_services()
121 cfg = hookenv.config()
122 if cfg.implicit_save:
123 cfg.save()
121124
122 def provide_data(self):125 def provide_data(self):
123 """126 """
124127
=== modified file 'docs/examples/config.rst'
--- docs/examples/config.rst 2014-06-12 17:55:56 +0000
+++ docs/examples/config.rst 2014-08-26 15:16:14 +0000
@@ -38,8 +38,6 @@
3838
39 assert config['app-name'] == 'My App'39 assert config['app-name'] == 'My App'
4040
41 config.save()
42
43Checking if a config value has changed41Checking if a config value has changed
44--------------------------------------42--------------------------------------
4543
@@ -64,8 +62,6 @@
64 assert config['app-name'] == 'My New App'62 assert config['app-name'] == 'My New App'
65 assert config.previous('app-name') == 'My App'63 assert config.previous('app-name') == 'My App'
6664
67 config.save()
68
69Saving arbitrary key/value data65Saving arbitrary key/value data
70-------------------------------66-------------------------------
7167
@@ -85,12 +81,8 @@
8581
86 config['mykey'] = 'myval'82 config['mykey'] = 'myval'
8783
88 config.save()
89
90 @hooks.hook('config-changed')84 @hooks.hook('config-changed')
91 def config_changed():85 def config_changed():
92 config = hookenv.config()86 config = hookenv.config()
9387
94 assert config['mykey'] == 'myval'88 assert config['mykey'] == 'myval'
95
96 config.save()
9789
=== modified file 'tests/contrib/ansible/test_ansible.py'
--- tests/contrib/ansible/test_ansible.py 2014-07-30 13:30:43 +0000
+++ tests/contrib/ansible/test_ansible.py 2014-08-26 15:16:14 +0000
@@ -11,6 +11,7 @@
1111
1212
13import charmhelpers.contrib.ansible13import charmhelpers.contrib.ansible
14from charmhelpers.core import hookenv
1415
1516
16class InstallAnsibleSupportTestCase(unittest.TestCase):17class InstallAnsibleSupportTestCase(unittest.TestCase):
@@ -185,7 +186,8 @@
185 'ansible-playbook', '-c', 'local', 'playbooks/complete-state.yaml',186 'ansible-playbook', '-c', 'local', 'playbooks/complete-state.yaml',
186 '--tags', 'install,somethingelse'])187 '--tags', 'install,somethingelse'])
187188
188 def test_hooks_executes_playbook_with_tag(self):189 @mock.patch.object(hookenv, 'config')
190 def test_hooks_executes_playbook_with_tag(self, config):
189 hooks = charmhelpers.contrib.ansible.AnsibleHooks('my/playbook.yaml')191 hooks = charmhelpers.contrib.ansible.AnsibleHooks('my/playbook.yaml')
190 foo = mock.MagicMock()192 foo = mock.MagicMock()
191 hooks.register('foo', foo)193 hooks.register('foo', foo)
@@ -197,7 +199,8 @@
197 'ansible-playbook', '-c', 'local', 'my/playbook.yaml',199 'ansible-playbook', '-c', 'local', 'my/playbook.yaml',
198 '--tags', 'foo'])200 '--tags', 'foo'])
199201
200 def test_specifying_ansible_handled_hooks(self):202 @mock.patch.object(hookenv, 'config')
203 def test_specifying_ansible_handled_hooks(self, config):
201 hooks = charmhelpers.contrib.ansible.AnsibleHooks(204 hooks = charmhelpers.contrib.ansible.AnsibleHooks(
202 'my/playbook.yaml', default_hooks=['start', 'stop'])205 'my/playbook.yaml', default_hooks=['start', 'stop'])
203206
204207
=== modified file 'tests/core/test_hookenv.py'
--- tests/core/test_hookenv.py 2014-05-06 20:42:06 +0000
+++ tests/core/test_hookenv.py 2014-08-26 15:16:14 +0000
@@ -932,6 +932,37 @@
932932
933933
934class HooksTest(TestCase):934class HooksTest(TestCase):
935 def setUp(self):
936 super(HooksTest, self).setUp()
937 self.config = patch.object(hookenv, 'config')
938 self.config.start()
939
940 def tearDown(self):
941 super(HooksTest, self).tearDown()
942 self.config.stop()
943
944 def test_config_saved_after_execute(self):
945 config = hookenv.config()
946 config.implicit_save = True
947
948 foo = MagicMock()
949 hooks = hookenv.Hooks()
950 hooks.register('foo', foo)
951 hooks.execute(['foo', 'some', 'other', 'args'])
952
953 self.assertTrue(config.save.called)
954
955 def test_config_not_saved_after_execute(self):
956 config = hookenv.config()
957 config.implicit_save = False
958
959 foo = MagicMock()
960 hooks = hookenv.Hooks()
961 hooks.register('foo', foo)
962 hooks.execute(['foo', 'some', 'other', 'args'])
963
964 self.assertFalse(config.save.called)
965
935 def test_runs_a_registered_function(self):966 def test_runs_a_registered_function(self):
936 foo = MagicMock()967 foo = MagicMock()
937 hooks = hookenv.Hooks()968 hooks = hookenv.Hooks()
938969
=== modified file 'tests/core/test_services.py'
--- tests/core/test_services.py 2014-07-10 14:43:59 +0000
+++ tests/core/test_services.py 2014-08-26 15:16:14 +0000
@@ -30,7 +30,8 @@
30 @mock.patch.object(services.ServiceManager, 'reconfigure_services')30 @mock.patch.object(services.ServiceManager, 'reconfigure_services')
31 @mock.patch.object(services.ServiceManager, 'stop_services')31 @mock.patch.object(services.ServiceManager, 'stop_services')
32 @mock.patch.object(hookenv, 'hook_name')32 @mock.patch.object(hookenv, 'hook_name')
33 def test_manage_stop(self, hook_name, stop_services, reconfigure_services):33 @mock.patch.object(hookenv, 'config')
34 def test_manage_stop(self, config, hook_name, stop_services, reconfigure_services):
34 manager = services.ServiceManager()35 manager = services.ServiceManager()
35 hook_name.return_value = 'stop'36 hook_name.return_value = 'stop'
36 manager.manage()37 manager.manage()
@@ -41,7 +42,8 @@
41 @mock.patch.object(services.ServiceManager, 'reconfigure_services')42 @mock.patch.object(services.ServiceManager, 'reconfigure_services')
42 @mock.patch.object(services.ServiceManager, 'stop_services')43 @mock.patch.object(services.ServiceManager, 'stop_services')
43 @mock.patch.object(hookenv, 'hook_name')44 @mock.patch.object(hookenv, 'hook_name')
44 def test_manage_other(self, hook_name, stop_services, reconfigure_services, provide_data):45 @mock.patch.object(hookenv, 'config')
46 def test_manage_other(self, config, hook_name, stop_services, reconfigure_services, provide_data):
45 manager = services.ServiceManager()47 manager = services.ServiceManager()
46 hook_name.return_value = 'config-changed'48 hook_name.return_value = 'config-changed'
47 manager.manage()49 manager.manage()
@@ -49,6 +51,22 @@
49 reconfigure_services.assert_called_once_with()51 reconfigure_services.assert_called_once_with()
50 provide_data.assert_called_once_with()52 provide_data.assert_called_once_with()
5153
54 @mock.patch.object(hookenv, 'config')
55 def test_manage_config_saved(self, config):
56 config = config.return_value
57 config.implicit_save = True
58 manager = services.ServiceManager()
59 manager.manage()
60 self.assertTrue(config.save.called)
61
62 @mock.patch.object(hookenv, 'config')
63 def test_manage_config_not_saved(self, config):
64 config = config.return_value
65 config.implicit_save = False
66 manager = services.ServiceManager()
67 manager.manage()
68 self.assertFalse(config.save.called)
69
52 @mock.patch.object(services.ServiceManager, 'save_ready')70 @mock.patch.object(services.ServiceManager, 'save_ready')
53 @mock.patch.object(services.ServiceManager, 'fire_event')71 @mock.patch.object(services.ServiceManager, 'fire_event')
54 @mock.patch.object(services.ServiceManager, 'is_ready')72 @mock.patch.object(services.ServiceManager, 'is_ready')

Subscribers

People subscribed via source and target branches