~bloodearnest/charms/precise/squid-reverseproxy/trunk

« back to all changes in this revision

Viewing changes to hooks/shelltoolbox/__init__.py

  • Committer: Marco Ceppi
  • Date: 2013-11-07 01:40:09 UTC
  • mfrom: (28.1.85 master)
  • Revision ID: marco@ceppi.net-20131107014009-lqqg63wkyt6ot2ou
[sidnei] Greatly improve test coverage
[sidnei] Allow the use of an X-Balancer-Name header to select which cache_peer backend will be used for a specific request.
[sidnei] Support 'all-services' being set in the relation, in the way that the haproxy sets it, in addition to the previously supported 'sitenames' setting. Makes it compatible with the haproxy charm.
[sidnei] When the list of supported 'sitenames' (computed from dstdomain acls) changes, notify services related via the 'cached-website' relation. This allows to add new services in the haproxy service (or really, any other service related), which notifies the squid service, which then bubbles up to services related via cached-website.

Show diffs side-by-side

added added

removed removed

Lines of Context:
1
 
# Copyright 2012 Canonical Ltd.
2
 
 
3
 
# This file is part of python-shell-toolbox.
4
 
#
5
 
# python-shell-toolbox is free software: you can redistribute it and/or modify
6
 
# it under the terms of the GNU General Public License as published by the
7
 
# Free Software Foundation, version 3 of the License.
8
 
#
9
 
# python-shell-toolbox is distributed in the hope that it will be useful, but
10
 
# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
11
 
# or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
12
 
# more details.
13
 
#
14
 
# You should have received a copy of the GNU General Public License
15
 
# along with python-shell-toolbox. If not, see <http://www.gnu.org/licenses/>.
16
 
 
17
 
"""Helper functions for accessing shell commands in Python."""
18
 
 
19
 
__metaclass__ = type
20
 
__all__ = [
21
 
    'apt_get_install',
22
 
    'bzr_whois',
23
 
    'cd',
24
 
    'command',
25
 
    'DictDiffer',
26
 
    'environ',
27
 
    'file_append',
28
 
    'file_prepend',
29
 
    'generate_ssh_keys',
30
 
    'get_su_command',
31
 
    'get_user_home',
32
 
    'get_user_ids',
33
 
    'install_extra_repositories',
34
 
    'join_command',
35
 
    'mkdirs',
36
 
    'run',
37
 
    'Serializer',
38
 
    'script_name',
39
 
    'search_file',
40
 
    'ssh',
41
 
    'su',
42
 
    'user_exists',
43
 
    'wait_for_page_contents',
44
 
    ]
45
 
 
46
 
from collections import namedtuple
47
 
from contextlib import contextmanager
48
 
from email.Utils import parseaddr
49
 
import errno
50
 
import json
51
 
import operator
52
 
import os
53
 
import pipes
54
 
import pwd
55
 
import re
56
 
import subprocess
57
 
import sys
58
 
from textwrap import dedent
59
 
import time
60
 
import urllib2
61
 
 
62
 
 
63
 
Env = namedtuple('Env', 'uid gid home')
64
 
 
65
 
 
66
 
def apt_get_install(*args, **kwargs):
67
 
    """Install given packages using apt.
68
 
 
69
 
    It is possible to pass environment variables to be set during install
70
 
    using keyword arguments.
71
 
 
72
 
    :raises: subprocess.CalledProcessError
73
 
    """
74
 
    caller = kwargs.pop('caller', run)
75
 
    debian_frontend = kwargs.pop('DEBIAN_FRONTEND', 'noninteractive')
76
 
    with environ(DEBIAN_FRONTEND=debian_frontend, **kwargs):
77
 
        cmd = ('apt-get', '-y', 'install') + args
78
 
        return caller(*cmd)
79
 
 
80
 
 
81
 
def bzr_whois(user):
82
 
    """Return full name and email of bzr `user`.
83
 
 
84
 
    Return None if the given `user` does not have a bzr user id.
85
 
    """
86
 
    with su(user):
87
 
        try:
88
 
            whoami = run('bzr', 'whoami')
89
 
        except (subprocess.CalledProcessError, OSError):
90
 
            return None
91
 
    return parseaddr(whoami)
92
 
 
93
 
 
94
 
@contextmanager
95
 
def cd(directory):
96
 
    """A context manager to temporarily change current working dir, e.g.::
97
 
 
98
 
        >>> import os
99
 
        >>> os.chdir('/tmp')
100
 
        >>> with cd('/bin'): print os.getcwd()
101
 
        /bin
102
 
        >>> print os.getcwd()
103
 
        /tmp
104
 
    """
105
 
    cwd = os.getcwd()
106
 
    os.chdir(directory)
107
 
    try:
108
 
        yield
109
 
    finally:
110
 
        os.chdir(cwd)
111
 
 
112
 
 
113
 
def command(*base_args):
114
 
    """Return a callable that will run the given command with any arguments.
115
 
 
116
 
    The first argument is the path to the command to run, subsequent arguments
117
 
    are command-line arguments to "bake into" the returned callable.
118
 
 
119
 
    The callable runs the given executable and also takes arguments that will
120
 
    be appeneded to the "baked in" arguments.
121
 
 
122
 
    For example, this code will list a file named "foo" (if it exists):
123
 
 
124
 
        ls_foo = command('/bin/ls', 'foo')
125
 
        ls_foo()
126
 
 
127
 
    While this invocation will list "foo" and "bar" (assuming they exist):
128
 
 
129
 
        ls_foo('bar')
130
 
    """
131
 
    def callable_command(*args):
132
 
        all_args = base_args + args
133
 
        return run(*all_args)
134
 
 
135
 
    return callable_command
136
 
 
137
 
 
138
 
@contextmanager
139
 
def environ(**kwargs):
140
 
    """A context manager to temporarily change environment variables.
141
 
 
142
 
    If an existing environment variable is changed, it is restored during
143
 
    context cleanup::
144
 
 
145
 
        >>> import os
146
 
        >>> os.environ['MY_VARIABLE'] = 'foo'
147
 
        >>> with environ(MY_VARIABLE='bar'): print os.getenv('MY_VARIABLE')
148
 
        bar
149
 
        >>> print os.getenv('MY_VARIABLE')
150
 
        foo
151
 
        >>> del os.environ['MY_VARIABLE']
152
 
 
153
 
    If we are adding environment variables, they are removed during context
154
 
    cleanup::
155
 
 
156
 
        >>> import os
157
 
        >>> with environ(MY_VAR1='foo', MY_VAR2='bar'):
158
 
        ...     print os.getenv('MY_VAR1'), os.getenv('MY_VAR2')
159
 
        foo bar
160
 
        >>> os.getenv('MY_VAR1') == os.getenv('MY_VAR2') == None
161
 
        True
162
 
    """
163
 
    backup = {}
164
 
    for key, value in kwargs.items():
165
 
        backup[key] = os.getenv(key)
166
 
        os.environ[key] = value
167
 
    try:
168
 
        yield
169
 
    finally:
170
 
        for key, value in backup.items():
171
 
            if value is None:
172
 
                del os.environ[key]
173
 
            else:
174
 
                os.environ[key] = value
175
 
 
176
 
 
177
 
def file_append(filename, line):
178
 
    r"""Append given `line`, if not present, at the end of `filename`.
179
 
 
180
 
    Usage example::
181
 
 
182
 
        >>> import tempfile
183
 
        >>> f = tempfile.NamedTemporaryFile('w', delete=False)
184
 
        >>> f.write('line1\n')
185
 
        >>> f.close()
186
 
        >>> file_append(f.name, 'new line\n')
187
 
        >>> open(f.name).read()
188
 
        'line1\nnew line\n'
189
 
 
190
 
    Nothing happens if the file already contains the given `line`::
191
 
 
192
 
        >>> file_append(f.name, 'new line\n')
193
 
        >>> open(f.name).read()
194
 
        'line1\nnew line\n'
195
 
 
196
 
    A new line is automatically added before the given `line` if it is not
197
 
    present at the end of current file content::
198
 
 
199
 
        >>> import tempfile
200
 
        >>> f = tempfile.NamedTemporaryFile('w', delete=False)
201
 
        >>> f.write('line1')
202
 
        >>> f.close()
203
 
        >>> file_append(f.name, 'new line\n')
204
 
        >>> open(f.name).read()
205
 
        'line1\nnew line\n'
206
 
 
207
 
    The file is created if it does not exist::
208
 
 
209
 
        >>> import tempfile
210
 
        >>> filename = tempfile.mktemp()
211
 
        >>> file_append(filename, 'line1\n')
212
 
        >>> open(filename).read()
213
 
        'line1\n'
214
 
    """
215
 
    if not line.endswith('\n'):
216
 
        line += '\n'
217
 
    with open(filename, 'a+') as f:
218
 
        lines = f.readlines()
219
 
        if line not in lines:
220
 
            if not lines or lines[-1].endswith('\n'):
221
 
                f.write(line)
222
 
            else:
223
 
                f.write('\n' + line)
224
 
 
225
 
 
226
 
def file_prepend(filename, line):
227
 
    r"""Insert given `line`, if not present, at the beginning of `filename`.
228
 
 
229
 
    Usage example::
230
 
 
231
 
        >>> import tempfile
232
 
        >>> f = tempfile.NamedTemporaryFile('w', delete=False)
233
 
        >>> f.write('line1\n')
234
 
        >>> f.close()
235
 
        >>> file_prepend(f.name, 'line0\n')
236
 
        >>> open(f.name).read()
237
 
        'line0\nline1\n'
238
 
 
239
 
    If the file starts with the given `line`, nothing happens::
240
 
 
241
 
        >>> file_prepend(f.name, 'line0\n')
242
 
        >>> open(f.name).read()
243
 
        'line0\nline1\n'
244
 
 
245
 
    If the file contains the given `line`, but not at the beginning,
246
 
    the line is moved on top::
247
 
 
248
 
        >>> file_prepend(f.name, 'line1\n')
249
 
        >>> open(f.name).read()
250
 
        'line1\nline0\n'
251
 
    """
252
 
    if not line.endswith('\n'):
253
 
        line += '\n'
254
 
    with open(filename, 'r+') as f:
255
 
        lines = f.readlines()
256
 
        if lines[0] != line:
257
 
            try:
258
 
                lines.remove(line)
259
 
            except ValueError:
260
 
                pass
261
 
            lines.insert(0, line)
262
 
            f.seek(0)
263
 
            f.writelines(lines)
264
 
 
265
 
 
266
 
def generate_ssh_keys(path, passphrase=''):
267
 
    """Generate ssh key pair, saving them inside the given `directory`.
268
 
 
269
 
        >>> generate_ssh_keys('/tmp/id_rsa')
270
 
        0
271
 
        >>> open('/tmp/id_rsa').readlines()[0].strip()
272
 
        '-----BEGIN RSA PRIVATE KEY-----'
273
 
        >>> open('/tmp/id_rsa.pub').read().startswith('ssh-rsa')
274
 
        True
275
 
        >>> os.remove('/tmp/id_rsa')
276
 
        >>> os.remove('/tmp/id_rsa.pub')
277
 
 
278
 
    If either of the key files already exist, generate_ssh_keys() will
279
 
    raise an Exception.
280
 
 
281
 
    Note that ssh-keygen will prompt if the keyfiles already exist, but
282
 
    when we're using it non-interactively it's better to pre-empt that
283
 
    behaviour.
284
 
 
285
 
        >>> with open('/tmp/id_rsa', 'w') as key_file:
286
 
        ...    key_file.write("Don't overwrite me, bro!")
287
 
        >>> generate_ssh_keys('/tmp/id_rsa') # doctest: +ELLIPSIS
288
 
        Traceback (most recent call last):
289
 
        Exception: File /tmp/id_rsa already exists...
290
 
        >>> os.remove('/tmp/id_rsa')
291
 
 
292
 
        >>> with open('/tmp/id_rsa.pub', 'w') as key_file:
293
 
        ...    key_file.write("Don't overwrite me, bro!")
294
 
        >>> generate_ssh_keys('/tmp/id_rsa') # doctest: +ELLIPSIS
295
 
        Traceback (most recent call last):
296
 
        Exception: File /tmp/id_rsa.pub already exists...
297
 
        >>> os.remove('/tmp/id_rsa.pub')
298
 
    """
299
 
    if os.path.exists(path):
300
 
        raise Exception("File {} already exists.".format(path))
301
 
    if os.path.exists(path + '.pub'):
302
 
        raise Exception("File {}.pub already exists.".format(path))
303
 
    return subprocess.call([
304
 
        'ssh-keygen', '-q', '-t', 'rsa', '-N', passphrase, '-f', path])
305
 
 
306
 
 
307
 
def get_su_command(user, args):
308
 
    """Return a command line as a sequence, prepending "su" if necessary.
309
 
 
310
 
    This can be used together with `run` when the `su` context manager is not
311
 
    enough (e.g. an external program uses uid rather than euid).
312
 
 
313
 
        run(*get_su_command(user, ['bzr', 'whoami']))
314
 
 
315
 
    If the su is requested as current user, the arguments are returned as
316
 
    given::
317
 
 
318
 
        >>> import getpass
319
 
        >>> current_user = getpass.getuser()
320
 
 
321
 
        >>> get_su_command(current_user, ('ls', '-l'))
322
 
        ('ls', '-l')
323
 
 
324
 
    Otherwise, "su" is prepended::
325
 
 
326
 
        >>> get_su_command('nobody', ('ls', '-l', 'my file'))
327
 
        ('su', 'nobody', '-c', "ls -l 'my file'")
328
 
    """
329
 
    if get_user_ids(user)[0] != os.getuid():
330
 
        args = [i for i in args if i is not None]
331
 
        return ('su', user, '-c', join_command(args))
332
 
    return args
333
 
 
334
 
 
335
 
def get_user_home(user):
336
 
    """Return the home directory of the given `user`.
337
 
 
338
 
        >>> get_user_home('root')
339
 
        '/root'
340
 
 
341
 
    If the user does not exist, return a default /home/[username] home::
342
 
 
343
 
        >>> get_user_home('_this_user_does_not_exist_')
344
 
        '/home/_this_user_does_not_exist_'
345
 
    """
346
 
    try:
347
 
        return pwd.getpwnam(user).pw_dir
348
 
    except KeyError:
349
 
        return os.path.join(os.path.sep, 'home', user)
350
 
 
351
 
 
352
 
def get_user_ids(user):
353
 
    """Return the uid and gid of given `user`, e.g.::
354
 
 
355
 
        >>> get_user_ids('root')
356
 
        (0, 0)
357
 
    """
358
 
    userdata = pwd.getpwnam(user)
359
 
    return userdata.pw_uid, userdata.pw_gid
360
 
 
361
 
 
362
 
def install_extra_repositories(*repositories):
363
 
    """Install all of the extra repositories and update apt.
364
 
 
365
 
    Given repositories can contain a "{distribution}" placeholder, that will
366
 
    be replaced by current distribution codename.
367
 
 
368
 
    :raises: subprocess.CalledProcessError
369
 
    """
370
 
    distribution = run('lsb_release', '-cs').strip()
371
 
    # Starting from Oneiric, `apt-add-repository` is interactive by
372
 
    # default, and requires a "-y" flag to be set.
373
 
    assume_yes = None if distribution == 'lucid' else '-y'
374
 
    for repo in repositories:
375
 
        repository = repo.format(distribution=distribution)
376
 
        run('apt-add-repository', assume_yes, repository)
377
 
    run('apt-get', 'clean')
378
 
    run('apt-get', 'update')
379
 
 
380
 
 
381
 
def join_command(args):
382
 
    """Return a valid Unix command line from `args`.
383
 
 
384
 
        >>> join_command(['ls', '-l'])
385
 
        'ls -l'
386
 
 
387
 
    Arguments containing spaces and empty args are correctly quoted::
388
 
 
389
 
        >>> join_command(['command', 'arg1', 'arg containing spaces', ''])
390
 
        "command arg1 'arg containing spaces' ''"
391
 
    """
392
 
    return ' '.join(pipes.quote(arg) for arg in args)
393
 
 
394
 
 
395
 
def mkdirs(*args):
396
 
    """Create leaf directories (given as `args`) and all intermediate ones.
397
 
 
398
 
        >>> import tempfile
399
 
        >>> base_dir = tempfile.mktemp(suffix='/')
400
 
        >>> dir1 = tempfile.mktemp(prefix=base_dir)
401
 
        >>> dir2 = tempfile.mktemp(prefix=base_dir)
402
 
        >>> mkdirs(dir1, dir2)
403
 
        >>> os.path.isdir(dir1)
404
 
        True
405
 
        >>> os.path.isdir(dir2)
406
 
        True
407
 
 
408
 
    If the leaf directory already exists the function returns without errors::
409
 
 
410
 
        >>> mkdirs(dir1)
411
 
 
412
 
    An `OSError` is raised if the leaf path exists and it is a file::
413
 
 
414
 
        >>> f = tempfile.NamedTemporaryFile(
415
 
        ...     'w', delete=False, prefix=base_dir)
416
 
        >>> f.close()
417
 
        >>> mkdirs(f.name) # doctest: +ELLIPSIS
418
 
        Traceback (most recent call last):
419
 
        OSError: ...
420
 
    """
421
 
    for directory in args:
422
 
        try:
423
 
            os.makedirs(directory)
424
 
        except OSError as err:
425
 
            if err.errno != errno.EEXIST or os.path.isfile(directory):
426
 
                raise
427
 
 
428
 
 
429
 
def run(*args, **kwargs):
430
 
    """Run the command with the given arguments.
431
 
 
432
 
    The first argument is the path to the command to run.
433
 
    Subsequent arguments are command-line arguments to be passed.
434
 
 
435
 
    This function accepts all optional keyword arguments accepted by
436
 
    `subprocess.Popen`.
437
 
    """
438
 
    args = [i for i in args if i is not None]
439
 
    pipe = subprocess.PIPE
440
 
    process = subprocess.Popen(
441
 
        args, stdout=kwargs.pop('stdout', pipe),
442
 
        stderr=kwargs.pop('stderr', pipe),
443
 
        close_fds=kwargs.pop('close_fds', True), **kwargs)
444
 
    stdout, stderr = process.communicate()
445
 
    if process.returncode:
446
 
        exception = subprocess.CalledProcessError(
447
 
            process.returncode, repr(args))
448
 
        # The output argument of `CalledProcessError` was introduced in Python
449
 
        # 2.7. Monkey patch the output here to avoid TypeErrors in older
450
 
        # versions of Python, still preserving the output in Python 2.7.
451
 
        exception.output = ''.join(filter(None, [stdout, stderr]))
452
 
        raise exception
453
 
    return stdout
454
 
 
455
 
 
456
 
def script_name():
457
 
    """Return the name of this script."""
458
 
    return os.path.basename(sys.argv[0])
459
 
 
460
 
 
461
 
def search_file(regexp, filename):
462
 
    """Return the first line in `filename` that matches `regexp`."""
463
 
    with open(filename) as f:
464
 
        for line in f:
465
 
            if re.search(regexp, line):
466
 
                return line
467
 
 
468
 
 
469
 
def ssh(location, user=None, key=None, caller=subprocess.call):
470
 
    """Return a callable that can be used to run ssh shell commands.
471
 
 
472
 
    The ssh `location` and, optionally, `user` must be given.
473
 
    If the user is None then the current user is used for the connection.
474
 
 
475
 
    The callable internally uses the given `caller`::
476
 
 
477
 
        >>> def caller(cmd):
478
 
        ...     print tuple(cmd)
479
 
        >>> sshcall = ssh('example.com', 'myuser', caller=caller)
480
 
        >>> root_sshcall = ssh('example.com', caller=caller)
481
 
        >>> sshcall('ls -l') # doctest: +ELLIPSIS
482
 
        ('ssh', '-t', ..., 'myuser@example.com', '--', 'ls -l')
483
 
        >>> root_sshcall('ls -l') # doctest: +ELLIPSIS
484
 
        ('ssh', '-t', ..., 'example.com', '--', 'ls -l')
485
 
 
486
 
    The ssh key path can be optionally provided::
487
 
 
488
 
        >>> root_sshcall = ssh('example.com', key='/tmp/foo', caller=caller)
489
 
        >>> root_sshcall('ls -l') # doctest: +ELLIPSIS
490
 
        ('ssh', '-t', ..., '-i', '/tmp/foo', 'example.com', '--', 'ls -l')
491
 
 
492
 
    If the ssh command exits with an error code,
493
 
    a `subprocess.CalledProcessError` is raised::
494
 
 
495
 
        >>> ssh('loc', caller=lambda cmd: 1)('ls -l') # doctest: +ELLIPSIS
496
 
        Traceback (most recent call last):
497
 
        CalledProcessError: ...
498
 
 
499
 
    If ignore_errors is set to True when executing the command, no error
500
 
    will be raised, even if the command itself returns an error code.
501
 
 
502
 
        >>> sshcall = ssh('loc', caller=lambda cmd: 1)
503
 
        >>> sshcall('ls -l', ignore_errors=True)
504
 
    """
505
 
    sshcmd = [
506
 
        'ssh',
507
 
        '-t',
508
 
        '-t',  # Yes, this second -t is deliberate. See `man ssh`.
509
 
        '-o', 'StrictHostKeyChecking=no',
510
 
        '-o', 'UserKnownHostsFile=/dev/null',
511
 
        ]
512
 
    if key is not None:
513
 
        sshcmd.extend(['-i', key])
514
 
    if user is not None:
515
 
        location = '{}@{}'.format(user, location)
516
 
    sshcmd.extend([location, '--'])
517
 
 
518
 
    def _sshcall(cmd, ignore_errors=False):
519
 
        command = sshcmd + [cmd]
520
 
        retcode = caller(command)
521
 
        if retcode and not ignore_errors:
522
 
            raise subprocess.CalledProcessError(retcode, ' '.join(command))
523
 
 
524
 
    return _sshcall
525
 
 
526
 
 
527
 
@contextmanager
528
 
def su(user):
529
 
    """A context manager to temporarily run the script as a different user."""
530
 
    uid, gid = get_user_ids(user)
531
 
    os.setegid(gid)
532
 
    os.seteuid(uid)
533
 
    home = get_user_home(user)
534
 
    with environ(HOME=home):
535
 
        try:
536
 
            yield Env(uid, gid, home)
537
 
        finally:
538
 
            os.setegid(os.getgid())
539
 
            os.seteuid(os.getuid())
540
 
 
541
 
 
542
 
def user_exists(username):
543
 
    """Return True if given `username` exists, e.g.::
544
 
 
545
 
        >>> user_exists('root')
546
 
        True
547
 
        >>> user_exists('_this_user_does_not_exist_')
548
 
        False
549
 
    """
550
 
    try:
551
 
        pwd.getpwnam(username)
552
 
    except KeyError:
553
 
        return False
554
 
    return True
555
 
 
556
 
 
557
 
def wait_for_page_contents(url, contents, timeout=120, validate=None):
558
 
    if validate is None:
559
 
        validate = operator.contains
560
 
    start_time = time.time()
561
 
    while True:
562
 
        try:
563
 
            stream = urllib2.urlopen(url)
564
 
        except (urllib2.HTTPError, urllib2.URLError):
565
 
            pass
566
 
        else:
567
 
            page = stream.read()
568
 
            if validate(page, contents):
569
 
                return page
570
 
        if time.time() - start_time >= timeout:
571
 
            raise RuntimeError('timeout waiting for contents of ' + url)
572
 
        time.sleep(0.1)
573
 
 
574
 
 
575
 
class DictDiffer:
576
 
    """
577
 
    Calculate the difference between two dictionaries as:
578
 
    (1) items added
579
 
    (2) items removed
580
 
    (3) keys same in both but changed values
581
 
    (4) keys same in both and unchanged values
582
 
    """
583
 
 
584
 
    # Based on answer by hughdbrown at:
585
 
    # http://stackoverflow.com/questions/1165352
586
 
 
587
 
    def __init__(self, current_dict, past_dict):
588
 
        self.current_dict = current_dict
589
 
        self.past_dict = past_dict
590
 
        self.set_current = set(current_dict)
591
 
        self.set_past = set(past_dict)
592
 
        self.intersect = self.set_current.intersection(self.set_past)
593
 
 
594
 
    @property
595
 
    def added(self):
596
 
        return self.set_current - self.intersect
597
 
 
598
 
    @property
599
 
    def removed(self):
600
 
        return self.set_past - self.intersect
601
 
 
602
 
    @property
603
 
    def changed(self):
604
 
        return set(key for key in self.intersect
605
 
                   if self.past_dict[key] != self.current_dict[key])
606
 
    @property
607
 
    def unchanged(self):
608
 
        return set(key for key in self.intersect
609
 
                   if self.past_dict[key] == self.current_dict[key])
610
 
    @property
611
 
    def modified(self):
612
 
        return self.current_dict != self.past_dict
613
 
 
614
 
    @property
615
 
    def added_or_changed(self):
616
 
        return self.added.union(self.changed)
617
 
 
618
 
    def _changes(self, keys):
619
 
        new = {}
620
 
        old = {}
621
 
        for k in keys:
622
 
            new[k] = self.current_dict.get(k)
623
 
            old[k] = self.past_dict.get(k)
624
 
        return "%s -> %s" % (old, new)
625
 
 
626
 
    def __str__(self):
627
 
        if self.modified:
628
 
            s = dedent("""\
629
 
            added: %s
630
 
            removed: %s
631
 
            changed: %s
632
 
            unchanged: %s""") % (
633
 
                self._changes(self.added),
634
 
                self._changes(self.removed),
635
 
                self._changes(self.changed),
636
 
                list(self.unchanged))
637
 
        else:
638
 
            s = "no changes"
639
 
        return s
640
 
 
641
 
 
642
 
class Serializer:
643
 
    """Handle JSON (de)serialization."""
644
 
 
645
 
    def __init__(self, path, default=None, serialize=None, deserialize=None):
646
 
        self.path = path
647
 
        self.default = default or {}
648
 
        self.serialize = serialize or json.dump
649
 
        self.deserialize = deserialize or json.load
650
 
 
651
 
    def exists(self):
652
 
        return os.path.exists(self.path)
653
 
 
654
 
    def get(self):
655
 
        if self.exists():
656
 
            with open(self.path) as f:
657
 
                return self.deserialize(f)
658
 
        return self.default
659
 
 
660
 
    def set(self, data):
661
 
        with open(self.path, 'w') as f:
662
 
            self.serialize(data, f)