3
# mailer.py: send email describing a commit
5
# $HeadURL: http://svn.collab.net/repos/svn/branches/1.2.x/tools/hook-scripts/mailer/mailer.py $
6
# $LastChangedDate: 2005-04-21 14:12:41 -0400 (Thu, 21 Apr 2005) $
7
# $LastChangedBy: breser $
8
# $LastChangedRevision: 14358 $
10
# USAGE: mailer.py commit REPOS REVISION [CONFIG-FILE]
11
# mailer.py propchange REPOS REVISION AUTHOR PROPNAME [CONFIG-FILE]
12
# mailer.py propchange2 REPOS REVISION AUTHOR PROPNAME ACTION \
14
# mailer.py lock REPOS AUTHOR [CONFIG-FILE]
15
# mailer.py unlock REPOS AUTHOR [CONFIG-FILE]
17
# Using CONFIG-FILE, deliver an email describing the changes between
18
# REV and REV-1 for the repository REPOS.
20
# ACTION was added as a fifth argument to the post-revprop-change hook
21
# in Subversion 1.2.0. Its value is one of 'A', 'M' or 'D' to indicate
22
# if the property was added, modified or deleted, respectively.
24
# This version of mailer.py requires the python bindings from
25
# subversion 1.2.0 or later.
48
def main(pool, cmd, config_fname, repos_dir, cmd_args):
49
### TODO: Sanity check the incoming args
52
revision = int(cmd_args[0])
53
repos = Repository(repos_dir, revision, pool)
54
cfg = Config(config_fname, repos, { 'author' : repos.author })
55
messenger = Commit(pool, cfg, repos)
56
elif cmd == 'propchange' or cmd == 'propchange2':
57
revision = int(cmd_args[0])
59
propname = cmd_args[2]
60
action = (cmd == 'propchange2' and cmd_args[3] or 'A')
61
repos = Repository(repos_dir, revision, pool)
62
# Override the repos revision author with the author of the propchange
64
cfg = Config(config_fname, repos, { 'author' : author })
65
messenger = PropChange(pool, cfg, repos, author, propname, action)
66
elif cmd == 'lock' or cmd == 'unlock':
68
repos = Repository(repos_dir, 0, pool) ### any old revision will do
69
cfg = Config(config_fname, repos, { 'author' : author })
70
messenger = Lock(pool, cfg, repos, author, cmd == 'lock')
72
raise UnknownSubcommand(cmd)
77
# Minimal, incomplete, versions of popen2.Popen[34] for those platforms
78
# for which popen2 does not provide them.
80
Popen3 = popen2.Popen3
81
Popen4 = popen2.Popen4
82
except AttributeError:
84
def __init__(self, cmd, capturestderr = False):
85
if type(cmd) != types.StringType:
86
cmd = svn.core.argv_to_command_string(cmd)
88
self.fromchild, self.tochild, self.childerr \
89
= popen2.popen3(cmd, mode='b')
91
self.fromchild, self.tochild = popen2.popen2(cmd, mode='b')
95
rv = self.fromchild.close()
96
rv = self.tochild.close() or rv
97
if self.childerr is not None:
98
rv = self.childerr.close() or rv
102
def __init__(self, cmd):
103
if type(cmd) != types.StringType:
104
cmd = svn.core.argv_to_command_string(cmd)
105
self.fromchild, self.tochild = popen2.popen4(cmd, mode='b')
108
rv = self.fromchild.close()
109
rv = self.tochild.close() or rv
114
"Abstract base class to formalize the inteface of output methods"
116
def __init__(self, cfg, repos, prefix_param):
119
self.prefix_param = prefix_param
120
self._CHUNKSIZE = 128 * 1024
122
# This is a public member variable. This must be assigned a suitable
123
# piece of descriptive text before make_subject() is called.
126
def make_subject(self, group, params):
127
prefix = self.cfg.get(self.prefix_param, group, params)
129
subject = prefix + ' ' + self.subject
131
subject = self.subject
134
truncate_subject = int(
135
self.cfg.get('truncate-subject', group, params))
139
if truncate_subject and len(subject) > truncate_subject:
140
subject = subject[:(truncate_subject - 3)] + "..."
143
def start(self, group, params):
144
"""Override this method.
145
Begin writing an output representation. GROUP is the name of the
146
configuration file group which is causing this output to be produced.
147
PARAMS is a dictionary of any named subexpressions of regular expressions
148
defined in the configuration file, plus the key 'author' contains the
149
author of the action being reported."""
150
raise NotImplementedError
153
"""Override this method.
154
Flush any cached information and finish writing the output
156
raise NotImplementedError
158
def write(self, output):
159
"""Override this method.
160
Append the literal text string OUTPUT to the output representation."""
161
raise NotImplementedError
164
"""Override this method, if the default implementation is not sufficient.
165
Execute CMD, writing the stdout produced to the output representation."""
166
# By default we choose to incorporate child stderr into the output
167
pipe_ob = Popen4(cmd)
169
buf = pipe_ob.fromchild.read(self._CHUNKSIZE)
172
buf = pipe_ob.fromchild.read(self._CHUNKSIZE)
174
# wait on the child so we don't end up with a billion zombies
178
class MailedOutput(OutputBase):
179
def __init__(self, cfg, repos, prefix_param):
180
OutputBase.__init__(self, cfg, repos, prefix_param)
182
def start(self, group, params):
183
# whitespace-separated list of addresses; split into a clean list:
185
filter(None, string.split(self.cfg.get('to_addr', group, params)))
186
self.from_addr = self.cfg.get('from_addr', group, params) \
187
or self.repos.author or 'no_author'
188
self.reply_to = self.cfg.get('reply_to', group, params)
190
def mail_headers(self, group, params):
191
subject = self.make_subject(group, params)
192
hdrs = 'From: %s\n' \
195
'MIME-Version: 1.0\n' \
196
'Content-Type: text/plain; charset=UTF-8\n' \
197
% (self.from_addr, string.join(self.to_addrs, ', '), subject)
199
hdrs = '%sReply-To: %s\n' % (hdrs, self.reply_to)
203
class SMTPOutput(MailedOutput):
204
"Deliver a mail message to an MTA using SMTP."
206
def start(self, group, params):
207
MailedOutput.start(self, group, params)
209
self.buffer = cStringIO.StringIO()
210
self.write = self.buffer.write
212
self.write(self.mail_headers(group, params))
215
server = smtplib.SMTP(self.cfg.general.smtp_hostname)
216
if self.cfg.is_set('general.smtp_username'):
217
server.login(self.cfg.general.smtp_username,
218
self.cfg.general.smtp_password)
219
server.sendmail(self.from_addr, self.to_addrs, self.buffer.getvalue())
223
class StandardOutput(OutputBase):
224
"Print the commit message to stdout."
226
def __init__(self, cfg, repos, prefix_param):
227
OutputBase.__init__(self, cfg, repos, prefix_param)
228
self.write = sys.stdout.write
230
def start(self, group, params):
231
self.write("Group: " + (group or "defaults") + "\n")
232
self.write("Subject: " + self.make_subject(group, params) + "\n\n")
238
class PipeOutput(MailedOutput):
239
"Deliver a mail message to an MDA via a pipe."
241
def __init__(self, cfg, repos, prefix_param):
242
MailedOutput.__init__(self, cfg, repos, prefix_param)
244
# figure out the command for delivery
245
self.cmd = string.split(cfg.general.mail_command)
247
def start(self, group, params):
248
MailedOutput.start(self, group, params)
250
### gotta fix this. this is pretty specific to sendmail and qmail's
251
### mailwrapper program. should be able to use option param substitution
252
cmd = self.cmd + [ '-f', self.from_addr ] + self.to_addrs
254
# construct the pipe for talking to the mailer
255
self.pipe = Popen3(cmd)
256
self.write = self.pipe.tochild.write
258
# we don't need the read-from-mailer descriptor, so close it
259
self.pipe.fromchild.close()
261
# start writing out the mail message
262
self.write(self.mail_headers(group, params))
265
# signal that we're done sending content
266
self.pipe.tochild.close()
268
# wait to avoid zombies
273
def __init__(self, pool, cfg, repos, prefix_param):
278
if cfg.is_set('general.mail_command'):
280
elif cfg.is_set('general.smtp_hostname'):
285
self.output = cls(cfg, repos, prefix_param)
288
class Commit(Messenger):
289
def __init__(self, pool, cfg, repos):
290
Messenger.__init__(self, pool, cfg, repos, 'commit_subject_prefix')
292
# get all the changes and sort by path
293
editor = svn.repos.ChangeCollector(repos.fs_ptr, repos.root_this, self.pool)
294
e_ptr, e_baton = svn.delta.make_editor(editor, self.pool)
295
svn.repos.replay(repos.root_this, e_ptr, e_baton, self.pool)
297
self.changelist = editor.get_changes().items()
298
self.changelist.sort()
300
# collect the set of groups and the unique sets of params for the options
302
for path, change in self.changelist:
303
for (group, params) in self.cfg.which_groups(path):
304
# turn the params into a hashable object and stash it away
305
param_list = params.items()
307
# collect the set of paths belonging to this group
308
if self.groups.has_key( (group, tuple(param_list)) ):
309
old_param, paths = self.groups[group, tuple(param_list)]
313
self.groups[group, tuple(param_list)] = (params, paths)
315
# figure out the changed directories
317
for path, change in self.changelist:
318
if change.item_kind == svn.core.svn_node_dir:
321
idx = string.rfind(path, '/')
325
dirs[path[:idx]] = None
327
dirlist = dirs.keys()
329
commondir, dirlist = get_commondir(dirlist)
331
# compose the basic subject line. later, we can prefix it.
333
dirlist = string.join(dirlist)
335
self.output.subject = 'r%d - in %s: %s' % (repos.rev, commondir, dirlist)
337
self.output.subject = 'r%d - %s' % (repos.rev, dirlist)
340
"Generate email for the various groups and option-params."
342
### the groups need to be further compressed. if the headers and
343
### body are the same across groups, then we can have multiple To:
344
### addresses. SMTPOutput holds the entire message body in memory,
345
### so if the body doesn't change, then it can be sent N times
346
### rather than rebuilding it each time.
348
subpool = svn.core.svn_pool_create(self.pool)
350
# build a renderer, tied to our output stream
351
renderer = TextCommitRenderer(self.output)
353
for (group, param_tuple), (params, paths) in self.groups.items():
354
self.output.start(group, params)
356
# generate the content for this group and set of params
357
generate_content(renderer, self.cfg, self.repos, self.changelist,
358
group, params, paths, subpool)
361
svn.core.svn_pool_clear(subpool)
363
svn.core.svn_pool_destroy(subpool)
367
from tempfile import NamedTemporaryFile
369
# NamedTemporaryFile was added in Python 2.3, so we need to emulate it
371
class NamedTemporaryFile:
373
self.name = tempfile.mktemp()
374
self.file = open(self.name, 'w+b')
377
def write(self, data):
378
self.file.write(data)
383
class PropChange(Messenger):
384
def __init__(self, pool, cfg, repos, author, propname, action):
385
Messenger.__init__(self, pool, cfg, repos, 'propchange_subject_prefix')
387
self.propname = propname
390
# collect the set of groups and the unique sets of params for the options
392
for (group, params) in self.cfg.which_groups(''):
393
# turn the params into a hashable object and stash it away
394
param_list = params.items()
396
self.groups[group, tuple(param_list)] = params
398
self.output.subject = 'r%d - %s' % (repos.rev, propname)
401
actions = { 'A': 'added', 'M': 'modified', 'D': 'deleted' }
402
for (group, param_tuple), params in self.groups.items():
403
self.output.start(group, params)
404
self.output.write('Author: %s\n'
406
'Property Name: %s\n'
409
% (self.author, self.repos.rev, self.propname,
410
actions.get(self.action, 'Unknown (\'%s\')' \
412
if self.action == 'A' or not actions.has_key(self.action):
413
self.output.write('Property value:\n')
414
propvalue = self.repos.get_rev_prop(self.propname)
415
self.output.write(propvalue)
416
elif self.action == 'M':
417
self.output.write('Property diff:\n')
418
tempfile1 = NamedTemporaryFile()
419
tempfile1.write(sys.stdin.read())
421
tempfile2 = NamedTemporaryFile()
422
tempfile2.write(self.repos.get_rev_prop(self.propname))
424
self.output.run(self.cfg.get_diff_cmd(group, {
425
'label_from' : 'old property value',
426
'label_to' : 'new property value',
427
'from' : tempfile1.name,
428
'to' : tempfile2.name,
433
def get_commondir(dirlist):
434
"""Figure out the common portion/parent (commondir) of all the paths
435
in DIRLIST and return a tuple consisting of commondir, dirlist. If
436
a commondir is found, the dirlist returned is rooted in that
437
commondir. If no commondir is found, dirlist is returned unchanged,
438
and commondir is the empty string."""
439
if len(dirlist) == 1 or '/' in dirlist:
443
common = string.split(dirlist.pop(), '/')
445
parts = string.split(d, '/')
446
for i in range(len(common)):
447
if i == len(parts) or common[i] != parts[i]:
450
commondir = string.join(common, '/')
452
# strip the common portion from each directory
453
l = len(commondir) + 1
459
newdirs.append(d[l:])
461
# nothing in common, so reset the list of directories
464
return commondir, newdirs
467
class Lock(Messenger):
468
def __init__(self, pool, cfg, repos, author, do_lock):
470
self.do_lock = do_lock
472
Messenger.__init__(self, pool, cfg, repos,
473
(do_lock and 'lock_subject_prefix'
474
or 'unlock_subject_prefix'))
476
# read all the locked paths from STDIN and strip off the trailing newlines
477
self.dirlist = map(lambda x: x.rstrip(), sys.stdin.readlines())
479
# collect the set of groups and the unique sets of params for the options
481
for path in self.dirlist:
482
for (group, params) in self.cfg.which_groups(path):
483
# turn the params into a hashable object and stash it away
484
param_list = params.items()
486
# collect the set of paths belonging to this group
487
if self.groups.has_key( (group, tuple(param_list)) ):
488
old_param, paths = self.groups[group, tuple(param_list)]
492
self.groups[group, tuple(param_list)] = (params, paths)
494
commondir, dirlist = get_commondir(self.dirlist)
496
# compose the basic subject line. later, we can prefix it.
498
dirlist = string.join(dirlist)
500
self.output.subject = '%s: %s' % (commondir, dirlist)
502
self.output.subject = '%s' % (dirlist)
504
# The lock comment is the same for all paths, so we can just pull
505
# the comment for the first path in the dirlist and cache it.
506
self.lock = svn.fs.svn_fs_get_lock(self.repos.fs_ptr,
507
self.dirlist[0], self.pool)
510
for (group, param_tuple), (params, paths) in self.groups.items():
511
self.output.start(group, params)
513
self.output.write('Author: %s\n'
515
(self.author, self.do_lock and 'Locked' or 'Unlocked'))
518
for dir in self.dirlist:
519
self.output.write(' %s\n\n' % dir)
522
self.output.write('Comment:\n%s\n' % (self.lock.comment or ''))
527
class DiffSelections:
528
def __init__(self, cfg, group, params):
534
gen_diffs = cfg.get('generate_diffs', group, params)
536
### Do a little dance for deprecated options. Note that even if you
537
### don't have an option anywhere in your configuration file, it
538
### still gets returned as non-None.
540
list = string.split(gen_diffs, " ")
555
### These options are deprecated
556
suppress = cfg.get('suppress_deletes', group, params)
557
if suppress == 'yes':
559
suppress = cfg.get('suppress_adds', group, params)
560
if suppress == 'yes':
564
def generate_content(renderer, cfg, repos, changelist, group, params, paths,
567
svndate = repos.get_rev_prop(svn.core.SVN_PROP_REVISION_DATE)
568
### pick a different date format?
569
date = time.ctime(svn.core.secs_from_timestr(svndate, pool))
571
diffsels = DiffSelections(cfg, group, params)
573
show_nonmatching_paths = cfg.get('show_nonmatching_paths', group, params) \
576
# figure out the lists of changes outside the selected path-space
577
other_added_data = other_removed_data = other_modified_data = [ ]
578
if len(paths) != len(changelist) and show_nonmatching_paths != 'no':
579
other_added_data = generate_list('A', changelist, paths, False)
580
other_removed_data = generate_list('R', changelist, paths, False)
581
other_modified_data = generate_list('M', changelist, paths, False)
583
if len(paths) != len(changelist) and show_nonmatching_paths == 'yes':
584
other_diffs = DiffGenerator(changelist, paths, False, cfg, repos, date,
585
group, params, diffsels, pool),
593
log=repos.get_rev_prop(svn.core.SVN_PROP_REVISION_LOG) or '',
594
added_data=generate_list('A', changelist, paths, True),
595
removed_data=generate_list('R', changelist, paths, True),
596
modified_data=generate_list('M', changelist, paths, True),
597
other_added_data=other_added_data,
598
other_removed_data=other_removed_data,
599
other_modified_data=other_modified_data,
600
diffs=DiffGenerator(changelist, paths, True, cfg, repos, date, group,
601
params, diffsels, pool),
602
other_diffs=other_diffs,
604
renderer.render(data)
607
def generate_list(changekind, changelist, paths, in_paths):
608
if changekind == 'A':
609
selection = lambda change: change.added
610
elif changekind == 'R':
611
selection = lambda change: change.path is None
612
elif changekind == 'M':
613
selection = lambda change: not change.added and change.path is not None
616
for path, change in changelist:
617
if selection(change) and paths.has_key(path) == in_paths:
620
is_dir=change.item_kind == svn.core.svn_node_dir,
621
props_changed=change.prop_changes,
622
text_changed=change.text_changed,
623
copied=change.added and change.base_path,
624
base_path=change.base_path,
625
base_rev=change.base_rev,
633
"This is a generator-like object returning DiffContent objects."
635
def __init__(self, changelist, paths, in_paths, cfg, repos, date, group,
636
params, diffsels, pool):
637
self.changelist = changelist
639
self.in_paths = in_paths
645
self.diffsels = diffsels
650
def __nonzero__(self):
651
# we always have some items
654
def __getitem__(self, idx):
656
if self.idx == len(self.changelist):
659
path, change = self.changelist[self.idx]
660
self.idx = self.idx + 1
662
# just skip directories. they have no diffs.
663
if change.item_kind == svn.core.svn_node_dir:
666
# is this change in (or out of) the set of matched paths?
667
if self.paths.has_key(path) != self.in_paths:
670
# figure out if/how to generate a diff
673
# it was deleted. should we show deletion diffs?
674
if not self.diffsels.delete:
678
diff = svn.fs.FileDiff(self.repos.get_root(change.base_rev),
679
change.base_path, None, None, self.pool)
681
label1 = '%s\t%s' % (change.base_path, self.date)
682
label2 = '(empty file)'
685
if change.base_path and (change.base_rev != -1):
686
# this file was copied. any diff to show? should we?
687
if not change.text_changed or not self.diffsels.copy:
691
diff = svn.fs.FileDiff(self.repos.get_root(change.base_rev),
693
self.repos.root_this, change.path,
695
label1 = change.base_path + '\t(original)'
696
label2 = '%s\t%s' % (change.path, self.date)
699
# the file was added. should we show it?
700
if not self.diffsels.add:
704
diff = svn.fs.FileDiff(None, None, self.repos.root_this,
705
change.path, self.pool)
706
label1 = '(empty file)'
707
label2 = '%s\t%s' % (change.path, self.date)
710
elif not change.text_changed:
711
# the text didn't change, so nothing to show.
714
# a simple modification. show the diff?
715
if not self.diffsels.modify:
719
diff = svn.fs.FileDiff(self.repos.get_root(change.base_rev),
721
self.repos.root_this, change.path,
723
label1 = change.base_path + '\t(original)'
724
label2 = '%s\t%s' % (change.path, self.date)
727
binary = diff.either_binary()
729
content = src_fname = dst_fname = None
731
src_fname, dst_fname = diff.get_files()
732
content = DiffContent(self.cfg.get_diff_cmd(self.group, {
733
'label_from' : label1,
739
# return a data item for this diff
743
base_path=change.base_path,
744
base_rev=change.base_rev,
747
from_fname=src_fname,
757
"This is a generator-like object returning annotated lines of a diff."
759
def __init__(self, cmd):
760
self.seen_change = False
762
# By default we choose to incorporate child stderr into the output
763
self.pipe = Popen4(cmd)
765
def __nonzero__(self):
766
# we always have some items
769
def __getitem__(self, idx):
770
if self.pipe is None:
773
line = self.pipe.fromchild.readline()
775
# wait on the child so we don't end up with a billion zombies
780
# classify the type of line.
783
self.seen_change = True
802
text=line[1:-1], # remove indicator and newline
807
class TextCommitRenderer:
808
"This class will render the commit mail in plain text."
810
def __init__(self, output):
813
def render(self, data):
814
"Render the commit defined by 'data'."
816
w = self.output.write
818
w('Author: %s\nDate: %s\nNew Revision: %s\n\n'
819
% (data.author, data.date, data.rev))
821
# print summary sections
822
self._render_list('Added', data.added_data)
823
self._render_list('Removed', data.removed_data)
824
self._render_list('Modified', data.modified_data)
826
if data.other_added_data or data.other_removed_data \
827
or data.other_modified_data:
828
if data.show_nonmatching_paths:
829
w('\nChanges in other areas also in this revision:\n')
830
self._render_list('Added', data.other_added_data)
831
self._render_list('Removed', data.other_removed_data)
832
self._render_list('Modified', data.other_modified_data)
834
w('and changes in other areas\n')
836
w('\nLog:\n%s\n' % data.log)
838
self._render_diffs(data.diffs)
840
w('\nDiffs of changes in other areas also in this revision:\n')
841
self._render_diffs(data.other_diffs)
843
def _render_list(self, header, data_list):
847
w = self.output.write
856
props = ' (contents, props changed)'
858
props = ' (props changed)'
861
w(' %s%s%s\n' % (d.path, is_dir, props))
869
w(' - copied%s from r%d, %s%s\n'
870
% (text, d.base_rev, d.base_path, is_dir))
872
def _render_diffs(self, diffs):
873
w = self.output.write
877
w('\nDeleted: %s\n' % diff.base_path)
878
elif diff.kind == 'C':
879
w('\nCopied: %s (from r%d, %s)\n'
880
% (diff.path, diff.base_rev, diff.base_path))
881
elif diff.kind == 'A':
882
w('\nAdded: %s\n' % diff.path)
885
w('\nModified: %s\n' % diff.path)
891
w('Binary file. No diff available.\n')
893
w('Binary files. No diff available.\n')
896
for line in diff.content:
901
"Hold roots and other information about the repository."
903
def __init__(self, repos_dir, rev, pool):
904
self.repos_dir = repos_dir
908
self.repos_ptr = svn.repos.open(repos_dir, pool)
909
self.fs_ptr = svn.repos.fs(self.repos_ptr)
913
self.root_this = self.get_root(rev)
915
self.author = self.get_rev_prop(svn.core.SVN_PROP_REVISION_AUTHOR)
917
def get_rev_prop(self, propname):
918
return svn.fs.revision_prop(self.fs_ptr, self.rev, propname, self.pool)
920
def get_root(self, rev):
922
return self.roots[rev]
925
root = self.roots[rev] = svn.fs.revision_root(self.fs_ptr, rev, self.pool)
931
# The predefined configuration sections. These are omitted from the
933
_predefined = ('general', 'defaults', 'maps')
935
def __init__(self, fname, repos, global_params):
936
cp = ConfigParser.ConfigParser()
939
# record the (non-default) groups that we find
942
for section in cp.sections():
943
if not hasattr(self, section):
944
section_ob = _sub_section()
945
setattr(self, section, section_ob)
946
if section not in self._predefined:
947
self._groups.append(section)
949
section_ob = getattr(self, section)
950
for option in cp.options(section):
951
# get the raw value -- we use the same format for *our* interpolation
952
value = cp.get(section, option, raw=1)
953
setattr(section_ob, option, value)
955
# be compatible with old format config files
956
if hasattr(self.general, 'diff') and not hasattr(self.defaults, 'diff'):
957
self.defaults.diff = self.general.diff
958
if not hasattr(self, 'maps'):
959
self.maps = _sub_section()
961
# these params are always available, although they may be overridden
962
self._global_params = global_params.copy()
964
# prepare maps. this may remove sections from consideration as a group.
967
# process all the group sections.
968
self._prep_groups(repos)
970
def is_set(self, option):
971
"""Return None if the option is not set; otherwise, its value is returned.
973
The option is specified as a dotted symbol, such as 'general.mail_command'
976
for part in string.split(option, '.'):
977
if not hasattr(ob, part):
979
ob = getattr(ob, part)
982
def get(self, option, group, params):
983
"Get a config value with appropriate substitutions and value mapping."
985
# find the right value
988
sub = getattr(self, group)
989
value = getattr(sub, option, None)
991
value = getattr(self.defaults, option, '')
994
if params is not None:
995
value = value % params
998
mapper = getattr(self.maps, option, None)
999
if mapper is not None:
1000
value = mapper(value)
1004
def get_diff_cmd(self, group, args):
1005
"Get a diff command as a list of argv elements."
1006
### do some better splitting to enable quoting of spaces
1007
diff_cmd = string.split(self.get('diff', group, None))
1010
for part in diff_cmd:
1011
cmd.append(part % args)
1014
def _prep_maps(self):
1015
"Rewrite the [maps] options into callables that look up values."
1017
for optname, mapvalue in vars(self.maps).items():
1018
if mapvalue[:1] == '[':
1019
# a section is acting as a mapping
1020
sectname = mapvalue[1:-1]
1021
if not hasattr(self, sectname):
1022
raise UnknownMappingSection(sectname)
1023
# construct a lambda to look up the given value as an option name,
1024
# and return the option's value. if the option is not present,
1025
# then just return the value unchanged.
1026
setattr(self.maps, optname,
1028
sect=getattr(self, sectname): getattr(sect, value,
1030
# remove the mapping section from consideration as a group
1031
self._groups.remove(sectname)
1033
# elif test for other mapper types. possible examples:
1035
# file:two-column-file.txt
1036
# ldap:some-query-spec
1037
# just craft a mapper function and insert it appropriately
1040
raise UnknownMappingSpec(mapvalue)
1042
def _prep_groups(self, repos):
1043
self._group_re = [ ]
1045
repos_dir = os.path.abspath(repos.repos_dir)
1047
# compute the default repository-based parameters. start with some
1048
# basic parameters, then bring in the regex-based params.
1049
default_params = self._global_params.copy()
1052
match = re.match(self.defaults.for_repos, repos_dir)
1054
default_params.update(match.groupdict())
1055
except AttributeError:
1056
# there is no self.defaults.for_repos
1059
# select the groups that apply to this repository
1060
for group in self._groups:
1061
sub = getattr(self, group)
1062
params = default_params
1063
if hasattr(sub, 'for_repos'):
1064
match = re.match(sub.for_repos, repos_dir)
1067
params = self._global_params.copy()
1068
params.update(match.groupdict())
1070
# if a matching rule hasn't been given, then use the empty string
1071
# as it will match all paths
1072
for_paths = getattr(sub, 'for_paths', '')
1073
exclude_paths = getattr(sub, 'exclude_paths', None)
1075
exclude_paths_re = re.compile(exclude_paths)
1077
exclude_paths_re = None
1079
self._group_re.append((group, re.compile(for_paths),
1080
exclude_paths_re, params))
1082
# after all the groups are done, add in the default group
1084
self._group_re.append((None,
1085
re.compile(self.defaults.for_paths),
1088
except AttributeError:
1089
# there is no self.defaults.for_paths
1092
def which_groups(self, path):
1093
"Return the path's associated groups."
1095
for group, pattern, exclude_pattern, repos_params in self._group_re:
1096
match = pattern.match(path)
1098
if exclude_pattern and exclude_pattern.match(path):
1100
params = repos_params.copy()
1101
params.update(match.groupdict())
1102
groups.append((group, params))
1104
groups.append((None, self._global_params))
1112
"Helper class to define an attribute-based hunk o' data."
1113
def __init__(self, **kw):
1114
vars(self).update(kw)
1116
class MissingConfig(Exception):
1118
class UnknownMappingSection(Exception):
1120
class UnknownMappingSpec(Exception):
1122
class UnknownSubcommand(Exception):
1126
# enable True/False in older vsns of Python
1134
if __name__ == '__main__':
1137
"""USAGE: %s commit REPOS REVISION [CONFIG-FILE]
1138
%s propchange REPOS REVISION AUTHOR PROPNAME [CONFIG-FILE]
1139
%s propchange2 REPOS REVISION AUTHOR PROPNAME ACTION
1141
%s lock REPOS AUTHOR [CONFIG-FILE]
1142
%s unlock REPOS AUTHOR [CONFIG-FILE]
1144
If no CONFIG-FILE is provided, the script will first search for a mailer.conf
1145
file in REPOS/conf/. Failing that, it will search the directory in which
1146
the script itself resides.
1148
ACTION was added as a fifth argument to the post-revprop-change hook
1149
in Subversion 1.2.0. Its value is one of 'A', 'M' or 'D' to indicate
1150
if the property was added, modified or deleted, respectively.
1152
""" % (sys.argv[0], sys.argv[0], sys.argv[0], ' ' * len(sys.argv[0]),
1153
sys.argv[0], sys.argv[0]))
1156
# Command list: subcommand -> number of arguments expected (not including
1157
# the repository directory and config-file)
1158
cmd_list = {'commit' : 1,
1166
argc = len(sys.argv)
1171
repos_dir = sys.argv[2]
1173
expected_args = cmd_list[cmd]
1177
if argc < (expected_args + 3):
1179
elif argc > expected_args + 4:
1181
elif argc == (expected_args + 4):
1182
config_fname = sys.argv[expected_args + 3]
1184
# Settle on a config file location, and open it.
1185
if config_fname is None:
1186
# Default to REPOS-DIR/conf/mailer.conf.
1187
config_fname = os.path.join(repos_dir, 'conf', 'mailer.conf')
1188
if not os.path.exists(config_fname):
1189
# Okay. Look for 'mailer.conf' as a sibling of this script.
1190
config_fname = os.path.join(os.path.dirname(sys.argv[0]), 'mailer.conf')
1191
if not os.path.exists(config_fname):
1192
raise MissingConfig(config_fname)
1193
config_fp = open(config_fname)
1195
svn.core.run_app(main, cmd, config_fname, repos_dir,
1196
sys.argv[3:3+expected_args])
1198
# ------------------------------------------------------------------------
1201
# * add configuration options
1202
# - each group defines delivery info:
1203
# o whether to set Reply-To and/or Mail-Followup-To
1204
# (btw: it is legal do set Reply-To since this is the originator of the
1205
# mail; i.e. different from MLMs that munge it)
1206
# - each group defines content construction:
1207
# o max size of diff before trimming
1208
# o max size of entire commit message before truncation
1209
# - per-repository configuration
1210
# o extra config living in repos
1211
# o how to construct a ViewCVS URL for the diff [DONE (as patch)]
1212
# o optional, non-mail log file
1213
# o look up authors (username -> email; for the From: header) in a
1215
# - if the subject line gets too long, then trim it. configurable?
1216
# * get rid of global functions that should properly be class methods