Fix compiler warning due to missing function prototype.
[svn.git] / tools / hook-scripts / mailer / mailer.py
blob6c00fd3efffaba970093067622a8443ca14fe494
1 #!/usr/bin/env python
3 # mailer.py: send email describing a commit
5 # $HeadURL$
6 # $LastChangedDate$
7 # $LastChangedBy$
8 # $LastChangedRevision$
10 # USAGE: mailer.py commit REPOS REVISION [CONFIG-FILE]
11 # mailer.py propchange REPOS REVISION AUTHOR REVPROPNAME [CONFIG-FILE]
12 # mailer.py propchange2 REPOS REVISION AUTHOR REVPROPNAME ACTION \
13 # [CONFIG-FILE]
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 # See _MIN_SVN_VERSION below for which version of Subversion's Python
25 # bindings are required by this version of mailer.py.
27 import os
28 import sys
29 import string
30 import ConfigParser
31 import time
32 import popen2
33 import cStringIO
34 import smtplib
35 import re
36 import tempfile
37 import types
38 import urllib
40 # Minimal version of Subversion's bindings required
41 _MIN_SVN_VERSION = [1, 5, 0]
43 # Import the Subversion Python bindings, making sure they meet our
44 # minimum version requirements.
45 try:
46 import svn.fs
47 import svn.delta
48 import svn.repos
49 import svn.core
50 except ImportError:
51 sys.stderr.write(
52 "You need version %s or better of the Subversion Python bindings.\n" \
53 % string.join(map(lambda x: str(x), _MIN_SVN_VERSION), '.'))
54 sys.exit(1)
55 if _MIN_SVN_VERSION > [svn.core.SVN_VER_MAJOR,
56 svn.core.SVN_VER_MINOR,
57 svn.core.SVN_VER_PATCH]:
58 sys.stderr.write(
59 "You need version %s or better of the Subversion Python bindings.\n" \
60 % string.join(map(lambda x: str(x), _MIN_SVN_VERSION), '.'))
61 sys.exit(1)
64 SEPARATOR = '=' * 78
66 def main(pool, cmd, config_fname, repos_dir, cmd_args):
67 ### TODO: Sanity check the incoming args
69 if cmd == 'commit':
70 revision = int(cmd_args[0])
71 repos = Repository(repos_dir, revision, pool)
72 cfg = Config(config_fname, repos, { 'author' : repos.author })
73 messenger = Commit(pool, cfg, repos)
74 elif cmd == 'propchange' or cmd == 'propchange2':
75 revision = int(cmd_args[0])
76 author = cmd_args[1]
77 propname = cmd_args[2]
78 action = (cmd == 'propchange2' and cmd_args[3] or 'A')
79 repos = Repository(repos_dir, revision, pool)
80 # Override the repos revision author with the author of the propchange
81 repos.author = author
82 cfg = Config(config_fname, repos, { 'author' : author })
83 messenger = PropChange(pool, cfg, repos, author, propname, action)
84 elif cmd == 'lock' or cmd == 'unlock':
85 author = cmd_args[0]
86 repos = Repository(repos_dir, 0, pool) ### any old revision will do
87 # Override the repos revision author with the author of the lock/unlock
88 repos.author = author
89 cfg = Config(config_fname, repos, { 'author' : author })
90 messenger = Lock(pool, cfg, repos, author, cmd == 'lock')
91 else:
92 raise UnknownSubcommand(cmd)
94 messenger.generate()
97 # Minimal, incomplete, versions of popen2.Popen[34] for those platforms
98 # for which popen2 does not provide them.
99 try:
100 Popen3 = popen2.Popen3
101 Popen4 = popen2.Popen4
102 except AttributeError:
103 class Popen3:
104 def __init__(self, cmd, capturestderr = False):
105 if type(cmd) != types.StringType:
106 cmd = svn.core.argv_to_command_string(cmd)
107 if capturestderr:
108 self.fromchild, self.tochild, self.childerr \
109 = popen2.popen3(cmd, mode='b')
110 else:
111 self.fromchild, self.tochild = popen2.popen2(cmd, mode='b')
112 self.childerr = None
114 def wait(self):
115 rv = self.fromchild.close()
116 rv = self.tochild.close() or rv
117 if self.childerr is not None:
118 rv = self.childerr.close() or rv
119 return rv
121 class Popen4:
122 def __init__(self, cmd):
123 if type(cmd) != types.StringType:
124 cmd = svn.core.argv_to_command_string(cmd)
125 self.fromchild, self.tochild = popen2.popen4(cmd, mode='b')
127 def wait(self):
128 rv = self.fromchild.close()
129 rv = self.tochild.close() or rv
130 return rv
132 def remove_leading_slashes(path):
133 while path and path[0] == '/':
134 path = path[1:]
135 return path
138 class OutputBase:
139 "Abstract base class to formalize the inteface of output methods"
141 def __init__(self, cfg, repos, prefix_param):
142 self.cfg = cfg
143 self.repos = repos
144 self.prefix_param = prefix_param
145 self._CHUNKSIZE = 128 * 1024
147 # This is a public member variable. This must be assigned a suitable
148 # piece of descriptive text before make_subject() is called.
149 self.subject = ""
151 def make_subject(self, group, params):
152 prefix = self.cfg.get(self.prefix_param, group, params)
153 if prefix:
154 subject = prefix + ' ' + self.subject
155 else:
156 subject = self.subject
158 try:
159 truncate_subject = int(
160 self.cfg.get('truncate_subject', group, params))
161 except ValueError:
162 truncate_subject = 0
164 if truncate_subject and len(subject) > truncate_subject:
165 subject = subject[:(truncate_subject - 3)] + "..."
166 return subject
168 def start(self, group, params):
169 """Override this method.
170 Begin writing an output representation. GROUP is the name of the
171 configuration file group which is causing this output to be produced.
172 PARAMS is a dictionary of any named subexpressions of regular expressions
173 defined in the configuration file, plus the key 'author' contains the
174 author of the action being reported."""
175 raise NotImplementedError
177 def finish(self):
178 """Override this method.
179 Flush any cached information and finish writing the output
180 representation."""
181 raise NotImplementedError
183 def write(self, output):
184 """Override this method.
185 Append the literal text string OUTPUT to the output representation."""
186 raise NotImplementedError
188 def run(self, cmd):
189 """Override this method, if the default implementation is not sufficient.
190 Execute CMD, writing the stdout produced to the output representation."""
191 # By default we choose to incorporate child stderr into the output
192 pipe_ob = Popen4(cmd)
194 buf = pipe_ob.fromchild.read(self._CHUNKSIZE)
195 while buf:
196 self.write(buf)
197 buf = pipe_ob.fromchild.read(self._CHUNKSIZE)
199 # wait on the child so we don't end up with a billion zombies
200 pipe_ob.wait()
203 class MailedOutput(OutputBase):
204 def __init__(self, cfg, repos, prefix_param):
205 OutputBase.__init__(self, cfg, repos, prefix_param)
207 def start(self, group, params):
208 # whitespace (or another character) separated list of addresses
209 # which must be split into a clean list
210 to_addr_in = self.cfg.get('to_addr', group, params)
211 # if list of addresses starts with '[.]'
212 # use the character between the square brackets as split char
213 # else use whitespaces
214 if len(to_addr_in) >= 3 and to_addr_in[0] == '[' \
215 and to_addr_in[2] == ']':
216 self.to_addrs = \
217 filter(None, string.split(to_addr_in[3:], to_addr_in[1]))
218 else:
219 self.to_addrs = filter(None, string.split(to_addr_in))
220 self.from_addr = self.cfg.get('from_addr', group, params) \
221 or self.repos.author or 'no_author'
222 # if the from_addr (also) starts with '[.]' (may happen if one
223 # map is used for both to_addr and from_addr) remove '[.]'
224 if len(self.from_addr) >= 3 and self.from_addr[0] == '[' \
225 and self.from_addr[2] == ']':
226 self.from_addr = self.from_addr[3:]
227 self.reply_to = self.cfg.get('reply_to', group, params)
228 # if the reply_to (also) starts with '[.]' (may happen if one
229 # map is used for both to_addr and reply_to) remove '[.]'
230 if len(self.reply_to) >= 3 and self.reply_to[0] == '[' \
231 and self.reply_to[2] == ']':
232 self.reply_to = self.reply_to[3:]
234 def mail_headers(self, group, params):
235 subject = self.make_subject(group, params)
236 try:
237 subject.encode('ascii')
238 except UnicodeError:
239 from email.Header import Header
240 subject = Header(subject, 'utf-8').encode()
241 hdrs = 'From: %s\n' \
242 'To: %s\n' \
243 'Subject: %s\n' \
244 'MIME-Version: 1.0\n' \
245 'Content-Type: text/plain; charset=UTF-8\n' \
246 'Content-Transfer-Encoding: 8bit\n' \
247 % (self.from_addr, string.join(self.to_addrs, ', '), subject)
248 if self.reply_to:
249 hdrs = '%sReply-To: %s\n' % (hdrs, self.reply_to)
250 return hdrs + '\n'
253 class SMTPOutput(MailedOutput):
254 "Deliver a mail message to an MTA using SMTP."
256 def start(self, group, params):
257 MailedOutput.start(self, group, params)
259 self.buffer = cStringIO.StringIO()
260 self.write = self.buffer.write
262 self.write(self.mail_headers(group, params))
264 def finish(self):
265 server = smtplib.SMTP(self.cfg.general.smtp_hostname)
266 if self.cfg.is_set('general.smtp_username'):
267 server.login(self.cfg.general.smtp_username,
268 self.cfg.general.smtp_password)
269 server.sendmail(self.from_addr, self.to_addrs, self.buffer.getvalue())
270 server.quit()
273 class StandardOutput(OutputBase):
274 "Print the commit message to stdout."
276 def __init__(self, cfg, repos, prefix_param):
277 OutputBase.__init__(self, cfg, repos, prefix_param)
278 self.write = sys.stdout.write
280 def start(self, group, params):
281 self.write("Group: " + (group or "defaults") + "\n")
282 self.write("Subject: " + self.make_subject(group, params) + "\n\n")
284 def finish(self):
285 pass
288 class PipeOutput(MailedOutput):
289 "Deliver a mail message to an MTA via a pipe."
291 def __init__(self, cfg, repos, prefix_param):
292 MailedOutput.__init__(self, cfg, repos, prefix_param)
294 # figure out the command for delivery
295 self.cmd = string.split(cfg.general.mail_command)
297 def start(self, group, params):
298 MailedOutput.start(self, group, params)
300 ### gotta fix this. this is pretty specific to sendmail and qmail's
301 ### mailwrapper program. should be able to use option param substitution
302 cmd = self.cmd + [ '-f', self.from_addr ] + self.to_addrs
304 # construct the pipe for talking to the mailer
305 self.pipe = Popen3(cmd)
306 self.write = self.pipe.tochild.write
308 # we don't need the read-from-mailer descriptor, so close it
309 self.pipe.fromchild.close()
311 # start writing out the mail message
312 self.write(self.mail_headers(group, params))
314 def finish(self):
315 # signal that we're done sending content
316 self.pipe.tochild.close()
318 # wait to avoid zombies
319 self.pipe.wait()
322 class Messenger:
323 def __init__(self, pool, cfg, repos, prefix_param):
324 self.pool = pool
325 self.cfg = cfg
326 self.repos = repos
328 if cfg.is_set('general.mail_command'):
329 cls = PipeOutput
330 elif cfg.is_set('general.smtp_hostname'):
331 cls = SMTPOutput
332 else:
333 cls = StandardOutput
335 self.output = cls(cfg, repos, prefix_param)
338 class Commit(Messenger):
339 def __init__(self, pool, cfg, repos):
340 Messenger.__init__(self, pool, cfg, repos, 'commit_subject_prefix')
342 # get all the changes and sort by path
343 editor = svn.repos.ChangeCollector(repos.fs_ptr, repos.root_this, \
344 self.pool)
345 e_ptr, e_baton = svn.delta.make_editor(editor, self.pool)
346 svn.repos.replay(repos.root_this, e_ptr, e_baton, self.pool)
348 self.changelist = editor.get_changes().items()
349 self.changelist.sort()
351 # collect the set of groups and the unique sets of params for the options
352 self.groups = { }
353 for path, change in self.changelist:
354 for (group, params) in self.cfg.which_groups(path):
355 # turn the params into a hashable object and stash it away
356 param_list = params.items()
357 param_list.sort()
358 # collect the set of paths belonging to this group
359 if self.groups.has_key( (group, tuple(param_list)) ):
360 old_param, paths = self.groups[group, tuple(param_list)]
361 else:
362 paths = { }
363 paths[path] = None
364 self.groups[group, tuple(param_list)] = (params, paths)
366 # figure out the changed directories
367 dirs = { }
368 for path, change in self.changelist:
369 if change.item_kind == svn.core.svn_node_dir:
370 dirs[path] = None
371 else:
372 idx = string.rfind(path, '/')
373 if idx == -1:
374 dirs[''] = None
375 else:
376 dirs[path[:idx]] = None
378 dirlist = dirs.keys()
380 commondir, dirlist = get_commondir(dirlist)
382 # compose the basic subject line. later, we can prefix it.
383 dirlist.sort()
384 dirlist = string.join(dirlist)
385 if commondir:
386 self.output.subject = 'r%d - in %s: %s' % (repos.rev, commondir, dirlist)
387 else:
388 self.output.subject = 'r%d - %s' % (repos.rev, dirlist)
390 def generate(self):
391 "Generate email for the various groups and option-params."
393 ### the groups need to be further compressed. if the headers and
394 ### body are the same across groups, then we can have multiple To:
395 ### addresses. SMTPOutput holds the entire message body in memory,
396 ### so if the body doesn't change, then it can be sent N times
397 ### rather than rebuilding it each time.
399 subpool = svn.core.svn_pool_create(self.pool)
401 # build a renderer, tied to our output stream
402 renderer = TextCommitRenderer(self.output)
404 for (group, param_tuple), (params, paths) in self.groups.items():
405 self.output.start(group, params)
407 # generate the content for this group and set of params
408 generate_content(renderer, self.cfg, self.repos, self.changelist,
409 group, params, paths, subpool)
411 self.output.finish()
412 svn.core.svn_pool_clear(subpool)
414 svn.core.svn_pool_destroy(subpool)
417 try:
418 from tempfile import NamedTemporaryFile
419 except ImportError:
420 # NamedTemporaryFile was added in Python 2.3, so we need to emulate it
421 # for older Pythons.
422 class NamedTemporaryFile:
423 def __init__(self):
424 self.name = tempfile.mktemp()
425 self.file = open(self.name, 'w+b')
426 def __del__(self):
427 os.remove(self.name)
428 def write(self, data):
429 self.file.write(data)
430 def flush(self):
431 self.file.flush()
434 class PropChange(Messenger):
435 def __init__(self, pool, cfg, repos, author, propname, action):
436 Messenger.__init__(self, pool, cfg, repos, 'propchange_subject_prefix')
437 self.author = author
438 self.propname = propname
439 self.action = action
441 # collect the set of groups and the unique sets of params for the options
442 self.groups = { }
443 for (group, params) in self.cfg.which_groups(''):
444 # turn the params into a hashable object and stash it away
445 param_list = params.items()
446 param_list.sort()
447 self.groups[group, tuple(param_list)] = params
449 self.output.subject = 'r%d - %s' % (repos.rev, propname)
451 def generate(self):
452 actions = { 'A': 'added', 'M': 'modified', 'D': 'deleted' }
453 for (group, param_tuple), params in self.groups.items():
454 self.output.start(group, params)
455 self.output.write('Author: %s\n'
456 'Revision: %s\n'
457 'Property Name: %s\n'
458 'Action: %s\n'
459 '\n'
460 % (self.author, self.repos.rev, self.propname,
461 actions.get(self.action, 'Unknown (\'%s\')' \
462 % self.action)))
463 if self.action == 'A' or not actions.has_key(self.action):
464 self.output.write('Property value:\n')
465 propvalue = self.repos.get_rev_prop(self.propname)
466 self.output.write(propvalue)
467 elif self.action == 'M':
468 self.output.write('Property diff:\n')
469 tempfile1 = NamedTemporaryFile()
470 tempfile1.write(sys.stdin.read())
471 tempfile1.flush()
472 tempfile2 = NamedTemporaryFile()
473 tempfile2.write(self.repos.get_rev_prop(self.propname))
474 tempfile2.flush()
475 self.output.run(self.cfg.get_diff_cmd(group, {
476 'label_from' : 'old property value',
477 'label_to' : 'new property value',
478 'from' : tempfile1.name,
479 'to' : tempfile2.name,
481 self.output.finish()
484 def get_commondir(dirlist):
485 """Figure out the common portion/parent (commondir) of all the paths
486 in DIRLIST and return a tuple consisting of commondir, dirlist. If
487 a commondir is found, the dirlist returned is rooted in that
488 commondir. If no commondir is found, dirlist is returned unchanged,
489 and commondir is the empty string."""
490 if len(dirlist) < 2 or '/' in dirlist:
491 commondir = ''
492 newdirs = dirlist
493 else:
494 common = string.split(dirlist[0], '/')
495 for j in range(1, len(dirlist)):
496 d = dirlist[j]
497 parts = string.split(d, '/')
498 for i in range(len(common)):
499 if i == len(parts) or common[i] != parts[i]:
500 del common[i:]
501 break
502 commondir = string.join(common, '/')
503 if commondir:
504 # strip the common portion from each directory
505 l = len(commondir) + 1
506 newdirs = [ ]
507 for d in dirlist:
508 if d == commondir:
509 newdirs.append('.')
510 else:
511 newdirs.append(d[l:])
512 else:
513 # nothing in common, so reset the list of directories
514 newdirs = dirlist
516 return commondir, newdirs
519 class Lock(Messenger):
520 def __init__(self, pool, cfg, repos, author, do_lock):
521 self.author = author
522 self.do_lock = do_lock
524 Messenger.__init__(self, pool, cfg, repos,
525 (do_lock and 'lock_subject_prefix'
526 or 'unlock_subject_prefix'))
528 # read all the locked paths from STDIN and strip off the trailing newlines
529 self.dirlist = map(lambda x: x.rstrip(), sys.stdin.readlines())
531 # collect the set of groups and the unique sets of params for the options
532 self.groups = { }
533 for path in self.dirlist:
534 for (group, params) in self.cfg.which_groups(path):
535 # turn the params into a hashable object and stash it away
536 param_list = params.items()
537 param_list.sort()
538 # collect the set of paths belonging to this group
539 if self.groups.has_key( (group, tuple(param_list)) ):
540 old_param, paths = self.groups[group, tuple(param_list)]
541 else:
542 paths = { }
543 paths[path] = None
544 self.groups[group, tuple(param_list)] = (params, paths)
546 commondir, dirlist = get_commondir(self.dirlist)
548 # compose the basic subject line. later, we can prefix it.
549 dirlist.sort()
550 dirlist = string.join(dirlist)
551 if commondir:
552 self.output.subject = '%s: %s' % (commondir, dirlist)
553 else:
554 self.output.subject = '%s' % (dirlist)
556 # The lock comment is the same for all paths, so we can just pull
557 # the comment for the first path in the dirlist and cache it.
558 self.lock = svn.fs.svn_fs_get_lock(self.repos.fs_ptr,
559 self.dirlist[0], self.pool)
561 def generate(self):
562 for (group, param_tuple), (params, paths) in self.groups.items():
563 self.output.start(group, params)
565 self.output.write('Author: %s\n'
566 '%s paths:\n' %
567 (self.author, self.do_lock and 'Locked' or 'Unlocked'))
569 self.dirlist.sort()
570 for dir in self.dirlist:
571 self.output.write(' %s\n\n' % dir)
573 if self.do_lock:
574 self.output.write('Comment:\n%s\n' % (self.lock.comment or ''))
576 self.output.finish()
579 class DiffSelections:
580 def __init__(self, cfg, group, params):
581 self.add = False
582 self.copy = False
583 self.delete = False
584 self.modify = False
586 gen_diffs = cfg.get('generate_diffs', group, params)
588 ### Do a little dance for deprecated options. Note that even if you
589 ### don't have an option anywhere in your configuration file, it
590 ### still gets returned as non-None.
591 if len(gen_diffs):
592 list = string.split(gen_diffs, " ")
593 for item in list:
594 if item == 'add':
595 self.add = True
596 if item == 'copy':
597 self.copy = True
598 if item == 'delete':
599 self.delete = True
600 if item == 'modify':
601 self.modify = True
602 else:
603 self.add = True
604 self.copy = True
605 self.delete = True
606 self.modify = True
607 ### These options are deprecated
608 suppress = cfg.get('suppress_deletes', group, params)
609 if suppress == 'yes':
610 self.delete = False
611 suppress = cfg.get('suppress_adds', group, params)
612 if suppress == 'yes':
613 self.add = False
616 class DiffURLSelections:
617 def __init__(self, cfg, group, params):
618 self.cfg = cfg
619 self.group = group
620 self.params = params
622 def _get_url(self, action, repos_rev, change):
623 # The parameters for the URLs generation need to be placed in the
624 # parameters for the configuration module, otherwise we may get
625 # KeyError exceptions.
626 params = self.params.copy()
627 params['path'] = change.path and urllib.quote(change.path) or None
628 params['base_path'] = change.base_path and urllib.quote(change.base_path) \
629 or None
630 params['rev'] = repos_rev
631 params['base_rev'] = change.base_rev
633 return self.cfg.get("diff_%s_url" % action, self.group, params)
635 def get_add_url(self, repos_rev, change):
636 return self._get_url('add', repos_rev, change)
638 def get_copy_url(self, repos_rev, change):
639 return self._get_url('copy', repos_rev, change)
641 def get_delete_url(self, repos_rev, change):
642 return self._get_url('delete', repos_rev, change)
644 def get_modify_url(self, repos_rev, change):
645 return self._get_url('modify', repos_rev, change)
647 def generate_content(renderer, cfg, repos, changelist, group, params, paths,
648 pool):
650 svndate = repos.get_rev_prop(svn.core.SVN_PROP_REVISION_DATE)
651 ### pick a different date format?
652 date = time.ctime(svn.core.secs_from_timestr(svndate, pool))
654 diffsels = DiffSelections(cfg, group, params)
655 diffurls = DiffURLSelections(cfg, group, params)
657 show_nonmatching_paths = cfg.get('show_nonmatching_paths', group, params) \
658 or 'yes'
660 params_with_rev = params.copy()
661 params_with_rev['rev'] = repos.rev
662 commit_url = cfg.get('commit_url', group, params_with_rev)
664 # figure out the lists of changes outside the selected path-space
665 other_added_data = other_replaced_data = other_deleted_data = \
666 other_modified_data = [ ]
667 if len(paths) != len(changelist) and show_nonmatching_paths != 'no':
668 other_added_data = generate_list('A', changelist, paths, False)
669 other_replaced_data = generate_list('R', changelist, paths, False)
670 other_deleted_data = generate_list('D', changelist, paths, False)
671 other_modified_data = generate_list('M', changelist, paths, False)
673 if len(paths) != len(changelist) and show_nonmatching_paths == 'yes':
674 other_diffs = DiffGenerator(changelist, paths, False, cfg, repos, date,
675 group, params, diffsels, diffurls, pool)
676 else:
677 other_diffs = None
679 data = _data(
680 author=repos.author,
681 date=date,
682 rev=repos.rev,
683 log=repos.get_rev_prop(svn.core.SVN_PROP_REVISION_LOG) or '',
684 commit_url=commit_url,
685 added_data=generate_list('A', changelist, paths, True),
686 replaced_data=generate_list('R', changelist, paths, True),
687 deleted_data=generate_list('D', changelist, paths, True),
688 modified_data=generate_list('M', changelist, paths, True),
689 show_nonmatching_paths=show_nonmatching_paths,
690 other_added_data=other_added_data,
691 other_replaced_data=other_replaced_data,
692 other_deleted_data=other_deleted_data,
693 other_modified_data=other_modified_data,
694 diffs=DiffGenerator(changelist, paths, True, cfg, repos, date, group,
695 params, diffsels, diffurls, pool),
696 other_diffs=other_diffs,
698 renderer.render(data)
701 def generate_list(changekind, changelist, paths, in_paths):
702 if changekind == 'A':
703 selection = lambda change: change.action == svn.repos.CHANGE_ACTION_ADD
704 elif changekind == 'R':
705 selection = lambda change: change.action == svn.repos.CHANGE_ACTION_REPLACE
706 elif changekind == 'D':
707 selection = lambda change: change.action == svn.repos.CHANGE_ACTION_DELETE
708 elif changekind == 'M':
709 selection = lambda change: change.action == svn.repos.CHANGE_ACTION_MODIFY
711 items = [ ]
712 for path, change in changelist:
713 if selection(change) and paths.has_key(path) == in_paths:
714 item = _data(
715 path=path,
716 is_dir=change.item_kind == svn.core.svn_node_dir,
717 props_changed=change.prop_changes,
718 text_changed=change.text_changed,
719 copied=(change.action == svn.repos.CHANGE_ACTION_ADD \
720 or change.action == svn.repos.CHANGE_ACTION_REPLACE) \
721 and change.base_path,
722 base_path=remove_leading_slashes(change.base_path),
723 base_rev=change.base_rev,
725 items.append(item)
727 return items
730 class DiffGenerator:
731 "This is a generator-like object returning DiffContent objects."
733 def __init__(self, changelist, paths, in_paths, cfg, repos, date, group,
734 params, diffsels, diffurls, pool):
735 self.changelist = changelist
736 self.paths = paths
737 self.in_paths = in_paths
738 self.cfg = cfg
739 self.repos = repos
740 self.date = date
741 self.group = group
742 self.params = params
743 self.diffsels = diffsels
744 self.diffurls = diffurls
745 self.pool = pool
747 self.diff = self.diff_url = None
749 self.idx = 0
751 def __nonzero__(self):
752 # we always have some items
753 return True
755 def __getitem__(self, idx):
756 while 1:
757 if self.idx == len(self.changelist):
758 raise IndexError
760 path, change = self.changelist[self.idx]
761 self.idx = self.idx + 1
763 diff = diff_url = None
764 kind = None
765 label1 = None
766 label2 = None
767 src_fname = None
768 dst_fname = None
769 binary = None
770 singular = None
771 content = None
773 # just skip directories. they have no diffs.
774 if change.item_kind == svn.core.svn_node_dir:
775 continue
777 # is this change in (or out of) the set of matched paths?
778 if self.paths.has_key(path) != self.in_paths:
779 continue
781 if change.base_rev != -1:
782 svndate = self.repos.get_rev_prop(svn.core.SVN_PROP_REVISION_DATE,
783 change.base_rev)
784 ### pick a different date format?
785 base_date = time.ctime(svn.core.secs_from_timestr(svndate, self.pool))
786 else:
787 base_date = ''
789 # figure out if/how to generate a diff
791 base_path = remove_leading_slashes(change.base_path)
792 if change.action == svn.repos.CHANGE_ACTION_DELETE:
793 # it was delete.
794 kind = 'D'
796 # get the diff url, if any is specified
797 diff_url = self.diffurls.get_delete_url(self.repos.rev, change)
799 # show the diff?
800 if self.diffsels.delete:
801 diff = svn.fs.FileDiff(self.repos.get_root(change.base_rev),
802 base_path, None, None, self.pool)
804 label1 = '%s\t%s\t(r%s)' % (base_path, self.date, change.base_rev)
805 label2 = '/dev/null\t00:00:00 1970\t(deleted)'
806 singular = True
808 elif change.action == svn.repos.CHANGE_ACTION_ADD \
809 or change.action == svn.repos.CHANGE_ACTION_REPLACE:
810 if base_path and (change.base_rev != -1):
812 # any diff of interest?
813 if change.text_changed:
814 # this file was copied and modified.
815 kind = 'W'
817 # get the diff url, if any is specified
818 diff_url = self.diffurls.get_copy_url(self.repos.rev, change)
820 # show the diff?
821 if self.diffsels.modify:
822 diff = svn.fs.FileDiff(self.repos.get_root(change.base_rev),
823 base_path,
824 self.repos.root_this, change.path,
825 self.pool)
826 label1 = '%s\t%s\t(r%s, copy source)' \
827 % (base_path, base_date, change.base_rev)
828 label2 = '%s\t%s\t(r%s)' \
829 % (change.path, self.date, self.repos.rev)
830 singular = False
831 else:
832 # this file was copied.
833 kind = 'C'
834 if self.diffsels.copy:
835 diff = svn.fs.FileDiff(None, None, self.repos.root_this,
836 change.path, self.pool)
837 label1 = '/dev/null\t00:00:00 1970\t' \
838 '(empty, because file is newly added)'
839 label2 = '%s\t%s\t(r%s, copy of r%s, %s)' \
840 % (change.path, self.date, self.repos.rev, \
841 change.base_rev, base_path)
842 singular = False
843 else:
844 # the file was added.
845 kind = 'A'
847 # get the diff url, if any is specified
848 diff_url = self.diffurls.get_add_url(self.repos.rev, change)
850 # show the diff?
851 if self.diffsels.add:
852 diff = svn.fs.FileDiff(None, None, self.repos.root_this,
853 change.path, self.pool)
854 label1 = '/dev/null\t00:00:00 1970\t' \
855 '(empty, because file is newly added)'
856 label2 = '%s\t%s\t(r%s)' \
857 % (change.path, self.date, self.repos.rev)
858 singular = True
860 elif not change.text_changed:
861 # the text didn't change, so nothing to show.
862 continue
863 else:
864 # a simple modification.
865 kind = 'M'
867 # get the diff url, if any is specified
868 diff_url = self.diffurls.get_modify_url(self.repos.rev, change)
870 # show the diff?
871 if self.diffsels.modify:
872 diff = svn.fs.FileDiff(self.repos.get_root(change.base_rev),
873 base_path,
874 self.repos.root_this, change.path,
875 self.pool)
876 label1 = '%s\t%s\t(r%s)' \
877 % (base_path, base_date, change.base_rev)
878 label2 = '%s\t%s\t(r%s)' \
879 % (change.path, self.date, self.repos.rev)
880 singular = False
882 if diff:
883 binary = diff.either_binary()
884 if binary:
885 content = src_fname = dst_fname = None
886 else:
887 src_fname, dst_fname = diff.get_files()
888 content = DiffContent(self.cfg.get_diff_cmd(self.group, {
889 'label_from' : label1,
890 'label_to' : label2,
891 'from' : src_fname,
892 'to' : dst_fname,
895 # return a data item for this diff
896 return _data(
897 path=change.path,
898 base_path=base_path,
899 base_rev=change.base_rev,
900 diff=diff,
901 diff_url=diff_url,
902 kind=kind,
903 label_from=label1,
904 label_to=label2,
905 from_fname=src_fname,
906 to_fname=dst_fname,
907 binary=binary,
908 singular=singular,
909 content=content,
913 class DiffContent:
914 "This is a generator-like object returning annotated lines of a diff."
916 def __init__(self, cmd):
917 self.seen_change = False
919 # By default we choose to incorporate child stderr into the output
920 self.pipe = Popen4(cmd)
922 def __nonzero__(self):
923 # we always have some items
924 return True
926 def __getitem__(self, idx):
927 if self.pipe is None:
928 raise IndexError
930 line = self.pipe.fromchild.readline()
931 if not line:
932 # wait on the child so we don't end up with a billion zombies
933 self.pipe.wait()
934 self.pipe = None
935 raise IndexError
937 # classify the type of line.
938 first = line[:1]
939 if first == '@':
940 self.seen_change = True
941 ltype = 'H'
942 elif first == '-':
943 if self.seen_change:
944 ltype = 'D'
945 else:
946 ltype = 'F'
947 elif first == '+':
948 if self.seen_change:
949 ltype = 'A'
950 else:
951 ltype = 'T'
952 elif first == ' ':
953 ltype = 'C'
954 else:
955 ltype = 'U'
957 if line[-2] == '\r':
958 line=line[0:-2] + '\n' # remove carriage return
960 return _data(
961 raw=line,
962 text=line[1:-1], # remove indicator and newline
963 type=ltype,
967 class TextCommitRenderer:
968 "This class will render the commit mail in plain text."
970 def __init__(self, output):
971 self.output = output
973 def render(self, data):
974 "Render the commit defined by 'data'."
976 w = self.output.write
978 w('Author: %s\nDate: %s\nNew Revision: %s\n' % (data.author,
979 data.date,
980 data.rev))
982 if data.commit_url:
983 w('URL: %s\n\n' % data.commit_url)
984 else:
985 w('\n')
987 w('Log:\n%s\n\n' % data.log.strip())
989 # print summary sections
990 self._render_list('Added', data.added_data)
991 self._render_list('Replaced', data.replaced_data)
992 self._render_list('Deleted', data.deleted_data)
993 self._render_list('Modified', data.modified_data)
995 if data.other_added_data or data.other_replaced_data \
996 or data.other_deleted_data or data.other_modified_data:
997 if data.show_nonmatching_paths:
998 w('\nChanges in other areas also in this revision:\n')
999 self._render_list('Added', data.other_added_data)
1000 self._render_list('Replaced', data.other_replaced_data)
1001 self._render_list('Deleted', data.other_deleted_data)
1002 self._render_list('Modified', data.other_modified_data)
1003 else:
1004 w('and changes in other areas\n')
1006 self._render_diffs(data.diffs, '')
1007 if data.other_diffs:
1008 self._render_diffs(data.other_diffs,
1009 '\nDiffs of changes in other areas also'
1010 ' in this revision:\n')
1012 def _render_list(self, header, data_list):
1013 if not data_list:
1014 return
1016 w = self.output.write
1017 w(header + ':\n')
1018 for d in data_list:
1019 if d.is_dir:
1020 is_dir = '/'
1021 else:
1022 is_dir = ''
1023 if d.props_changed:
1024 if d.text_changed:
1025 props = ' (contents, props changed)'
1026 else:
1027 props = ' (props changed)'
1028 else:
1029 props = ''
1030 w(' %s%s%s\n' % (d.path, is_dir, props))
1031 if d.copied:
1032 if is_dir:
1033 text = ''
1034 elif d.text_changed:
1035 text = ', changed'
1036 else:
1037 text = ' unchanged'
1038 w(' - copied%s from r%d, %s%s\n'
1039 % (text, d.base_rev, d.base_path, is_dir))
1041 def _render_diffs(self, diffs, section_header):
1042 """Render diffs. Write the SECTION_HEADER if there are actually
1043 any diffs to render."""
1044 if not diffs:
1045 return
1046 w = self.output.write
1047 section_header_printed = False
1049 for diff in diffs:
1050 if not diff.diff and not diff.diff_url:
1051 continue
1052 if not section_header_printed:
1053 w(section_header)
1054 section_header_printed = True
1055 if diff.kind == 'D':
1056 w('\nDeleted: %s\n' % diff.base_path)
1057 elif diff.kind == 'A':
1058 w('\nAdded: %s\n' % diff.path)
1059 elif diff.kind == 'C':
1060 w('\nCopied: %s (from r%d, %s)\n'
1061 % (diff.path, diff.base_rev, diff.base_path))
1062 elif diff.kind == 'W':
1063 w('\nCopied and modified: %s (from r%d, %s)\n'
1064 % (diff.path, diff.base_rev, diff.base_path))
1065 else:
1066 # kind == 'M'
1067 w('\nModified: %s\n' % diff.path)
1069 if diff.diff_url:
1070 w('URL: %s\n' % diff.diff_url)
1072 if not diff.diff:
1073 continue
1075 w(SEPARATOR + '\n')
1077 if diff.binary:
1078 if diff.singular:
1079 w('Binary file. No diff available.\n')
1080 else:
1081 w('Binary file (source and/or target). No diff available.\n')
1082 continue
1084 for line in diff.content:
1085 w(line.raw)
1088 class Repository:
1089 "Hold roots and other information about the repository."
1091 def __init__(self, repos_dir, rev, pool):
1092 self.repos_dir = repos_dir
1093 self.rev = rev
1094 self.pool = pool
1096 self.repos_ptr = svn.repos.open(repos_dir, pool)
1097 self.fs_ptr = svn.repos.fs(self.repos_ptr)
1099 self.roots = { }
1101 self.root_this = self.get_root(rev)
1103 self.author = self.get_rev_prop(svn.core.SVN_PROP_REVISION_AUTHOR)
1105 def get_rev_prop(self, propname, rev = None):
1106 if not rev:
1107 rev = self.rev
1108 return svn.fs.revision_prop(self.fs_ptr, rev, propname, self.pool)
1110 def get_root(self, rev):
1111 try:
1112 return self.roots[rev]
1113 except KeyError:
1114 pass
1115 root = self.roots[rev] = svn.fs.revision_root(self.fs_ptr, rev, self.pool)
1116 return root
1119 class Config:
1121 # The predefined configuration sections. These are omitted from the
1122 # set of groups.
1123 _predefined = ('general', 'defaults', 'maps')
1125 def __init__(self, fname, repos, global_params):
1126 cp = ConfigParser.ConfigParser()
1127 cp.read(fname)
1129 # record the (non-default) groups that we find
1130 self._groups = [ ]
1132 for section in cp.sections():
1133 if not hasattr(self, section):
1134 section_ob = _sub_section()
1135 setattr(self, section, section_ob)
1136 if section not in self._predefined:
1137 self._groups.append(section)
1138 else:
1139 section_ob = getattr(self, section)
1140 for option in cp.options(section):
1141 # get the raw value -- we use the same format for *our* interpolation
1142 value = cp.get(section, option, raw=1)
1143 setattr(section_ob, option, value)
1145 # be compatible with old format config files
1146 if hasattr(self.general, 'diff') and not hasattr(self.defaults, 'diff'):
1147 self.defaults.diff = self.general.diff
1148 if not hasattr(self, 'maps'):
1149 self.maps = _sub_section()
1151 # these params are always available, although they may be overridden
1152 self._global_params = global_params.copy()
1154 # prepare maps. this may remove sections from consideration as a group.
1155 self._prep_maps()
1157 # process all the group sections.
1158 self._prep_groups(repos)
1160 def is_set(self, option):
1161 """Return None if the option is not set; otherwise, its value is returned.
1163 The option is specified as a dotted symbol, such as 'general.mail_command'
1165 ob = self
1166 for part in string.split(option, '.'):
1167 if not hasattr(ob, part):
1168 return None
1169 ob = getattr(ob, part)
1170 return ob
1172 def get(self, option, group, params):
1173 "Get a config value with appropriate substitutions and value mapping."
1175 # find the right value
1176 value = None
1177 if group:
1178 sub = getattr(self, group)
1179 value = getattr(sub, option, None)
1180 if value is None:
1181 value = getattr(self.defaults, option, '')
1183 # parameterize it
1184 if params is not None:
1185 value = value % params
1187 # apply any mapper
1188 mapper = getattr(self.maps, option, None)
1189 if mapper is not None:
1190 value = mapper(value)
1192 # Apply any parameters that may now be available for
1193 # substitution that were not before the mapping.
1194 if value is not None and params is not None:
1195 value = value % params
1197 return value
1199 def get_diff_cmd(self, group, args):
1200 "Get a diff command as a list of argv elements."
1201 ### do some better splitting to enable quoting of spaces
1202 diff_cmd = string.split(self.get('diff', group, None))
1204 cmd = [ ]
1205 for part in diff_cmd:
1206 cmd.append(part % args)
1207 return cmd
1209 def _prep_maps(self):
1210 "Rewrite the [maps] options into callables that look up values."
1212 mapsections = []
1214 for optname, mapvalue in vars(self.maps).items():
1215 if mapvalue[:1] == '[':
1216 # a section is acting as a mapping
1217 sectname = mapvalue[1:-1]
1218 if not hasattr(self, sectname):
1219 raise UnknownMappingSection(sectname)
1220 # construct a lambda to look up the given value as an option name,
1221 # and return the option's value. if the option is not present,
1222 # then just return the value unchanged.
1223 setattr(self.maps, optname,
1224 lambda value,
1225 sect=getattr(self, sectname): getattr(sect,
1226 value.lower(),
1227 value))
1228 # mark for removal when all optnames are done
1229 if sectname not in mapsections:
1230 mapsections.append(sectname)
1232 # elif test for other mapper types. possible examples:
1233 # dbm:filename.db
1234 # file:two-column-file.txt
1235 # ldap:some-query-spec
1236 # just craft a mapper function and insert it appropriately
1238 else:
1239 raise UnknownMappingSpec(mapvalue)
1241 # remove each mapping section from consideration as a group
1242 for sectname in mapsections:
1243 self._groups.remove(sectname)
1246 def _prep_groups(self, repos):
1247 self._group_re = [ ]
1249 repos_dir = os.path.abspath(repos.repos_dir)
1251 # compute the default repository-based parameters. start with some
1252 # basic parameters, then bring in the regex-based params.
1253 self._default_params = self._global_params
1255 try:
1256 match = re.match(self.defaults.for_repos, repos_dir)
1257 if match:
1258 self._default_params = self._default_params.copy()
1259 self._default_params.update(match.groupdict())
1260 except AttributeError:
1261 # there is no self.defaults.for_repos
1262 pass
1264 # select the groups that apply to this repository
1265 for group in self._groups:
1266 sub = getattr(self, group)
1267 params = self._default_params
1268 if hasattr(sub, 'for_repos'):
1269 match = re.match(sub.for_repos, repos_dir)
1270 if not match:
1271 continue
1272 params = params.copy()
1273 params.update(match.groupdict())
1275 # if a matching rule hasn't been given, then use the empty string
1276 # as it will match all paths
1277 for_paths = getattr(sub, 'for_paths', '')
1278 exclude_paths = getattr(sub, 'exclude_paths', None)
1279 if exclude_paths:
1280 exclude_paths_re = re.compile(exclude_paths)
1281 else:
1282 exclude_paths_re = None
1284 self._group_re.append((group, re.compile(for_paths),
1285 exclude_paths_re, params))
1287 # after all the groups are done, add in the default group
1288 try:
1289 self._group_re.append((None,
1290 re.compile(self.defaults.for_paths),
1291 None,
1292 self._default_params))
1293 except AttributeError:
1294 # there is no self.defaults.for_paths
1295 pass
1297 def which_groups(self, path):
1298 "Return the path's associated groups."
1299 groups = []
1300 for group, pattern, exclude_pattern, repos_params in self._group_re:
1301 match = pattern.match(path)
1302 if match:
1303 if exclude_pattern and exclude_pattern.match(path):
1304 continue
1305 params = repos_params.copy()
1306 params.update(match.groupdict())
1307 groups.append((group, params))
1308 if not groups:
1309 groups.append((None, self._default_params))
1310 return groups
1313 class _sub_section:
1314 pass
1316 class _data:
1317 "Helper class to define an attribute-based hunk o' data."
1318 def __init__(self, **kw):
1319 vars(self).update(kw)
1321 class MissingConfig(Exception):
1322 pass
1323 class UnknownMappingSection(Exception):
1324 pass
1325 class UnknownMappingSpec(Exception):
1326 pass
1327 class UnknownSubcommand(Exception):
1328 pass
1331 # enable True/False in older vsns of Python
1332 try:
1333 _unused = True
1334 except NameError:
1335 True = 1
1336 False = 0
1339 if __name__ == '__main__':
1340 def usage():
1341 scriptname = os.path.basename(sys.argv[0])
1342 sys.stderr.write(
1343 """USAGE: %s commit REPOS REVISION [CONFIG-FILE]
1344 %s propchange REPOS REVISION AUTHOR REVPROPNAME [CONFIG-FILE]
1345 %s propchange2 REPOS REVISION AUTHOR REVPROPNAME ACTION [CONFIG-FILE]
1346 %s lock REPOS AUTHOR [CONFIG-FILE]
1347 %s unlock REPOS AUTHOR [CONFIG-FILE]
1349 If no CONFIG-FILE is provided, the script will first search for a mailer.conf
1350 file in REPOS/conf/. Failing that, it will search the directory in which
1351 the script itself resides.
1353 ACTION was added as a fifth argument to the post-revprop-change hook
1354 in Subversion 1.2.0. Its value is one of 'A', 'M' or 'D' to indicate
1355 if the property was added, modified or deleted, respectively.
1357 """ % (scriptname, scriptname, scriptname, scriptname, scriptname))
1358 sys.exit(1)
1360 # Command list: subcommand -> number of arguments expected (not including
1361 # the repository directory and config-file)
1362 cmd_list = {'commit' : 1,
1363 'propchange' : 3,
1364 'propchange2': 4,
1365 'lock' : 1,
1366 'unlock' : 1,
1369 config_fname = None
1370 argc = len(sys.argv)
1371 if argc < 3:
1372 usage()
1374 cmd = sys.argv[1]
1375 repos_dir = svn.core.svn_path_canonicalize(sys.argv[2])
1376 try:
1377 expected_args = cmd_list[cmd]
1378 except KeyError:
1379 usage()
1381 if argc < (expected_args + 3):
1382 usage()
1383 elif argc > expected_args + 4:
1384 usage()
1385 elif argc == (expected_args + 4):
1386 config_fname = sys.argv[expected_args + 3]
1388 # Settle on a config file location, and open it.
1389 if config_fname is None:
1390 # Default to REPOS-DIR/conf/mailer.conf.
1391 config_fname = os.path.join(repos_dir, 'conf', 'mailer.conf')
1392 if not os.path.exists(config_fname):
1393 # Okay. Look for 'mailer.conf' as a sibling of this script.
1394 config_fname = os.path.join(os.path.dirname(sys.argv[0]), 'mailer.conf')
1395 if not os.path.exists(config_fname):
1396 raise MissingConfig(config_fname)
1398 svn.core.run_app(main, cmd, config_fname, repos_dir,
1399 sys.argv[3:3+expected_args])
1401 # ------------------------------------------------------------------------
1402 # TODO
1404 # * add configuration options
1405 # - each group defines delivery info:
1406 # o whether to set Reply-To and/or Mail-Followup-To
1407 # (btw: it is legal do set Reply-To since this is the originator of the
1408 # mail; i.e. different from MLMs that munge it)
1409 # - each group defines content construction:
1410 # o max size of diff before trimming
1411 # o max size of entire commit message before truncation
1412 # - per-repository configuration
1413 # o extra config living in repos
1414 # o optional, non-mail log file
1415 # o look up authors (username -> email; for the From: header) in a
1416 # file(s) or DBM
1417 # * get rid of global functions that should properly be class methods