Merge lp:~gandelman-a/charm-helpers/charm-helpers-sync into lp:charm-helpers

Proposed by Adam Gandelman
Status: Merged
Merged at revision: 41
Proposed branch: lp:~gandelman-a/charm-helpers/charm-helpers-sync
Merge into: lp:charm-helpers
Diff against target: 493 lines (+468/-0)
4 files modified
tests/tools/test_charm_helper_sync.py (+197/-0)
tools/charm_helpers_sync/README (+89/-0)
tools/charm_helpers_sync/charm_helpers_sync.py (+168/-0)
tools/charm_helpers_sync/example-config.yaml (+14/-0)
To merge this branch: bzr merge lp:~gandelman-a/charm-helpers/charm-helpers-sync
Reviewer Review Type Date Requested Status
Matthew Wedgwood (community) Approve
Adam Gandelman (community) Needs Resubmitting
Review via email: mp+170470@code.launchpad.net

Description of the change

This includes a little utility script for syncing parts of lp:charm-helpers into a local charm. It is meant to be used by charm authors while authoring a charm that depends on parts of charm-helpers. I've been using it heavily while working on a new charm lately, my workflow has been something like:

- Add a charm-helpers-sync.yaml to the top level of my charm, describing what I need included, pulling from upstream lp:charm-helpers.

- Realize I need new functionality in charm-helpers, create my own branch at lp:~gandelman-a/charm-helpers. Use this as a working branch alongisde my WIP charm. Commit changes there, point my charm-helpers-sync.yaml to that branch, sync them in as required.

- Propose lp:~gandelman-a/charm-helpers for merging to lp:charm-helpers. After its merged, update my charm-helpers-sync.yaml to point back to upstream. Re-sync, pulling in my changes and whatever else may have landed upstream.

It's worked well for me so far and really helps prevent me from having a heavily modified fork of charm-helpers embedded in my charm.

To post a comment you must log in.
Revision history for this message
Matthew Wedgwood (mew) wrote :

Adam, this looks great, thanks for submitting it.

I'm a little unclear - is this intended for use within a charm or externally during development? If it's the former, I think we'd want to discourage folks from installing charm-helpers onto their units, especially with the amount of development on it. In either case, could you update the README to be a little more explicit about the use case?

The one other thing I'd love to see is tests. As it's outside the core package, I'd much rather get this into the tree now so I think it's OK to merge if you don't have the time for them.

Thanks!

review: Needs Information
33. By Adam Gandelman

Add tests, rename to charm_helpers_sync and make importable.

34. By Adam Gandelman

Update README with more a descriptive use case.

Revision history for this message
Adam Gandelman (gandelman-a) wrote :

Hey Matthew-

This is intended to be used by charm authors during their offline development, as a way of embeding a specific revision of a charm helpers branch into their charm. I've updated the README accordingly and added some tests. Needed to rename the script and its home to get it importable during tests.

Thanks.

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

Thanks for the additions. +1

review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== added directory 'tests/tools'
2=== added file 'tests/tools/__init__.py'
3=== added file 'tests/tools/test_charm_helper_sync.py'
4--- tests/tools/test_charm_helper_sync.py 1970-01-01 00:00:00 +0000
5+++ tests/tools/test_charm_helper_sync.py 2013-06-27 22:23:25 +0000
6@@ -0,0 +1,197 @@
7+import unittest
8+from mock import call, patch
9+import yaml
10+
11+import tools.charm_helpers_sync.charm_helpers_sync as sync
12+
13+INCLUDE = """
14+include:
15+ - core
16+ - contrib.openstack
17+ - contrib.storage
18+ - contrib.hahelpers:
19+ - utils
20+ - ceph_utils
21+ - cluster_utils
22+ - haproxy_utils
23+"""
24+
25+
26+class HelperSyncTests(unittest.TestCase):
27+ def test_clone_helpers(self):
28+ '''It properly branches the correct helpers branch'''
29+ with patch('subprocess.check_call') as check_call:
30+ sync.clone_helpers(work_dir='/tmp/foo', branch='lp:charm-helpers')
31+ check_call.assert_called_with(['bzr', 'branch',
32+ 'lp:charm-helpers',
33+ '/tmp/foo/charm-helpers'])
34+
35+ def test_module_path(self):
36+ '''It converts a python module path to a filesystem path'''
37+ self.assertEquals(sync._module_path('some.test.module'),
38+ 'some/test/module')
39+
40+ def test_src_path(self):
41+ '''It renders the correct path to module within charm-helpers tree'''
42+ path = sync._src_path(src='/tmp/charm-helpers',
43+ module='contrib.openstack')
44+ self.assertEquals('/tmp/charm-helpers/charmhelpers/contrib/openstack',
45+ path)
46+
47+ def test_dest_path(self):
48+ '''It correctly finds the correct install path within a charm'''
49+ path = sync._dest_path(dest='/tmp/mycharm/hooks/charmhelpers',
50+ module='contrib.openstack')
51+ self.assertEquals('/tmp/mycharm/hooks/charmhelpers/contrib/openstack',
52+ path)
53+
54+ @patch('__builtin__.open')
55+ @patch('os.path.exists')
56+ @patch('os.walk')
57+ def test_ensure_init(self, walk, exists, _open):
58+ '''It ensures all subdirectories of a parent are python importable'''
59+ # os walk
60+ # os.path.join
61+ # os.path.exists
62+ # open
63+ def _walk(path):
64+ yield ('/tmp/hooks/', ['helpers'], [])
65+ yield ('/tmp/hooks/helpers', ['foo'], [])
66+ yield ('/tmp/hooks/helpers/foo', [], [])
67+ walk.side_effect = _walk
68+ exists.return_value = False
69+ sync.ensure_init('hooks/helpers/foo/')
70+ ex = [call('/tmp/hooks/__init__.py', 'wb'),
71+ call('/tmp/hooks/helpers/__init__.py', 'wb'),
72+ call('/tmp/hooks/helpers/foo/__init__.py', 'wb')]
73+ for c in ex:
74+ self.assertIn(c, _open.call_args_list)
75+
76+ @patch('tools.charm_helpers_sync.charm_helpers_sync.ensure_init')
77+ @patch('os.path.isfile')
78+ @patch('shutil.copy')
79+ @patch('os.makedirs')
80+ @patch('os.path.exists')
81+ def test_sync_pyfile(self, exists, mkdirs, copy, isfile, ensure_init):
82+ '''It correctly syncs a py src file from src to dest'''
83+ exists.return_value = False
84+ isfile.return_value = True
85+ sync.sync_pyfile('/tmp/charm-helpers/core/host',
86+ 'hooks/charmhelpers/core')
87+ mkdirs.assert_called_with('hooks/charmhelpers/core')
88+ copy_f = call('/tmp/charm-helpers/core/host.py',
89+ 'hooks/charmhelpers/core')
90+ copy_i = call('/tmp/charm-helpers/core/__init__.py',
91+ 'hooks/charmhelpers/core')
92+ self.assertIn(copy_f, copy.call_args_list)
93+ self.assertIn(copy_i, copy.call_args_list)
94+ ensure_init.assert_called_with('hooks/charmhelpers/core')
95+
96+ @patch('os.path.isdir')
97+ @patch('os.path.isfile')
98+ def test_filter_dir(self, isfile, isdir):
99+ '''It filters non-python files and non-module dirs from sync source'''
100+ files = {
101+ 'bad_file.bin': 'f',
102+ 'some_dir': 'd',
103+ 'good_helper.py': 'f',
104+ 'good_helper2.py': 'f',
105+ 'good_helper3.py': 'f',
106+ 'bad_file.img': 'f',
107+ }
108+
109+ def _isfile(f):
110+ try:
111+ return files[f.split('/').pop()] == 'f'
112+ except KeyError:
113+ return False
114+
115+ def _isdir(f):
116+ try:
117+ return files[f.split('/').pop()] == 'd'
118+ except KeyError:
119+ return False
120+
121+ isfile.side_effect = _isfile
122+ isdir.side_effect = _isdir
123+ result = sync._filter(dir='/tmp/charm-helpers/core',
124+ ls=files.iterkeys())
125+ ex = ['bad_file.bin', 'bad_file.img', 'some_dir']
126+ self.assertEquals(ex, result)
127+
128+ @patch('tools.charm_helpers_sync.charm_helpers_sync._filter')
129+ @patch('tools.charm_helpers_sync.charm_helpers_sync.ensure_init')
130+ @patch('shutil.copytree')
131+ @patch('shutil.rmtree')
132+ @patch('os.path.exists')
133+ def test_sync_directory(self, exists, rmtree, copytree, ensure_init,
134+ _filter):
135+ '''It correctly syncs src directory to dest directory'''
136+ sync.sync_directory('/tmp/charm-helpers/charmhelpers/core',
137+ 'hooks/charmhelpers/core')
138+ exists.return_value = True
139+ rmtree.assert_called_with('hooks/charmhelpers/core')
140+ copytree.assert_called_with('/tmp/charm-helpers/charmhelpers/core',
141+ 'hooks/charmhelpers/core', ignore=_filter)
142+ ensure_init.assert_called_with('hooks/charmhelpers/core')
143+
144+ @patch('os.path.isfile')
145+ def test_is_pyfile(self, isfile):
146+ '''It correctly identifies incomplete path to a py src file as such'''
147+ sync._is_pyfile('/tmp/charm-helpers/charmhelpers/core/host')
148+ isfile.assert_called_with(
149+ '/tmp/charm-helpers/charmhelpers/core/host.py'
150+ )
151+
152+ @patch('tools.charm_helpers_sync.charm_helpers_sync.sync_directory')
153+ @patch('os.path.isdir')
154+ def test_syncs_directory(self, is_dir, sync_dir):
155+ '''It correctly syncs a module directory'''
156+ is_dir.return_value = True
157+ sync.sync(src='/tmp/charm-helpers',
158+ dest='hooks/charmhelpers',
159+ module='contrib.openstack')
160+
161+ sync_dir.assert_called_with(
162+ '/tmp/charm-helpers/charmhelpers/contrib/openstack',
163+ 'hooks/charmhelpers/contrib/openstack')
164+
165+ @patch('tools.charm_helpers_sync.charm_helpers_sync.sync_pyfile')
166+ @patch('tools.charm_helpers_sync.charm_helpers_sync._is_pyfile')
167+ @patch('os.path.isdir')
168+ def test_syncs_file(self, is_dir, is_pyfile, sync_pyfile):
169+ '''It correctly syncs a module file'''
170+ is_dir.return_value = False
171+ is_pyfile.return_value = True
172+ sync.sync(src='/tmp/charm-helpers',
173+ dest='hooks/charmhelpers',
174+ module='contrib.openstack.utils')
175+
176+ sync_pyfile.assert_called_with(
177+ '/tmp/charm-helpers/charmhelpers/contrib/openstack/utils',
178+ 'hooks/charmhelpers/contrib/openstack')
179+
180+ @patch('tools.charm_helpers_sync.charm_helpers_sync.sync')
181+ @patch('os.path.isdir')
182+ def test_sync_helpers_from_config(self, isdir, _sync):
183+ '''It correctly syncs a list of included helpers'''
184+ include = yaml.load(INCLUDE)['include']
185+ isdir.return_value = True
186+ sync.sync_helpers(include=include,
187+ src='/tmp/charm-helpers',
188+
189+ dest='hooks/charmhelpers')
190+ mods = [
191+ 'core',
192+ 'contrib.openstack',
193+ 'contrib.storage',
194+ 'contrib.hahelpers.utils',
195+ 'contrib.hahelpers.ceph_utils',
196+ 'contrib.hahelpers.cluster_utils',
197+ 'contrib.hahelpers.haproxy_utils'
198+ ]
199+
200+ ex_calls = []
201+ [ex_calls.append(call('/tmp/charm-helpers', 'hooks/charmhelpers', c))
202+ for c in mods]
203+ self.assertEquals(ex_calls, _sync.call_args_list)
204
205=== added directory 'tools'
206=== added file 'tools/__init__.py'
207=== added directory 'tools/charm_helpers_sync'
208=== added file 'tools/charm_helpers_sync/README'
209--- tools/charm_helpers_sync/README 1970-01-01 00:00:00 +0000
210+++ tools/charm_helpers_sync/README 2013-06-27 22:23:25 +0000
211@@ -0,0 +1,89 @@
212+Script for synchronizing charm-helpers into a charm branch.
213+
214+This script is intended to be used by charm authors during the development
215+of their charm. It allows authors to pull in bits of a charm-helpers source
216+tree and embed directly into their charm, to be deployed with the rest of
217+their hooks and charm payload. This script is not intended to be called
218+by the hooks themselves, but instead by the charm author while they are
219+hacking on a charm offline. Consider it a method of compiling specific
220+revision of a charm-helpers branch into a given charm source tree.
221+
222+Some goals and benefits to using a sync tool to manage this process:
223+
224+ - Reduces the burden of manually copying in upstream charm helpers code
225+ into a charm and helps ensure we can easily keep a specific charm's
226+ helper code up to date.
227+
228+ - Allows authors to hack on their own working branch of charm-helpers,
229+ easily sync into their WIP charm. Any changes they've made to charm
230+ helpers can be upstreamed via a merge of their charm-helpers branch
231+ into lp:charm-helpers, ideally at the same time they are upstreaming
232+ the charm itself into the charm store. Separating charm helper
233+ development from charm development can help reduce cases where charms
234+ are shipping locally modified helpers.
235+
236+ - Avoids the need to ship the *entire* lp:charm-helpers source tree with
237+ a charm. Authors can selectively pick and choose what subset of helpers
238+ to include to satisfy the goals of their charm.
239+
240+Loosely based on OpenStack's oslo-incubator:
241+
242+ https://github.com/openstack/oslo-incubator.git
243+
244+Allows specifying a list of dependencies to sync in from a charm-helpers
245+branch. Ideally, each charm should describe its requirements in a yaml
246+config included in the charm, eg charm-helpers.yaml (NOTE: Example module
247+layout as of 05/30/2013):
248+
249+ $ cd my-charm
250+ $ cat >charm-helpers.yaml <<END
251+ destination: hooks/helpers
252+ branch: lp:charm-helpers
253+ include:
254+ - core
255+ - contrib.openstack
256+ - contrib.hahelpers:
257+ - ceph_utils
258+ END
259+
260+includes may be defined as entire module sub-directories, or as invidual
261+.py files with in a module sub-directory.
262+
263+Charm author can then sync in and update helpers as needed. The following
264+import all of charmhelpers.core + charmhelpers.contrib.openstack, and only
265+ceph_utils.py from charmhelpers.contrib.hahelpers:
266+
267+ $ charm-helper-sync -c charm-helpers.yaml
268+ $ find hooks/helpers/
269+ hooks/helpers/
270+ hooks/helpers/contrib
271+ hooks/helpers/contrib/openstack
272+ hooks/helpers/contrib/openstack/openstack_utils.py
273+ hooks/helpers/contrib/openstack/__init__.py
274+ hooks/helpers/contrib/hahelpers
275+ hooks/helpers/contrib/hahelpers/ceph_utils.py
276+ hooks/helpers/contrib/hahelpers/__init__.py
277+ hooks/helpers/contrib/__init__.py
278+ hooks/helpers/core
279+ hooks/helpers/core/hookenv.py
280+ hooks/helpers/core/host.py
281+ hooks/helpers/core/__init__.py
282+ hooks/helpers/__init__.py
283+
284+
285+Script will create missing __init__.py's to ensure each subdirectory is
286+importable, assuming the script is run from the charm's top-level directory.
287+
288+You may also override configured destination directory and source bzr
289+branch:
290+
291+ $ charm-helper-sync -b ~/src/bzr/charm-helpers-dev \
292+ -d hooks/helpers-test \
293+ -c charm-helpers.yaml
294+
295+Or not use a config file at all:
296+ $ charm-helper-sync -b lp:~gandelman-a/charm-helpers/fixes \
297+ -d hooks/helpers core contrib.openstack contrib.hahelpers
298+
299+Script will create missing __init__.py's to ensure each subdirectory is
300+importable, assuming the script is run from the charm's top-level directory.
301
302=== added file 'tools/charm_helpers_sync/__init__.py'
303=== added file 'tools/charm_helpers_sync/charm_helpers_sync.py'
304--- tools/charm_helpers_sync/charm_helpers_sync.py 1970-01-01 00:00:00 +0000
305+++ tools/charm_helpers_sync/charm_helpers_sync.py 2013-06-27 22:23:25 +0000
306@@ -0,0 +1,168 @@
307+#!/usr/bin/python
308+#
309+# Copyright 2013 Canonical Ltd.
310+
311+# Authors:
312+# Adam Gandelman <adamg@ubuntu.com>
313+#
314+
315+import logging
316+import optparse
317+import os
318+import subprocess
319+import shutil
320+import sys
321+import tempfile
322+import yaml
323+
324+CHARM_HELPERS_BRANCH = 'lp:charm-helpers'
325+
326+def parse_config(conf_file):
327+ if not os.path.isfile(conf_file):
328+ logging.error('Invalid config file: %s.' % conf_file)
329+ return False
330+ return yaml.load(open(conf_file).read())
331+
332+def clone_helpers(work_dir, branch):
333+ dest = os.path.join(work_dir, 'charm-helpers')
334+ logging.info('Checking out %s to %s.' % (branch, dest))
335+ cmd = ['bzr', 'branch', branch, dest]
336+ subprocess.check_call(cmd)
337+ return dest
338+
339+def _filter(dir, ls):
340+ _filter = []
341+ for f in ls:
342+ _f = os.path.join(dir, f)
343+ if (os.path.isfile(_f) and not _f.endswith('.py')):
344+ logging.debug('Not syncing files: %s' % f)
345+ _filter.append(f)
346+ elif (os.path.isdir(_f) and not
347+ os.path.isfile(os.path.join(_f, '__init__.py'))):
348+ logging.debug('Not syncing directory: %s' % f)
349+ _filter.append(f)
350+ return _filter
351+
352+def _module_path(module):
353+ return os.path.join(*module.split('.'))
354+
355+def _src_path(src, module):
356+ return os.path.join(src, 'charmhelpers', _module_path(module))
357+
358+def _dest_path(dest, module):
359+ return os.path.join(dest, _module_path(module))
360+
361+def _is_pyfile(path):
362+ return os.path.isfile(path + '.py')
363+
364+def ensure_init(path):
365+ '''
366+ ensure directories leading up to path are importable, omitting
367+ parent directory, eg path='/hooks/helpers/foo'/:
368+ hooks/
369+ hooks/helpers/__init__.py
370+ hooks/helpers/foo/__init__.py
371+ '''
372+ for d, dirs, files in os.walk(os.path.join(*path.split('/')[:2])):
373+ _i = os.path.join(d, '__init__.py')
374+ if not os.path.exists(_i):
375+ logging.info('Adding missing __init__.py: %s' % _i)
376+ open(_i, 'wb').close()
377+
378+def sync_pyfile(src, dest):
379+ src = src + '.py'
380+ src_dir = os.path.dirname(src)
381+ logging.info('Syncing pyfile: %s -> %s.' % (src, dest))
382+ if not os.path.exists(dest):
383+ os.makedirs(dest)
384+ shutil.copy(src, dest)
385+ if os.path.isfile(os.path.join(src_dir, '__init__.py')):
386+ shutil.copy(os.path.join(src_dir, '__init__.py'),
387+ dest)
388+ ensure_init(dest)
389+
390+def sync_directory(src, dest):
391+ if os.path.exists(dest):
392+ logging.debug('Removing existing directory: %s' % dest)
393+ shutil.rmtree(dest)
394+ logging.info('Syncing directory: %s -> %s.' % (src, dest))
395+ shutil.copytree(src, dest, ignore=_filter)
396+ ensure_init(dest)
397+
398+def sync(src, dest, module):
399+ if os.path.isdir(_src_path(src, module)):
400+ sync_directory(_src_path(src, module), _dest_path(dest, module))
401+ elif _is_pyfile(_src_path(src, module)):
402+ sync_pyfile(_src_path(src, module),
403+ os.path.dirname(_dest_path(dest, module)))
404+ else:
405+ logging.warn('Could not sync: %s. Neither a pyfile or directory, '
406+ 'does it even exist?' % module)
407+
408+def sync_helpers(include, src, dest):
409+ if not os.path.isdir(dest):
410+ os.mkdir(dest)
411+
412+ for inc in include:
413+ if isinstance(inc, str):
414+ sync(src, dest, inc)
415+ elif isinstance(inc, dict):
416+ # could also do nested dicts here.
417+ for k, v in inc.iteritems():
418+ if isinstance(v, list):
419+ [sync(src, dest, '%s.%s' % (k, m)) for m in v]
420+
421+if __name__ == '__main__':
422+ parser = optparse.OptionParser()
423+ parser.add_option('-c', '--config', action='store', dest='config',
424+ default=None, help='helper config file')
425+ parser.add_option('-D', '--debug', action='store_true', dest='debug',
426+ default=False, help='debug')
427+ parser.add_option('-b', '--branch', action='store', dest='branch',
428+ help='charm-helpers bzr branch (overrides config)')
429+ parser.add_option('-d', '--destination', action='store', dest='dest_dir',
430+ help='sync destination dir (overrides config)')
431+ (opts, args) = parser.parse_args()
432+
433+ if opts.debug:
434+ logging.basicConfig(level=logging.DEBUG)
435+ else:
436+ logging.basicConfig(level=logging.INFO)
437+
438+ if opts.config:
439+ logging.info('Loading charm helper config from %s.' % opts.config)
440+ config = parse_config(opts.config)
441+ if not config:
442+ logging.error('Could not parse config from %s.' % opts.config)
443+ sys.exit(1)
444+ else:
445+ config = {}
446+
447+ if 'branch' not in config:
448+ config['branch'] = CHARM_HELPERS_BRANCH
449+ if opts.branch:
450+ config['branch'] = opts.branch
451+ if opts.dest_dir:
452+ config['destination'] = opts.dest_dir
453+
454+ if 'destination' not in config:
455+ logging.error('No destination dir. specified as option or config.')
456+ sys.exit(1)
457+
458+ if 'include' not in config:
459+ if not args:
460+ logging.error('No modules to sync specified as option or config.')
461+ sys.exit(1)
462+ config['include'] = []
463+ [config['include'].append(a) for a in args]
464+
465+ tmpd = tempfile.mkdtemp()
466+ try:
467+ checkout = clone_helpers(tmpd, config['branch'])
468+ sync_helpers(config['include'], checkout, config['destination'])
469+ except Exception, e:
470+ logging.error("Could not sync: %s" % e)
471+ raise e
472+ finally:
473+ logging.debug('Cleaning up %s' % tmpd)
474+ shutil.rmtree(tmpd)
475
476=== added file 'tools/charm_helpers_sync/example-config.yaml'
477--- tools/charm_helpers_sync/example-config.yaml 1970-01-01 00:00:00 +0000
478+++ tools/charm_helpers_sync/example-config.yaml 2013-06-27 22:23:25 +0000
479@@ -0,0 +1,14 @@
480+# Import from remote bzr branch.
481+branch: lp:charm-helpers
482+# install helpers to ./hooks/charmhelpers
483+destination: hooks/charmhelpers
484+include:
485+ # include all of charmhelpers.core
486+ - core
487+ # all of charmhelpers.payload
488+ - payload
489+ # and a subset of charmhelpers.contrib.hahelpers
490+ - contrib.hahelpers:
491+ - openstack_common
492+ - ceph_utils
493+ - utils

Subscribers

People subscribed via source and target branches