| 1 | #!/usr/bin/env python
|
|---|
| 2 | #
|
|---|
| 3 | # mailer.py: send email describing a commit
|
|---|
| 4 | #
|
|---|
| 5 | # $HeadURL: http://svn.collab.net/repos/svn/branches/1.1.x/tools/hook-scripts/mailer/mailer.py $
|
|---|
| 6 | # $LastChangedDate: 2005-03-26 12:14:01 -0800 (Sat, 26 Mar 2005) $
|
|---|
| 7 | # $LastChangedBy: maxb $
|
|---|
| 8 | # $LastChangedRevision: 13698 $
|
|---|
| 9 | #
|
|---|
| 10 | # USAGE: mailer.py commit REPOS-DIR REVISION [CONFIG-FILE]
|
|---|
| 11 | # mailer.py propchange REPOS-DIR REVISION AUTHOR PROPNAME [CONFIG-FILE]
|
|---|
| 12 | #
|
|---|
| 13 | # Using CONFIG-FILE, deliver an email describing the changes between
|
|---|
| 14 | # REV and REV-1 for the repository REPOS.
|
|---|
| 15 | #
|
|---|
| 16 |
|
|---|
| 17 | import os
|
|---|
| 18 | import sys
|
|---|
| 19 | import string
|
|---|
| 20 | import ConfigParser
|
|---|
| 21 | import time
|
|---|
| 22 | import popen2
|
|---|
| 23 | import cStringIO
|
|---|
| 24 | import smtplib
|
|---|
| 25 | import re
|
|---|
| 26 | import types
|
|---|
| 27 |
|
|---|
| 28 | import svn.fs
|
|---|
| 29 | import svn.delta
|
|---|
| 30 | import svn.repos
|
|---|
| 31 | import svn.core
|
|---|
| 32 |
|
|---|
| 33 | SEPARATOR = '=' * 78
|
|---|
| 34 |
|
|---|
| 35 |
|
|---|
| 36 | def main(pool, cmd, config_fname, repos_dir, rev, author, propname):
|
|---|
| 37 | repos = Repository(repos_dir, rev, pool)
|
|---|
| 38 |
|
|---|
| 39 | if cmd == 'commit':
|
|---|
| 40 | cfg = Config(config_fname, repos, { 'author' : author or repos.author })
|
|---|
| 41 | messenger = Commit(pool, cfg, repos)
|
|---|
| 42 | elif cmd == 'propchange':
|
|---|
| 43 | # Override the repos revision author with the author of the propchange
|
|---|
| 44 | repos.author = author
|
|---|
| 45 | cfg = Config(config_fname, repos, { 'author' : author })
|
|---|
| 46 | messenger = PropChange(pool, cfg, repos, author, propname)
|
|---|
| 47 | else:
|
|---|
| 48 | raise UnknownSubcommand(cmd)
|
|---|
| 49 |
|
|---|
| 50 | messenger.generate()
|
|---|
| 51 |
|
|---|
| 52 |
|
|---|
| 53 | # ============================================================================
|
|---|
| 54 | if sys.platform == "win32":
|
|---|
| 55 | _escape_shell_arg_re = re.compile(r'(\\+)(\"|$)')
|
|---|
| 56 |
|
|---|
| 57 | def escape_shell_arg(arg):
|
|---|
| 58 | # The (very strange) parsing rules used by the C runtime library are
|
|---|
| 59 | # described at:
|
|---|
| 60 | # http://msdn.microsoft.com/library/en-us/vclang/html/_pluslang_Parsing_C.2b2b_.Command.2d.Line_Arguments.asp
|
|---|
| 61 |
|
|---|
| 62 | # double up slashes, but only if they are followed by a quote character
|
|---|
| 63 | arg = re.sub(_escape_shell_arg_re, r'\1\1\2', arg)
|
|---|
| 64 |
|
|---|
| 65 | # surround by quotes and escape quotes inside
|
|---|
| 66 | arg = '"' + string.replace(arg, '"', '"^""') + '"'
|
|---|
| 67 | return arg
|
|---|
| 68 |
|
|---|
| 69 |
|
|---|
| 70 | def argv_to_command_string(argv):
|
|---|
| 71 | """Flatten a list of command line arguments into a command string.
|
|---|
| 72 |
|
|---|
| 73 | The resulting command string is expected to be passed to the system
|
|---|
| 74 | shell which os functions like popen() and system() invoke internally.
|
|---|
| 75 | """
|
|---|
| 76 |
|
|---|
| 77 | # According cmd's usage notes (cmd /?), it parses the command line by
|
|---|
| 78 | # "seeing if the first character is a quote character and if so, stripping
|
|---|
| 79 | # the leading character and removing the last quote character."
|
|---|
| 80 | # So to prevent the argument string from being changed we add an extra set
|
|---|
| 81 | # of quotes around it here.
|
|---|
| 82 | return '"' + string.join(map(escape_shell_arg, argv), " ") + '"'
|
|---|
| 83 |
|
|---|
| 84 | else:
|
|---|
| 85 | def escape_shell_arg(str):
|
|---|
| 86 | return "'" + string.replace(str, "'", "'\\''") + "'"
|
|---|
| 87 |
|
|---|
| 88 | def argv_to_command_string(argv):
|
|---|
| 89 | """Flatten a list of command line arguments into a command string.
|
|---|
| 90 |
|
|---|
| 91 | The resulting command string is expected to be passed to the system
|
|---|
| 92 | shell which os functions like popen() and system() invoke internally.
|
|---|
| 93 | """
|
|---|
| 94 |
|
|---|
| 95 | return string.join(map(escape_shell_arg, argv), " ")
|
|---|
| 96 | # ============================================================================
|
|---|
| 97 |
|
|---|
| 98 | # Minimal, incomplete, versions of popen2.Popen[34] for those platforms
|
|---|
| 99 | # for which popen2 does not provide them.
|
|---|
| 100 | try:
|
|---|
| 101 | Popen3 = popen2.Popen3
|
|---|
| 102 | Popen4 = popen2.Popen4
|
|---|
| 103 | except AttributeError:
|
|---|
| 104 | class Popen3:
|
|---|
| 105 | def __init__(self, cmd, capturestderr = False):
|
|---|
| 106 | if type(cmd) != types.StringType:
|
|---|
| 107 | cmd = argv_to_command_string(cmd)
|
|---|
| 108 | if capturestderr:
|
|---|
| 109 | self.fromchild, self.tochild, self.childerr \
|
|---|
| 110 | = popen2.popen3(cmd, mode='b')
|
|---|
| 111 | else:
|
|---|
| 112 | self.fromchild, self.tochild = popen2.popen2(cmd, mode='b')
|
|---|
| 113 | self.childerr = None
|
|---|
| 114 |
|
|---|
| 115 | def wait(self):
|
|---|
| 116 | rv = self.fromchild.close()
|
|---|
| 117 | rv = self.tochild.close() or rv
|
|---|
| 118 | if self.childerr is not None:
|
|---|
| 119 | rv = self.childerr.close() or rv
|
|---|
| 120 | return rv
|
|---|
| 121 |
|
|---|
| 122 | class Popen4:
|
|---|
| 123 | def __init__(self, cmd):
|
|---|
| 124 | if type(cmd) != types.StringType:
|
|---|
| 125 | cmd = argv_to_command_string(cmd)
|
|---|
| 126 | self.fromchild, self.tochild = popen2.popen4(cmd, mode='b')
|
|---|
| 127 |
|
|---|
| 128 | def wait(self):
|
|---|
| 129 | rv = self.fromchild.close()
|
|---|
| 130 | rv = self.tochild.close() or rv
|
|---|
| 131 | return rv
|
|---|
| 132 |
|
|---|
| 133 | class MailedOutput:
|
|---|
| 134 | def __init__(self, cfg, repos, prefix_param):
|
|---|
| 135 | self.cfg = cfg
|
|---|
| 136 | self.repos = repos
|
|---|
| 137 | self.prefix_param = prefix_param
|
|---|
| 138 | self._CHUNKSIZE = 128 * 1024
|
|---|
| 139 |
|
|---|
| 140 | def start(self, group, params):
|
|---|
| 141 | # whitespace-separated list of addresses; split into a clean list:
|
|---|
| 142 | self.to_addrs = \
|
|---|
| 143 | filter(None, string.split(self.cfg.get('to_addr', group, params)))
|
|---|
| 144 | self.from_addr = self.cfg.get('from_addr', group, params) \
|
|---|
| 145 | or self.repos.author or 'no_author'
|
|---|
| 146 | self.reply_to = self.cfg.get('reply_to', group, params)
|
|---|
| 147 |
|
|---|
| 148 | def mail_headers(self, group, params):
|
|---|
| 149 | prefix = self.cfg.get(self.prefix_param, group, params)
|
|---|
| 150 | if prefix:
|
|---|
| 151 | subject = prefix + ' ' + self.subject
|
|---|
| 152 | else:
|
|---|
| 153 | subject = self.subject
|
|---|
| 154 | hdrs = 'From: %s\n' \
|
|---|
| 155 | 'To: %s\n' \
|
|---|
| 156 | 'Subject: %s\n' \
|
|---|
| 157 | 'MIME-Version: 1.0\n' \
|
|---|
| 158 | 'Content-Type: text/plain; charset=UTF-8\n' \
|
|---|
| 159 | % (self.from_addr, string.join(self.to_addrs, ', '), subject)
|
|---|
| 160 | if self.reply_to:
|
|---|
| 161 | hdrs = '%sReply-To: %s\n' % (hdrs, self.reply_to)
|
|---|
| 162 | return hdrs + '\n'
|
|---|
| 163 |
|
|---|
| 164 | def run(self, cmd):
|
|---|
| 165 | # By default we choose to incorporate child stderr into the output
|
|---|
| 166 | pipe_ob = Popen4(cmd)
|
|---|
| 167 |
|
|---|
| 168 | buf = pipe_ob.fromchild.read(self._CHUNKSIZE)
|
|---|
| 169 | while buf:
|
|---|
| 170 | self.write(buf)
|
|---|
| 171 | buf = pipe_ob.fromchild.read(self._CHUNKSIZE)
|
|---|
| 172 |
|
|---|
| 173 | # wait on the child so we don't end up with a billion zombies
|
|---|
| 174 | pipe_ob.wait()
|
|---|
| 175 |
|
|---|
| 176 |
|
|---|
| 177 | class SMTPOutput(MailedOutput):
|
|---|
| 178 | "Deliver a mail message to an MTA using SMTP."
|
|---|
| 179 |
|
|---|
| 180 | def start(self, group, params, **args):
|
|---|
| 181 | MailedOutput.start(self, group, params, **args)
|
|---|
| 182 |
|
|---|
| 183 | self.buffer = cStringIO.StringIO()
|
|---|
| 184 | self.write = self.buffer.write
|
|---|
| 185 |
|
|---|
| 186 | self.write(self.mail_headers(group, params))
|
|---|
| 187 |
|
|---|
| 188 | def finish(self):
|
|---|
| 189 | server = smtplib.SMTP(self.cfg.general.smtp_hostname)
|
|---|
| 190 | if self.cfg.is_set('general.smtp_username'):
|
|---|
| 191 | server.login(self.cfg.general.smtp_username,
|
|---|
| 192 | self.cfg.general.smtp_password)
|
|---|
| 193 | server.sendmail(self.from_addr, self.to_addrs, self.buffer.getvalue())
|
|---|
| 194 | server.quit()
|
|---|
| 195 |
|
|---|
| 196 |
|
|---|
| 197 | class StandardOutput:
|
|---|
| 198 | "Print the commit message to stdout."
|
|---|
| 199 |
|
|---|
| 200 | def __init__(self, cfg, repos, prefix_param):
|
|---|
| 201 | self.cfg = cfg
|
|---|
| 202 | self.repos = repos
|
|---|
| 203 | self._CHUNKSIZE = 128 * 1024
|
|---|
| 204 |
|
|---|
| 205 | self.write = sys.stdout.write
|
|---|
| 206 |
|
|---|
| 207 | def start(self, group, params, **args):
|
|---|
| 208 | pass
|
|---|
| 209 |
|
|---|
| 210 | def finish(self):
|
|---|
| 211 | pass
|
|---|
| 212 |
|
|---|
| 213 | def run(self, cmd):
|
|---|
| 214 | # By default we choose to incorporate child stderr into the output
|
|---|
| 215 | pipe_ob = Popen4(cmd)
|
|---|
| 216 |
|
|---|
| 217 | buf = pipe_ob.fromchild.read(self._CHUNKSIZE)
|
|---|
| 218 | while buf:
|
|---|
| 219 | self.write(buf)
|
|---|
| 220 | buf = pipe_ob.fromchild.read(self._CHUNKSIZE)
|
|---|
| 221 |
|
|---|
| 222 | # wait on the child so we don't end up with a billion zombies
|
|---|
| 223 | pipe_ob.wait()
|
|---|
| 224 |
|
|---|
| 225 |
|
|---|
| 226 | class PipeOutput(MailedOutput):
|
|---|
| 227 | "Deliver a mail message to an MDA via a pipe."
|
|---|
| 228 |
|
|---|
| 229 | def __init__(self, cfg, repos, prefix_param):
|
|---|
| 230 | MailedOutput.__init__(self, cfg, repos, prefix_param)
|
|---|
| 231 |
|
|---|
| 232 | # figure out the command for delivery
|
|---|
| 233 | self.cmd = string.split(cfg.general.mail_command)
|
|---|
| 234 |
|
|---|
| 235 | def start(self, group, params, **args):
|
|---|
| 236 | MailedOutput.start(self, group, params, **args)
|
|---|
| 237 |
|
|---|
| 238 | ### gotta fix this. this is pretty specific to sendmail and qmail's
|
|---|
| 239 | ### mailwrapper program. should be able to use option param substitution
|
|---|
| 240 | cmd = self.cmd + [ '-f', self.from_addr ] + self.to_addrs
|
|---|
| 241 |
|
|---|
| 242 | # construct the pipe for talking to the mailer
|
|---|
| 243 | self.pipe = Popen3(cmd)
|
|---|
| 244 | self.write = self.pipe.tochild.write
|
|---|
| 245 |
|
|---|
| 246 | # we don't need the read-from-mailer descriptor, so close it
|
|---|
| 247 | self.pipe.fromchild.close()
|
|---|
| 248 |
|
|---|
| 249 | # start writing out the mail message
|
|---|
| 250 | self.write(self.mail_headers(group, params))
|
|---|
| 251 |
|
|---|
| 252 | def finish(self):
|
|---|
| 253 | # signal that we're done sending content
|
|---|
| 254 | self.pipe.tochild.close()
|
|---|
| 255 |
|
|---|
| 256 | # wait to avoid zombies
|
|---|
| 257 | self.pipe.wait()
|
|---|
| 258 |
|
|---|
| 259 |
|
|---|
| 260 | class Messenger:
|
|---|
| 261 | def __init__(self, pool, cfg, repos, prefix_param):
|
|---|
| 262 | self.pool = pool
|
|---|
| 263 | self.cfg = cfg
|
|---|
| 264 | self.repos = repos
|
|---|
| 265 | self.determine_output(cfg, repos, prefix_param)
|
|---|
| 266 |
|
|---|
| 267 | def determine_output(self, cfg, repos, prefix_param):
|
|---|
| 268 | if cfg.is_set('general.mail_command'):
|
|---|
| 269 | cls = PipeOutput
|
|---|
| 270 | elif cfg.is_set('general.smtp_hostname'):
|
|---|
| 271 | cls = SMTPOutput
|
|---|
| 272 | else:
|
|---|
| 273 | cls = StandardOutput
|
|---|
| 274 |
|
|---|
| 275 | self.output = cls(cfg, repos, prefix_param)
|
|---|
| 276 |
|
|---|
| 277 |
|
|---|
| 278 | class Commit(Messenger):
|
|---|
| 279 | def __init__(self, pool, cfg, repos):
|
|---|
| 280 | Messenger.__init__(self, pool, cfg, repos, 'commit_subject_prefix')
|
|---|
| 281 |
|
|---|
| 282 | # get all the changes and sort by path
|
|---|
| 283 | editor = svn.repos.RevisionChangeCollector(repos.fs_ptr, repos.rev,
|
|---|
| 284 | self.pool)
|
|---|
| 285 | e_ptr, e_baton = svn.delta.make_editor(editor, self.pool)
|
|---|
| 286 | svn.repos.svn_repos_replay(repos.root_this, e_ptr, e_baton, self.pool)
|
|---|
| 287 |
|
|---|
| 288 | self.changelist = editor.changes.items()
|
|---|
| 289 | self.changelist.sort()
|
|---|
| 290 |
|
|---|
| 291 | ### hunh. this code isn't actually needed for StandardOutput. refactor?
|
|---|
| 292 | # collect the set of groups and the unique sets of params for the options
|
|---|
| 293 | self.groups = { }
|
|---|
| 294 | for path, change in self.changelist:
|
|---|
| 295 | for (group, params) in self.cfg.which_groups(path):
|
|---|
| 296 | # turn the params into a hashable object and stash it away
|
|---|
| 297 | param_list = params.items()
|
|---|
| 298 | param_list.sort()
|
|---|
| 299 | self.groups[group, tuple(param_list)] = params
|
|---|
| 300 |
|
|---|
| 301 | # figure out the changed directories
|
|---|
| 302 | dirs = { }
|
|---|
| 303 | for path, change in self.changelist:
|
|---|
| 304 | if change.item_kind == svn.core.svn_node_dir:
|
|---|
| 305 | dirs[path] = None
|
|---|
| 306 | else:
|
|---|
| 307 | idx = string.rfind(path, '/')
|
|---|
| 308 | if idx == -1:
|
|---|
| 309 | dirs[''] = None
|
|---|
| 310 | else:
|
|---|
| 311 | dirs[path[:idx]] = None
|
|---|
| 312 |
|
|---|
| 313 | dirlist = dirs.keys()
|
|---|
| 314 |
|
|---|
| 315 | # figure out the common portion of all the dirs. note that there is
|
|---|
| 316 | # no "common" if only a single dir was changed, or the root was changed.
|
|---|
| 317 | if len(dirs) == 1 or dirs.has_key(''):
|
|---|
| 318 | commondir = ''
|
|---|
| 319 | else:
|
|---|
| 320 | common = string.split(dirlist.pop(), '/')
|
|---|
| 321 | for d in dirlist:
|
|---|
| 322 | parts = string.split(d, '/')
|
|---|
| 323 | for i in range(len(common)):
|
|---|
| 324 | if i == len(parts) or common[i] != parts[i]:
|
|---|
| 325 | del common[i:]
|
|---|
| 326 | break
|
|---|
| 327 | commondir = string.join(common, '/')
|
|---|
| 328 | if commondir:
|
|---|
| 329 | # strip the common portion from each directory
|
|---|
| 330 | l = len(commondir) + 1
|
|---|
| 331 | dirlist = [ ]
|
|---|
| 332 | for d in dirs.keys():
|
|---|
| 333 | if d == commondir:
|
|---|
| 334 | dirlist.append('.')
|
|---|
| 335 | else:
|
|---|
| 336 | dirlist.append(d[l:])
|
|---|
| 337 | else:
|
|---|
| 338 | # nothing in common, so reset the list of directories
|
|---|
| 339 | dirlist = dirs.keys()
|
|---|
| 340 |
|
|---|
| 341 | # compose the basic subject line. later, we can prefix it.
|
|---|
| 342 | dirlist.sort()
|
|---|
| 343 | dirlist = string.join(dirlist)
|
|---|
| 344 | if commondir:
|
|---|
| 345 | self.output.subject = 'r%d - in %s: %s' % (repos.rev, commondir, dirlist)
|
|---|
| 346 | else:
|
|---|
| 347 | self.output.subject = 'r%d - %s' % (repos.rev, dirlist)
|
|---|
| 348 |
|
|---|
| 349 | def generate(self):
|
|---|
| 350 | "Generate email for the various groups and option-params."
|
|---|
| 351 |
|
|---|
| 352 | ### the groups need to be further compressed. if the headers and
|
|---|
| 353 | ### body are the same across groups, then we can have multiple To:
|
|---|
| 354 | ### addresses. SMTPOutput holds the entire message body in memory,
|
|---|
| 355 | ### so if the body doesn't change, then it can be sent N times
|
|---|
| 356 | ### rather than rebuilding it each time.
|
|---|
| 357 |
|
|---|
| 358 | subpool = svn.core.svn_pool_create(self.pool)
|
|---|
| 359 |
|
|---|
| 360 | for (group, param_tuple), params in self.groups.items():
|
|---|
| 361 | self.output.start(group, params)
|
|---|
| 362 |
|
|---|
| 363 | # generate the content for this group and set of params
|
|---|
| 364 | generate_content(self.output, self.cfg, self.repos, self.changelist,
|
|---|
| 365 | group, params, subpool)
|
|---|
| 366 |
|
|---|
| 367 | self.output.finish()
|
|---|
| 368 | svn.core.svn_pool_clear(subpool)
|
|---|
| 369 |
|
|---|
| 370 | svn.core.svn_pool_destroy(subpool)
|
|---|
| 371 |
|
|---|
| 372 |
|
|---|
| 373 | class PropChange(Messenger):
|
|---|
| 374 | def __init__(self, pool, cfg, repos, author, propname):
|
|---|
| 375 | Messenger.__init__(self, pool, cfg, repos, 'propchange_subject_prefix')
|
|---|
| 376 | self.author = author
|
|---|
| 377 | self.propname = propname
|
|---|
| 378 |
|
|---|
| 379 | ### hunh. this code isn't actually needed for StandardOutput. refactor?
|
|---|
| 380 | # collect the set of groups and the unique sets of params for the options
|
|---|
| 381 | self.groups = { }
|
|---|
| 382 | for (group, params) in self.cfg.which_groups(''):
|
|---|
| 383 | # turn the params into a hashable object and stash it away
|
|---|
| 384 | param_list = params.items()
|
|---|
| 385 | param_list.sort()
|
|---|
| 386 | self.groups[group, tuple(param_list)] = params
|
|---|
| 387 |
|
|---|
| 388 | self.output.subject = 'r%d - %s' % (repos.rev, propname)
|
|---|
| 389 |
|
|---|
| 390 | def generate(self):
|
|---|
| 391 | for (group, param_tuple), params in self.groups.items():
|
|---|
| 392 | self.output.start(group, params)
|
|---|
| 393 | self.output.write('Author: %s\nRevision: %s\nProperty Name: %s\n\n'
|
|---|
| 394 | % (self.author, self.repos.rev, self.propname))
|
|---|
| 395 | propvalue = self.repos.get_rev_prop(self.propname)
|
|---|
| 396 | self.output.write('New Property Value:\n')
|
|---|
| 397 | self.output.write(propvalue)
|
|---|
| 398 | self.output.finish()
|
|---|
| 399 |
|
|---|
| 400 |
|
|---|
| 401 | def generate_content(output, cfg, repos, changelist, group, params, pool):
|
|---|
| 402 |
|
|---|
| 403 | svndate = repos.get_rev_prop(svn.core.SVN_PROP_REVISION_DATE)
|
|---|
| 404 | ### pick a different date format?
|
|---|
| 405 | date = time.ctime(svn.core.secs_from_timestr(svndate, pool))
|
|---|
| 406 |
|
|---|
| 407 | output.write('Author: %s\nDate: %s\nNew Revision: %s\n\n'
|
|---|
| 408 | % (repos.author, date, repos.rev))
|
|---|
| 409 |
|
|---|
| 410 | # print summary sections
|
|---|
| 411 | generate_list(output, 'Added', changelist, _select_adds)
|
|---|
| 412 | generate_list(output, 'Removed', changelist, _select_deletes)
|
|---|
| 413 | generate_list(output, 'Modified', changelist, _select_modifies)
|
|---|
| 414 |
|
|---|
| 415 | output.write('Log:\n%s\n'
|
|---|
| 416 | % (repos.get_rev_prop(svn.core.SVN_PROP_REVISION_LOG) or ''))
|
|---|
| 417 |
|
|---|
| 418 | # these are sorted by path already
|
|---|
| 419 | for path, change in changelist:
|
|---|
| 420 | generate_diff(output, cfg, repos, date, change, group, params, pool)
|
|---|
| 421 |
|
|---|
| 422 |
|
|---|
| 423 | def _select_adds(change):
|
|---|
| 424 | return change.added
|
|---|
| 425 | def _select_deletes(change):
|
|---|
| 426 | return change.path is None
|
|---|
| 427 | def _select_modifies(change):
|
|---|
| 428 | return not change.added and change.path is not None
|
|---|
| 429 |
|
|---|
| 430 |
|
|---|
| 431 | def generate_list(output, header, changelist, selection):
|
|---|
| 432 | items = [ ]
|
|---|
| 433 | for path, change in changelist:
|
|---|
| 434 | if selection(change):
|
|---|
| 435 | items.append((path, change))
|
|---|
| 436 | if items:
|
|---|
| 437 | output.write('%s:\n' % header)
|
|---|
| 438 | for fname, change in items:
|
|---|
| 439 | if change.item_kind == svn.core.svn_node_dir:
|
|---|
| 440 | is_dir = '/'
|
|---|
| 441 | else:
|
|---|
| 442 | is_dir = ''
|
|---|
| 443 | if change.prop_changes:
|
|---|
| 444 | if change.text_changed:
|
|---|
| 445 | props = ' (contents, props changed)'
|
|---|
| 446 | else:
|
|---|
| 447 | props = ' (props changed)'
|
|---|
| 448 | else:
|
|---|
| 449 | props = ''
|
|---|
| 450 | output.write(' %s%s%s\n' % (fname, is_dir, props))
|
|---|
| 451 | if change.added and change.base_path:
|
|---|
| 452 | if is_dir:
|
|---|
| 453 | text = ''
|
|---|
| 454 | elif change.text_changed:
|
|---|
| 455 | text = ', changed'
|
|---|
| 456 | else:
|
|---|
| 457 | text = ' unchanged'
|
|---|
| 458 | output.write(' - copied%s from r%d, %s%s\n'
|
|---|
| 459 | % (text, change.base_rev, change.base_path[1:], is_dir))
|
|---|
| 460 |
|
|---|
| 461 |
|
|---|
| 462 | def generate_diff(output, cfg, repos, date, change, group, params, pool):
|
|---|
| 463 | if change.item_kind == svn.core.svn_node_dir:
|
|---|
| 464 | # all changes were printed in the summary. nothing to do.
|
|---|
| 465 | return
|
|---|
| 466 |
|
|---|
| 467 | gen_diffs = cfg.get('generate_diffs', group, params)
|
|---|
| 468 |
|
|---|
| 469 | ### Do a little dance for deprecated options. Note that even if you
|
|---|
| 470 | ### don't have an option anywhere in your configuration file, it
|
|---|
| 471 | ### still gets returned as non-None.
|
|---|
| 472 | if len(gen_diffs):
|
|---|
| 473 | diff_add = False
|
|---|
| 474 | diff_copy = False
|
|---|
| 475 | diff_delete = False
|
|---|
| 476 | diff_modify = False
|
|---|
| 477 | list = string.split(gen_diffs, " ")
|
|---|
| 478 | for item in list:
|
|---|
| 479 | if item == 'add':
|
|---|
| 480 | diff_add = True
|
|---|
| 481 | if item == 'copy':
|
|---|
| 482 | diff_copy = True
|
|---|
| 483 | if item == 'delete':
|
|---|
| 484 | diff_delete = True
|
|---|
| 485 | if item == 'modify':
|
|---|
| 486 | diff_modify = True
|
|---|
| 487 | else:
|
|---|
| 488 | diff_add = True
|
|---|
| 489 | diff_copy = True
|
|---|
| 490 | diff_delete = True
|
|---|
| 491 | diff_modify = True
|
|---|
| 492 | ### These options are deprecated
|
|---|
| 493 | suppress = cfg.get('suppress_deletes', group, params)
|
|---|
| 494 | if suppress == 'yes':
|
|---|
| 495 | diff_delete = False
|
|---|
| 496 | suppress = cfg.get('suppress_adds', group, params)
|
|---|
| 497 | if suppress == 'yes':
|
|---|
| 498 | diff_add = False
|
|---|
| 499 |
|
|---|
| 500 | if not change.path:
|
|---|
| 501 | ### params is a bit silly here
|
|---|
| 502 | if diff_delete == False:
|
|---|
| 503 | # a record of the deletion is in the summary. no need to write
|
|---|
| 504 | # anything further here.
|
|---|
| 505 | return
|
|---|
| 506 |
|
|---|
| 507 | output.write('\nDeleted: %s\n' % change.base_path)
|
|---|
| 508 | diff = svn.fs.FileDiff(repos.get_root(change.base_rev),
|
|---|
| 509 | change.base_path, None, None, pool)
|
|---|
| 510 |
|
|---|
| 511 | label1 = '%s\t%s' % (change.base_path, date)
|
|---|
| 512 | label2 = '(empty file)'
|
|---|
| 513 | singular = True
|
|---|
| 514 | elif change.added:
|
|---|
| 515 | if change.base_path and (change.base_rev != -1):
|
|---|
| 516 | # this file was copied.
|
|---|
| 517 |
|
|---|
| 518 | if not change.text_changed:
|
|---|
| 519 | # copies with no changes are reported in the header, so we can just
|
|---|
| 520 | # skip them here.
|
|---|
| 521 | return
|
|---|
| 522 |
|
|---|
| 523 | if diff_copy == False:
|
|---|
| 524 | # a record of the copy is in the summary, no need to write
|
|---|
| 525 | # anything further here.
|
|---|
| 526 | return
|
|---|
| 527 |
|
|---|
| 528 | # note that we strip the leading slash from the base (copyfrom) path
|
|---|
| 529 | output.write('\nCopied: %s (from r%d, %s)\n'
|
|---|
| 530 | % (change.path, change.base_rev, change.base_path[1:]))
|
|---|
| 531 | diff = svn.fs.FileDiff(repos.get_root(change.base_rev),
|
|---|
| 532 | change.base_path[1:],
|
|---|
| 533 | repos.root_this, change.path,
|
|---|
| 534 | pool)
|
|---|
| 535 | label1 = change.base_path[1:] + '\t(original)'
|
|---|
| 536 | label2 = '%s\t%s' % (change.path, date)
|
|---|
| 537 | singular = False
|
|---|
| 538 | else:
|
|---|
| 539 | if diff_add == False:
|
|---|
| 540 | # a record of the addition is in the summary. no need to write
|
|---|
| 541 | # anything further here.
|
|---|
| 542 | return
|
|---|
| 543 |
|
|---|
| 544 | output.write('\nAdded: %s\n' % change.path)
|
|---|
| 545 | diff = svn.fs.FileDiff(None, None, repos.root_this, change.path, pool)
|
|---|
| 546 | label1 = '(empty file)'
|
|---|
| 547 | label2 = '%s\t%s' % (change.path, date)
|
|---|
| 548 | singular = True
|
|---|
| 549 | elif not change.text_changed:
|
|---|
| 550 | # don't bother to show an empty diff. prolly just a prop change.
|
|---|
| 551 | return
|
|---|
| 552 | else:
|
|---|
| 553 | if diff_modify == False:
|
|---|
| 554 | # a record of the modification is in the summary, no need to write
|
|---|
| 555 | # anything further here.
|
|---|
| 556 | return
|
|---|
| 557 |
|
|---|
| 558 | output.write('\nModified: %s\n' % change.path)
|
|---|
| 559 | diff = svn.fs.FileDiff(repos.get_root(change.base_rev),
|
|---|
| 560 | change.base_path[1:],
|
|---|
| 561 | repos.root_this, change.path,
|
|---|
| 562 | pool)
|
|---|
| 563 | label1 = change.base_path[1:] + '\t(original)'
|
|---|
| 564 | label2 = '%s\t%s' % (change.path, date)
|
|---|
| 565 | singular = False
|
|---|
| 566 |
|
|---|
| 567 | output.write(SEPARATOR + '\n')
|
|---|
| 568 |
|
|---|
| 569 | if diff.either_binary():
|
|---|
| 570 | if singular:
|
|---|
| 571 | output.write('Binary file. No diff available.\n')
|
|---|
| 572 | else:
|
|---|
| 573 | output.write('Binary files. No diff available.\n')
|
|---|
| 574 | return
|
|---|
| 575 |
|
|---|
| 576 | ### do something with change.prop_changes
|
|---|
| 577 |
|
|---|
| 578 | src_fname, dst_fname = diff.get_files()
|
|---|
| 579 |
|
|---|
| 580 | output.run(cfg.get_diff_cmd({
|
|---|
| 581 | 'label_from' : label1,
|
|---|
| 582 | 'label_to' : label2,
|
|---|
| 583 | 'from' : src_fname,
|
|---|
| 584 | 'to' : dst_fname,
|
|---|
| 585 | }))
|
|---|
| 586 |
|
|---|
| 587 |
|
|---|
| 588 | class Repository:
|
|---|
| 589 | "Hold roots and other information about the repository."
|
|---|
| 590 |
|
|---|
| 591 | def __init__(self, repos_dir, rev, pool):
|
|---|
| 592 | self.repos_dir = repos_dir
|
|---|
| 593 | self.rev = rev
|
|---|
| 594 | self.pool = pool
|
|---|
| 595 |
|
|---|
| 596 | self.repos_ptr = svn.repos.svn_repos_open(repos_dir, pool)
|
|---|
| 597 | self.fs_ptr = svn.repos.svn_repos_fs(self.repos_ptr)
|
|---|
| 598 |
|
|---|
| 599 | self.roots = { }
|
|---|
| 600 |
|
|---|
| 601 | self.root_this = self.get_root(rev)
|
|---|
| 602 |
|
|---|
| 603 | self.author = self.get_rev_prop(svn.core.SVN_PROP_REVISION_AUTHOR)
|
|---|
| 604 |
|
|---|
| 605 | def get_rev_prop(self, propname):
|
|---|
| 606 | return svn.fs.revision_prop(self.fs_ptr, self.rev, propname, self.pool)
|
|---|
| 607 |
|
|---|
| 608 | def get_root(self, rev):
|
|---|
| 609 | try:
|
|---|
| 610 | return self.roots[rev]
|
|---|
| 611 | except KeyError:
|
|---|
| 612 | pass
|
|---|
| 613 | root = self.roots[rev] = svn.fs.revision_root(self.fs_ptr, rev, self.pool)
|
|---|
| 614 | return root
|
|---|
| 615 |
|
|---|
| 616 |
|
|---|
| 617 | class Config:
|
|---|
| 618 |
|
|---|
| 619 | # The predefined configuration sections. These are omitted from the
|
|---|
| 620 | # set of groups.
|
|---|
| 621 | _predefined = ('general', 'defaults')
|
|---|
| 622 |
|
|---|
| 623 | def __init__(self, fname, repos, global_params):
|
|---|
| 624 | cp = ConfigParser.ConfigParser()
|
|---|
| 625 | cp.read(fname)
|
|---|
| 626 |
|
|---|
| 627 | # record the (non-default) groups that we find
|
|---|
| 628 | self._groups = [ ]
|
|---|
| 629 |
|
|---|
| 630 | for section in cp.sections():
|
|---|
| 631 | if not hasattr(self, section):
|
|---|
| 632 | section_ob = _sub_section()
|
|---|
| 633 | setattr(self, section, section_ob)
|
|---|
| 634 | if section not in self._predefined:
|
|---|
| 635 | self._groups.append((section, section_ob))
|
|---|
| 636 | else:
|
|---|
| 637 | section_ob = getattr(self, section)
|
|---|
| 638 | for option in cp.options(section):
|
|---|
| 639 | # get the raw value -- we use the same format for *our* interpolation
|
|---|
| 640 | value = cp.get(section, option, raw=1)
|
|---|
| 641 | setattr(section_ob, option, value)
|
|---|
| 642 |
|
|---|
| 643 | ### do some better splitting to enable quoting of spaces
|
|---|
| 644 | self._diff_cmd = string.split(self.general.diff)
|
|---|
| 645 |
|
|---|
| 646 | # these params are always available, although they may be overridden
|
|---|
| 647 | self._global_params = global_params.copy()
|
|---|
| 648 |
|
|---|
| 649 | self._prep_groups(repos)
|
|---|
| 650 |
|
|---|
| 651 | def get_diff_cmd(self, args):
|
|---|
| 652 | cmd = [ ]
|
|---|
| 653 | for part in self._diff_cmd:
|
|---|
| 654 | cmd.append(part % args)
|
|---|
| 655 | return cmd
|
|---|
| 656 |
|
|---|
| 657 | def is_set(self, option):
|
|---|
| 658 | """Return None if the option is not set; otherwise, its value is returned.
|
|---|
| 659 |
|
|---|
| 660 | The option is specified as a dotted symbol, such as 'general.mail_command'
|
|---|
| 661 | """
|
|---|
| 662 | parts = string.split(option, '.')
|
|---|
| 663 | ob = self
|
|---|
| 664 | for part in string.split(option, '.'):
|
|---|
| 665 | if not hasattr(ob, part):
|
|---|
| 666 | return None
|
|---|
| 667 | ob = getattr(ob, part)
|
|---|
| 668 | return ob
|
|---|
| 669 |
|
|---|
| 670 | def get(self, option, group, params):
|
|---|
| 671 | if group:
|
|---|
| 672 | sub = getattr(self, group)
|
|---|
| 673 | if hasattr(sub, option):
|
|---|
| 674 | return getattr(sub, option) % params
|
|---|
| 675 | return getattr(self.defaults, option, '') % params
|
|---|
| 676 |
|
|---|
| 677 | def _prep_groups(self, repos):
|
|---|
| 678 | self._group_re = [ ]
|
|---|
| 679 |
|
|---|
| 680 | repos_dir = os.path.abspath(repos.repos_dir)
|
|---|
| 681 |
|
|---|
| 682 | # compute the default repository-based parameters. start with some
|
|---|
| 683 | # basic parameters, then bring in the regex-based params.
|
|---|
| 684 | default_params = self._global_params.copy()
|
|---|
| 685 |
|
|---|
| 686 | try:
|
|---|
| 687 | match = re.match(self.defaults.for_repos, repos_dir)
|
|---|
| 688 | if match:
|
|---|
| 689 | default_params.update(match.groupdict())
|
|---|
| 690 | except AttributeError:
|
|---|
| 691 | # there is no self.defaults.for_repos
|
|---|
| 692 | pass
|
|---|
| 693 |
|
|---|
| 694 | # select the groups that apply to this repository
|
|---|
| 695 | for group, sub in self._groups:
|
|---|
| 696 | params = default_params
|
|---|
| 697 | if hasattr(sub, 'for_repos'):
|
|---|
| 698 | match = re.match(sub.for_repos, repos_dir)
|
|---|
| 699 | if not match:
|
|---|
| 700 | continue
|
|---|
| 701 | params = self._global_params.copy()
|
|---|
| 702 | params.update(match.groupdict())
|
|---|
| 703 |
|
|---|
| 704 | # if a matching rule hasn't been given, then use the empty string
|
|---|
| 705 | # as it will match all paths
|
|---|
| 706 | for_paths = getattr(sub, 'for_paths', '')
|
|---|
| 707 | self._group_re.append((group, re.compile(for_paths), params))
|
|---|
| 708 |
|
|---|
| 709 | # after all the groups are done, add in the default group
|
|---|
| 710 | try:
|
|---|
| 711 | self._group_re.append((None,
|
|---|
| 712 | re.compile(self.defaults.for_paths),
|
|---|
| 713 | default_params))
|
|---|
| 714 | except AttributeError:
|
|---|
| 715 | # there is no self.defaults.for_paths
|
|---|
| 716 | pass
|
|---|
| 717 |
|
|---|
| 718 | def which_groups(self, path):
|
|---|
| 719 | "Return the path's associated groups."
|
|---|
| 720 | groups = []
|
|---|
| 721 | for group, pattern, repos_params in self._group_re:
|
|---|
| 722 | match = pattern.match(path)
|
|---|
| 723 | if match:
|
|---|
| 724 | params = repos_params.copy()
|
|---|
| 725 | params.update(match.groupdict())
|
|---|
| 726 | groups.append((group, params))
|
|---|
| 727 | if not groups:
|
|---|
| 728 | groups.append((None, self._global_params))
|
|---|
| 729 | return groups
|
|---|
| 730 |
|
|---|
| 731 |
|
|---|
| 732 | class _sub_section:
|
|---|
| 733 | pass
|
|---|
| 734 |
|
|---|
| 735 |
|
|---|
| 736 | class MissingConfig(Exception):
|
|---|
| 737 | pass
|
|---|
| 738 |
|
|---|
| 739 | class UnknownSubcommand(Exception):
|
|---|
| 740 | pass
|
|---|
| 741 |
|
|---|
| 742 |
|
|---|
| 743 | # enable True/False in older vsns of Python
|
|---|
| 744 | try:
|
|---|
| 745 | _unused = True
|
|---|
| 746 | except NameError:
|
|---|
| 747 | True = 1
|
|---|
| 748 | False = 0
|
|---|
| 749 |
|
|---|
| 750 |
|
|---|
| 751 | if __name__ == '__main__':
|
|---|
| 752 | def usage():
|
|---|
| 753 | sys.stderr.write(
|
|---|
| 754 | '''USAGE: %s commit REPOS-DIR REVISION [CONFIG-FILE]
|
|---|
| 755 | %s propchange REPOS-DIR REVISION AUTHOR PROPNAME [CONFIG-FILE]
|
|---|
| 756 | '''
|
|---|
| 757 | % (sys.argv[0], sys.argv[0]))
|
|---|
| 758 | sys.exit(1)
|
|---|
| 759 |
|
|---|
| 760 | if len(sys.argv) < 4:
|
|---|
| 761 | usage()
|
|---|
| 762 |
|
|---|
| 763 | cmd = sys.argv[1]
|
|---|
| 764 | repos_dir = sys.argv[2]
|
|---|
| 765 | revision = int(sys.argv[3])
|
|---|
| 766 | config_fname = None
|
|---|
| 767 | author = None
|
|---|
| 768 | propname = None
|
|---|
| 769 |
|
|---|
| 770 | if cmd == 'commit':
|
|---|
| 771 | if len(sys.argv) > 5:
|
|---|
| 772 | usage()
|
|---|
| 773 | if len(sys.argv) > 4:
|
|---|
| 774 | config_fname = sys.argv[4]
|
|---|
| 775 | elif cmd == 'propchange':
|
|---|
| 776 | if len(sys.argv) < 6 or len(sys.argv) > 7:
|
|---|
| 777 | usage()
|
|---|
| 778 | author = sys.argv[4]
|
|---|
| 779 | propname = sys.argv[5]
|
|---|
| 780 | if len(sys.argv) > 6:
|
|---|
| 781 | config_fname = sys.argv[6]
|
|---|
| 782 | else:
|
|---|
| 783 | usage()
|
|---|
| 784 |
|
|---|
| 785 | if config_fname is None:
|
|---|
| 786 | # default to REPOS-DIR/conf/mailer.conf
|
|---|
| 787 | config_fname = os.path.join(repos_dir, 'conf', 'mailer.conf')
|
|---|
| 788 | if not os.path.exists(config_fname):
|
|---|
| 789 | # okay. look for 'mailer.conf' as a sibling of this script
|
|---|
| 790 | config_fname = os.path.join(os.path.dirname(sys.argv[0]), 'mailer.conf')
|
|---|
| 791 |
|
|---|
| 792 | if not os.path.exists(config_fname):
|
|---|
| 793 | raise MissingConfig(config_fname)
|
|---|
| 794 |
|
|---|
| 795 | ### run some validation on these params
|
|---|
| 796 | svn.core.run_app(main, cmd, config_fname, repos_dir, revision,
|
|---|
| 797 | author, propname)
|
|---|
| 798 |
|
|---|
| 799 | # ------------------------------------------------------------------------
|
|---|
| 800 | # TODO
|
|---|
| 801 | #
|
|---|
| 802 | # * add configuration options
|
|---|
| 803 | # - default options [DONE]
|
|---|
| 804 | # - per-group overrides [DONE]
|
|---|
| 805 | # - group selection based on repos and on path [DONE]
|
|---|
| 806 | # - each group defines delivery info:
|
|---|
| 807 | # o how to construct From: [DONE]
|
|---|
| 808 | # o how to construct To: [DONE]
|
|---|
| 809 | # o subject line prefixes [DONE]
|
|---|
| 810 | # o whether to set Reply-To and/or Mail-Followup-To
|
|---|
| 811 | # (btw: it is legal do set Reply-To since this is the originator of the
|
|---|
| 812 | # mail; i.e. different from MLMs that munge it)
|
|---|
| 813 | # - each group defines content construction:
|
|---|
| 814 | # o max size of diff before trimming
|
|---|
| 815 | # o max size of entire commit message before truncation
|
|---|
| 816 | # o flag to disable generation of add/delete diffs
|
|---|
| 817 | # - per-repository configuration
|
|---|
| 818 | # o extra config living in repos
|
|---|
| 819 | # o how to construct a ViewCVS URL for the diff
|
|---|
| 820 | # o optional, non-mail log file
|
|---|
| 821 | # o look up authors (username -> email; for the From: header) in a
|
|---|
| 822 | # file(s) or DBM
|
|---|
| 823 | # - put the commit author into the params dict [DONE]
|
|---|
| 824 | # - if the subject line gets too long, then trim it. configurable?
|
|---|
| 825 | # * get rid of global functions that should properly be class methods
|
|---|