$ cat /usr/share/subversion/hook-scripts/mailer/mailer.py
#!/usr/bin/python
# -*- coding: utf-8 -*-
#
#
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
#
#
# mailer.py: send email describing a commit
#
# $HeadURL: http://svn.apache.org/repos/asf/subversion/branches/1.8.x/tools/hook-scripts/mailer/mailer.py $
# $LastChangedDate: 2013-04-12 07:44:37 +0000 (Fri, 12 Apr 2013) $
# $LastChangedBy: rhuijben $
# $LastChangedRevision: 1467191 $
#
# USAGE: mailer.py commit REPOS REVISION [CONFIG-FILE]
# mailer.py propchange REPOS REVISION AUTHOR REVPROPNAME [CONFIG-FILE]
# mailer.py propchange2 REPOS REVISION AUTHOR REVPROPNAME ACTION \
# [CONFIG-FILE]
# mailer.py lock REPOS AUTHOR [CONFIG-FILE]
# mailer.py unlock REPOS AUTHOR [CONFIG-FILE]
#
# Using CONFIG-FILE, deliver an email describing the changes between
# REV and REV-1 for the repository REPOS.
#
# ACTION was added as a fifth argument to the post-revprop-change hook
# in Subversion 1.2.0. Its value is one of 'A', 'M' or 'D' to indicate
# if the property was added, modified or deleted, respectively.
#
# See _MIN_SVN_VERSION below for which version of Subversion's Python
# bindings are required by this version of mailer.py.
import os
import sys
try:
# Python >=3.0
import configparser
from urllib.parse import quote as urllib_parse_quote
except ImportError:
# Python <3.0
import ConfigParser as configparser
from urllib import quote as urllib_parse_quote
import time
import subprocess
if sys.version_info[0] >= 3:
# Python >=3.0
from io import StringIO
else:
# Python <3.0
from cStringIO import StringIO
import smtplib
import re
import tempfile
# Minimal version of Subversion's bindings required
_MIN_SVN_VERSION = [1, 5, 0]
# Import the Subversion Python bindings, making sure they meet our
# minimum version requirements.
try:
import svn.fs
import svn.delta
import svn.repos
import svn.core
except ImportError:
sys.stderr.write(
"You need version %s or better of the Subversion Python bindings.\n" \
% ".".join([str(x) for x in _MIN_SVN_VERSION]))
sys.exit(1)
if _MIN_SVN_VERSION > [svn.core.SVN_VER_MAJOR,
svn.core.SVN_VER_MINOR,
svn.core.SVN_VER_PATCH]:
sys.stderr.write(
"You need version %s or better of the Subversion Python bindings.\n" \
% ".".join([str(x) for x in _MIN_SVN_VERSION]))
sys.exit(1)
SEPARATOR = '=' * 78
def main(pool, cmd, config_fname, repos_dir, cmd_args):
### TODO: Sanity check the incoming args
if cmd == 'commit':
revision = int(cmd_args[0])
repos = Repository(repos_dir, revision, pool)
cfg = Config(config_fname, repos,
{'author': repos.author,
'repos_basename': os.path.basename(repos.repos_dir)
})
messenger = Commit(pool, cfg, repos)
elif cmd == 'propchange' or cmd == 'propchange2':
revision = int(cmd_args[0])
author = cmd_args[1]
propname = cmd_args[2]
action = (cmd == 'propchange2' and cmd_args[3] or 'A')
repos = Repository(repos_dir, revision, pool)
# Override the repos revision author with the author of the propchange
repos.author = author
cfg = Config(config_fname, repos,
{'author': author,
'repos_basename': os.path.basename(repos.repos_dir)
})
messenger = PropChange(pool, cfg, repos, author, propname, action)
elif cmd == 'lock' or cmd == 'unlock':
author = cmd_args[0]
repos = Repository(repos_dir, 0, pool) ### any old revision will do
# Override the repos revision author with the author of the lock/unlock
repos.author = author
cfg = Config(config_fname, repos,
{'author': author,
'repos_basename': os.path.basename(repos.repos_dir)
})
messenger = Lock(pool, cfg, repos, author, cmd == 'lock')
else:
raise UnknownSubcommand(cmd)
messenger.generate()
def remove_leading_slashes(path):
while path and path[0] == '/':
path = path[1:]
return path
class OutputBase:
"Abstract base class to formalize the interface of output methods"
def __init__(self, cfg, repos, prefix_param):
self.cfg = cfg
self.repos = repos
self.prefix_param = prefix_param
self._CHUNKSIZE = 128 * 1024
# This is a public member variable. This must be assigned a suitable
# piece of descriptive text before make_subject() is called.
self.subject = ""
def make_subject(self, group, params):
prefix = self.cfg.get(self.prefix_param, group, params)
if prefix:
subject = prefix + ' ' + self.subject
else:
subject = self.subject
try:
truncate_subject = int(
self.cfg.get('truncate_subject', group, params))
except ValueError:
truncate_subject = 0
if truncate_subject and len(subject) > truncate_subject:
subject = subject[:(truncate_subject - 3)] + "..."
return subject
def start(self, group, params):
"""Override this method.
Begin writing an output representation. GROUP is the name of the
configuration file group which is causing this output to be produced.
PARAMS is a dictionary of any named subexpressions of regular expressions
defined in the configuration file, plus the key 'author' contains the
author of the action being reported."""
raise NotImplementedError
def finish(self):
"""Override this method.
Flush any cached information and finish writing the output
representation."""
raise NotImplementedError
def write(self, output):
"""Override this method.
Append the literal text string OUTPUT to the output representation."""
raise NotImplementedError
def run(self, cmd):
"""Override this method, if the default implementation is not sufficient.
Execute CMD, writing the stdout produced to the output representation."""
# By default we choose to incorporate child stderr into the output
pipe_ob = subprocess.Popen(cmd, stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
close_fds=sys.platform != "win32")
buf = pipe_ob.stdout.read(self._CHUNKSIZE)
while buf:
self.write(buf)
buf = pipe_ob.stdout.read(self._CHUNKSIZE)
# wait on the child so we don't end up with a billion zombies
pipe_ob.wait()
class MailedOutput(OutputBase):
def __init__(self, cfg, repos, prefix_param):
OutputBase.__init__(self, cfg, repos, prefix_param)
def start(self, group, params):
# whitespace (or another character) separated list of addresses
# which must be split into a clean list
to_addr_in = self.cfg.get('to_addr', group, params)
# if list of addresses starts with '[.]'
# use the character between the square brackets as split char
# else use whitespaces
if len(to_addr_in) >= 3 and to_addr_in[0] == '[' \
and to_addr_in[2] == ']':
self.to_addrs = \
[_f for _f in to_addr_in[3:].split(to_addr_in[1]) if _f]
else:
self.to_addrs = [_f for _f in to_addr_in.split() if _f]
self.from_addr = self.cfg.get('from_addr', group, params) \
or self.repos.author or 'no_author'
# if the from_addr (also) starts with '[.]' (may happen if one
# map is used for both to_addr and from_addr) remove '[.]'
if len(self.from_addr) >= 3 and self.from_addr[0] == '[' \
and self.from_addr[2] == ']':
self.from_addr = self.from_addr[3:]
self.reply_to = self.cfg.get('reply_to', group, params)
# if the reply_to (also) starts with '[.]' (may happen if one
# map is used for both to_addr and reply_to) remove '[.]'
if len(self.reply_to) >= 3 and self.reply_to[0] == '[' \
and self.reply_to[2] == ']':
self.reply_to = self.reply_to[3:]
def mail_headers(self, group, params):
from email import Utils
subject = self.make_subject(group, params)
try:
subject.encode('ascii')
except UnicodeError:
from email.Header import Header
subject = Header(subject, 'utf-8').encode()
hdrs = 'From: %s\n' \
'To: %s\n' \
'Subject: %s\n' \
'Date: %s\n' \
'Message-ID: %s\n' \
'MIME-Version: 1.0\n' \
'Content-Type: text/plain; charset=UTF-8\n' \
'Content-Transfer-Encoding: 8bit\n' \
'X-Svn-Commit-Project: %s\n' \
'X-Svn-Commit-Author: %s\n' \
'X-Svn-Commit-Revision: %d\n' \
'X-Svn-Commit-Repository: %s\n' \
% (self.from_addr, ', '.join(self.to_addrs), subject,
Utils.formatdate(), Utils.make_msgid(), group,
self.repos.author or 'no_author', self.repos.rev,
os.path.basename(self.repos.repos_dir))
if self.reply_to:
hdrs = '%sReply-To: %s\n' % (hdrs, self.reply_to)
return hdrs + '\n'
class SMTPOutput(MailedOutput):
"Deliver a mail message to an MTA using SMTP."
def start(self, group, params):
MailedOutput.start(self, group, params)
self.buffer = StringIO()
self.write = self.buffer.write
self.write(self.mail_headers(group, params))
def finish(self):
server = smtplib.SMTP(self.cfg.general.smtp_hostname)
if self.cfg.is_set('general.smtp_username'):
server.login(self.cfg.general.smtp_username,
self.cfg.general.smtp_password)
server.sendmail(self.from_addr, self.to_addrs, self.buffer.getvalue())
server.quit()
class StandardOutput(OutputBase):
"Print the commit message to stdout."
def __init__(self, cfg, repos, prefix_param):
OutputBase.__init__(self, cfg, repos, prefix_param)
self.write = sys.stdout.write
def start(self, group, params):
self.write("Group: " + (group or "defaults") + "\n")
self.write("Subject: " + self.make_subject(group, params) + "\n\n")
def finish(self):
pass
class PipeOutput(MailedOutput):
"Deliver a mail message to an MTA via a pipe."
def __init__(self, cfg, repos, prefix_param):
MailedOutput.__init__(self, cfg, repos, prefix_param)
# figure out the command for delivery
self.cmd = cfg.general.mail_command.split()
def start(self, group, params):
MailedOutput.start(self, group, params)
### gotta fix this. this is pretty specific to sendmail and qmail's
### mailwrapper program. should be able to use option param substitution
cmd = self.cmd + [ '-f', self.from_addr ] + self.to_addrs
# construct the pipe for talking to the mailer
self.pipe = subprocess.Popen(cmd, stdin=subprocess.PIPE,
close_fds=sys.platform != "win32")
self.write = self.pipe.stdin.write
# start writing out the mail message
self.write(self.mail_headers(group, params))
def finish(self):
# signal that we're done sending content
self.pipe.stdin.close()
# wait to avoid zombies
self.pipe.wait()
class Messenger:
def __init__(self, pool, cfg, repos, prefix_param):
self.pool = pool
self.cfg = cfg
self.repos = repos
if cfg.is_set('general.mail_command'):
cls = PipeOutput
elif cfg.is_set('general.smtp_hostname'):
cls = SMTPOutput
else:
cls = StandardOutput
self.output = cls(cfg, repos, prefix_param)
class Commit(Messenger):
def __init__(self, pool, cfg, repos):
Messenger.__init__(self, pool, cfg, repos, 'commit_subject_prefix')
# get all the changes and sort by path
editor = svn.repos.ChangeCollector(repos.fs_ptr, repos.root_this, \
self.pool)
e_ptr, e_baton = svn.delta.make_editor(editor, self.pool)
svn.repos.replay2(repos.root_this, "", svn.core.SVN_INVALID_REVNUM, 1, e_ptr, e_baton, None, self.pool)
self.changelist = sorted(editor.get_changes().items())
log = repos.get_rev_prop(svn.core.SVN_PROP_REVISION_LOG) or ''
# collect the set of groups and the unique sets of params for the options
self.groups = { }
for path, change in self.changelist:
for (group, params) in self.cfg.which_groups(path, log):
# turn the params into a hashable object and stash it away
param_list = sorted(params.items())
# collect the set of paths belonging to this group
if (group, tuple(param_list)) in self.groups:
old_param, paths = self.groups[group, tuple(param_list)]
else:
paths = { }
paths[path] = None
self.groups[group, tuple(param_list)] = (params, paths)
# figure out the changed directories
dirs = { }
for path, change in self.changelist:
if change.item_kind == svn.core.svn_node_dir:
dirs[path] = None
else:
idx = path.rfind('/')
if idx == -1:
dirs[''] = None
else:
dirs[path[:idx]] = None
dirlist = list(dirs.keys())
commondir, dirlist = get_commondir(dirlist)
# compose the basic subject line. later, we can prefix it.
dirlist.sort()
dirlist = ' '.join(dirlist)
if commondir:
self.output.subject = 'r%d - in %s: %s' % (repos.rev, commondir, dirlist)
else:
self.output.subject = 'r%d - %s' % (repos.rev, dirlist)
def generate(self):
"Generate email for the various groups and option-params."
### the groups need to be further compressed. if the headers and
### body are the same across groups, then we can have multiple To:
### addresses. SMTPOutput holds the entire message body in memory,
### so if the body doesn't change, then it can be sent N times
### rather than rebuilding it each time.
subpool = svn.core.svn_pool_create(self.pool)
# build a renderer, tied to our output stream
renderer = TextCommitRenderer(self.output)
for (group, param_tuple), (params, paths) in self.groups.items():
self.output.start(group, params)
# generate the content for this group and set of params
generate_content(renderer, self.cfg, self.repos, self.changelist,
group, params, paths, subpool)
self.output.finish()
svn.core.svn_pool_clear(subpool)
svn.core.svn_pool_destroy(subpool)
class PropChange(Messenger):
def __init__(self, pool, cfg, repos, author, propname, action):
Messenger.__init__(self, pool, cfg, repos, 'propchange_subject_prefix')
self.author = author
self.propname = propname
self.action = action
# collect the set of groups and the unique sets of params for the options
self.groups = { }
for (group, params) in self.cfg.which_groups('', None):
# turn the params into a hashable object and stash it away
param_list = sorted(params.items())
self.groups[group, tuple(param_list)] = params
self.output.subject = 'r%d - %s' % (repos.rev, propname)
def generate(self):
actions = { 'A': 'added', 'M': 'modified', 'D': 'deleted' }
for (group, param_tuple), params in self.groups.items():
self.output.start(group, params)
self.output.write('Author: %s\n'
'Revision: %s\n'
'Property Name: %s\n'
'Action: %s\n'
'\n'
% (self.author, self.repos.rev, self.propname,
actions.get(self.action, 'Unknown (\'%s\')' \
% self.action)))
if self.action == 'A' or self.action not in actions:
self.output.write('Property value:\n')
propvalue = self.repos.get_rev_prop(self.propname)
self.output.write(propvalue)
elif self.action == 'M':
self.output.write('Property diff:\n')
tempfile1 = tempfile.NamedTemporaryFile()
tempfile1.write(sys.stdin.read())
tempfile1.flush()
tempfile2 = tempfile.NamedTemporaryFile()
tempfile2.write(self.repos.get_rev_prop(self.propname))
tempfile2.flush()
self.output.run(self.cfg.get_diff_cmd(group, {
'label_from' : 'old property value',
'label_to' : 'new property value',
'from' : tempfile1.name,
'to' : tempfile2.name,
}))
self.output.finish()
def get_commondir(dirlist):
"""Figure out the common portion/parent (commondir) of all the paths
in DIRLIST and return a tuple consisting of commondir, dirlist. If
a commondir is found, the dirlist returned is rooted in that
commondir. If no commondir is found, dirlist is returned unchanged,
and commondir is the empty string."""
if len(dirlist) < 2 or '/' in dirlist:
commondir = ''
newdirs = dirlist
else:
common = dirlist[0].split('/')
for j in range(1, len(dirlist)):
d = dirlist[j]
parts = d.split('/')
for i in range(len(common)):
if i == len(parts) or common[i] != parts[i]:
del common[i:]
break
commondir = '/'.join(common)
if commondir:
# strip the common portion from each directory
l = len(commondir) + 1
newdirs = [ ]
for d in dirlist:
if d == commondir:
newdirs.append('.')
else:
newdirs.append(d[l:])
else:
# nothing in common, so reset the list of directories
newdirs = dirlist
return commondir, newdirs
class Lock(Messenger):
def __init__(self, pool, cfg, repos, author, do_lock):
self.author = author
self.do_lock = do_lock
Messenger.__init__(self, pool, cfg, repos,
(do_lock and 'lock_subject_prefix'
or 'unlock_subject_prefix'))
# read all the locked paths from STDIN and strip off the trailing newlines
self.dirlist = [x.rstrip() for x in sys.stdin.readlines()]
# collect the set of groups and the unique sets of params for the options
self.groups = { }
for path in self.dirlist:
for (group, params) in self.cfg.which_groups(path, None):
# turn the params into a hashable object and stash it away
param_list = sorted(params.items())
# collect the set of paths belonging to this group
if (group, tuple(param_list)) in self.groups:
old_param, paths = self.groups[group, tuple(param_list)]
else:
paths = { }
paths[path] = None
self.groups[group, tuple(param_list)] = (params, paths)
commondir, dirlist = get_commondir(self.dirlist)
# compose the basic subject line. later, we can prefix it.
dirlist.sort()
dirlist = ' '.join(dirlist)
if commondir:
self.output.subject = '%s: %s' % (commondir, dirlist)
else:
self.output.subject = '%s' % (dirlist)
# The lock comment is the same for all paths, so we can just pull
# the comment for the first path in the dirlist and cache it.
self.lock = svn.fs.svn_fs_get_lock(self.repos.fs_ptr,
self.dirlist[0], self.pool)
def generate(self):
for (group, param_tuple), (params, paths) in self.groups.items():
self.output.start(group, params)
self.output.write('Author: %s\n'
'%s paths:\n' %
(self.author, self.do_lock and 'Locked' or 'Unlocked'))
self.dirlist.sort()
for dir in self.dirlist:
self.output.write(' %s\n\n' % dir)
if self.do_lock:
self.output.write('Comment:\n%s\n' % (self.lock.comment or ''))
self.output.finish()
class DiffSelections:
def __init__(self, cfg, group, params):
self.add = False
self.copy = False
self.delete = False
self.modify = False
gen_diffs = cfg.get('generate_diffs', group, params)
### Do a little dance for deprecated options. Note that even if you
### don't have an option anywhere in your configuration file, it
### still gets returned as non-None.
if len(gen_diffs):
list = gen_diffs.split(" ")
for item in list:
if item == 'add':
self.add = True
if item == 'copy':
self.copy = True
if item == 'delete':
self.delete = True
if item == 'modify':
self.modify = True
else:
self.add = True
self.copy = True
self.delete = True
self.modify = True
### These options are deprecated
suppress = cfg.get('suppress_deletes', group, params)
if suppress == 'yes':
self.delete = False
suppress = cfg.get('suppress_adds', group, params)
if suppress == 'yes':
self.add = False
class DiffURLSelections:
def __init__(self, cfg, group, params):
self.cfg = cfg
self.group = group
self.params = params
def _get_url(self, action, repos_rev, change):
# The parameters for the URLs generation need to be placed in the
# parameters for the configuration module, otherwise we may get
# KeyError exceptions.
params = self.params.copy()
params['path'] = change.path and urllib_parse_quote(change.path) or None
params['base_path'] = change.base_path and urllib_parse_quote(change.base_path) \
or None
params['rev'] = repos_rev
params['base_rev'] = change.base_rev
return self.cfg.get("diff_%s_url" % action, self.group, params)
def get_add_url(self, repos_rev, change):
return self._get_url('add', repos_rev, change)
def get_copy_url(self, repos_rev, change):
return self._get_url('copy', repos_rev, change)
def get_delete_url(self, repos_rev, change):
return self._get_url('delete', repos_rev, change)
def get_modify_url(self, repos_rev, change):
return self._get_url('modify', repos_rev, change)
def generate_content(renderer, cfg, repos, changelist, group, params, paths,
pool):
svndate = repos.get_rev_prop(svn.core.SVN_PROP_REVISION_DATE)
### pick a different date format?
date = time.ctime(svn.core.secs_from_timestr(svndate, pool))
diffsels = DiffSelections(cfg, group, params)
diffurls = DiffURLSelections(cfg, group, params)
show_nonmatching_paths = cfg.get('show_nonmatching_paths', group, params) \
or 'yes'
params_with_rev = params.copy()
params_with_rev['rev'] = repos.rev
commit_url = cfg.get('commit_url', group, params_with_rev)
# figure out the lists of changes outside the selected path-space
other_added_data = other_replaced_data = other_deleted_data = \
other_modified_data = [ ]
if len(paths) != len(changelist) and show_nonmatching_paths != 'no':
other_added_data = generate_list('A', changelist, paths, False)
other_replaced_data = generate_list('R', changelist, paths, False)
other_deleted_data = generate_list('D', changelist, paths, False)
other_modified_data = generate_list('M', changelist, paths, False)
if len(paths) != len(changelist) and show_nonmatching_paths == 'yes':
other_diffs = DiffGenerator(changelist, paths, False, cfg, repos, date,
group, params, diffsels, diffurls, pool)
else:
other_diffs = None
data = _data(
author=repos.author,
date=date,
rev=repos.rev,
log=repos.get_rev_prop(svn.core.SVN_PROP_REVISION_LOG) or '',
commit_url=commit_url,
added_data=generate_list('A', changelist, paths, True),
replaced_data=generate_list('R', changelist, paths, True),
deleted_data=generate_list('D', changelist, paths, True),
modified_data=generate_list('M', changelist, paths, True),
show_nonmatching_paths=show_nonmatching_paths,
other_added_data=other_added_data,
other_replaced_data=other_replaced_data,
other_deleted_data=other_deleted_data,
other_modified_data=other_modified_data,
diffs=DiffGenerator(changelist, paths, True, cfg, repos, date, group,
params, diffsels, diffurls, pool),
other_diffs=other_diffs,
)
renderer.render(data)
def generate_list(changekind, changelist, paths, in_paths):
if changekind == 'A':
selection = lambda change: change.action == svn.repos.CHANGE_ACTION_ADD
elif changekind == 'R':
selection = lambda change: change.action == svn.repos.CHANGE_ACTION_REPLACE
elif changekind == 'D':
selection = lambda change: change.action == svn.repos.CHANGE_ACTION_DELETE
elif changekind == 'M':
selection = lambda change: change.action == svn.repos.CHANGE_ACTION_MODIFY
items = [ ]
for path, change in changelist:
if selection(change) and (path in paths) == in_paths:
item = _data(
path=path,
is_dir=change.item_kind == svn.core.svn_node_dir,
props_changed=change.prop_changes,
text_changed=change.text_changed,
copied=(change.action == svn.repos.CHANGE_ACTION_ADD \
or change.action == svn.repos.CHANGE_ACTION_REPLACE) \
and change.base_path,
base_path=remove_leading_slashes(change.base_path),
base_rev=change.base_rev,
)
items.append(item)
return items
class DiffGenerator:
"This is a generator-like object returning DiffContent objects."
def __init__(self, changelist, paths, in_paths, cfg, repos, date, group,
params, diffsels, diffurls, pool):
self.changelist = changelist
self.paths = paths
self.in_paths = in_paths
self.cfg = cfg
self.repos = repos
self.date = date
self.group = group
self.params = params
self.diffsels = diffsels
self.diffurls = diffurls
self.pool = pool
self.diff = self.diff_url = None
self.idx = 0
def __nonzero__(self):
# we always have some items
return True
def __getitem__(self, idx):
while True:
if self.idx == len(self.changelist):
raise IndexError
path, change = self.changelist[self.idx]
self.idx = self.idx + 1
diff = diff_url = None
kind = None
label1 = None
label2 = None
src_fname = None
dst_fname = None
binary = None
singular = None
content = None
# just skip directories. they have no diffs.
if change.item_kind == svn.core.svn_node_dir:
continue
# is this change in (or out of) the set of matched paths?
if (path in self.paths) != self.in_paths:
continue
if change.base_rev != -1:
svndate = self.repos.get_rev_prop(svn.core.SVN_PROP_REVISION_DATE,
change.base_rev)
### pick a different date format?
base_date = time.ctime(svn.core.secs_from_timestr(svndate, self.pool))
else:
base_date = ''
# figure out if/how to generate a diff
base_path = remove_leading_slashes(change.base_path)
if change.action == svn.repos.CHANGE_ACTION_DELETE:
# it was delete.
kind = 'D'
# get the diff url, if any is specified
diff_url = self.diffurls.get_delete_url(self.repos.rev, change)
# show the diff?
if self.diffsels.delete:
diff = svn.fs.FileDiff(self.repos.get_root(change.base_rev),
base_path, None, None, self.pool)
label1 = '%s\t%s\t(r%s)' % (base_path, self.date, change.base_rev)
label2 = '/dev/null\t00:00:00 1970\t(deleted)'
singular = True
elif change.action == svn.repos.CHANGE_ACTION_ADD \
or change.action == svn.repos.CHANGE_ACTION_REPLACE:
if base_path and (change.base_rev != -1):
# any diff of interest?
if change.text_changed:
# this file was copied and modified.
kind = 'W'
# get the diff url, if any is specified
diff_url = self.diffurls.get_copy_url(self.repos.rev, change)
# show the diff?
if self.diffsels.modify:
diff = svn.fs.FileDiff(self.repos.get_root(change.base_rev),
base_path,
self.repos.root_this, change.path,
self.pool)
label1 = '%s\t%s\t(r%s, copy source)' \
% (base_path, base_date, change.base_rev)
label2 = '%s\t%s\t(r%s)' \
% (change.path, self.date, self.repos.rev)
singular = False
else:
# this file was copied.
kind = 'C'
if self.diffsels.copy:
diff = svn.fs.FileDiff(None, None, self.repos.root_this,
change.path, self.pool)
label1 = '/dev/null\t00:00:00 1970\t' \
'(empty, because file is newly added)'
label2 = '%s\t%s\t(r%s, copy of r%s, %s)' \
% (change.path, self.date, self.repos.rev, \
change.base_rev, base_path)
singular = False
else:
# the file was added.
kind = 'A'
# get the diff url, if any is specified
diff_url = self.diffurls.get_add_url(self.repos.rev, change)
# show the diff?
if self.diffsels.add:
diff = svn.fs.FileDiff(None, None, self.repos.root_this,
change.path, self.pool)
label1 = '/dev/null\t00:00:00 1970\t' \
'(empty, because file is newly added)'
label2 = '%s\t%s\t(r%s)' \
% (change.path, self.date, self.repos.rev)
singular = True
elif not change.text_changed:
# the text didn't change, so nothing to show.
continue
else:
# a simple modification.
kind = 'M'
# get the diff url, if any is specified
diff_url = self.diffurls.get_modify_url(self.repos.rev, change)
# show the diff?
if self.diffsels.modify:
diff = svn.fs.FileDiff(self.repos.get_root(change.base_rev),
base_path,
self.repos.root_this, change.path,
self.pool)
label1 = '%s\t%s\t(r%s)' \
% (base_path, base_date, change.base_rev)
label2 = '%s\t%s\t(r%s)' \
% (change.path, self.date, self.repos.rev)
singular = False
if diff:
binary = diff.either_binary()
if binary:
content = src_fname = dst_fname = None
else:
src_fname, dst_fname = diff.get_files()
try:
content = DiffContent(self.cfg.get_diff_cmd(self.group, {
'label_from' : label1,
'label_to' : label2,
'from' : src_fname,
'to' : dst_fname,
}))
except OSError:
# diff command does not exist, try difflib.unified_diff()
content = DifflibDiffContent(label1, label2, src_fname, dst_fname)
# return a data item for this diff
return _data(
path=change.path,
base_path=base_path,
base_rev=change.base_rev,
diff=diff,
diff_url=diff_url,
kind=kind,
label_from=label1,
label_to=label2,
from_fname=src_fname,
to_fname=dst_fname,
binary=binary,
singular=singular,
content=content,
)
def _classify_diff_line(line, seen_change):
# classify the type of line.
first = line[:1]
ltype = ''
if first == '@':
seen_change = True
ltype = 'H'
elif first == '-':
if seen_change:
ltype = 'D'
else:
ltype = 'F'
elif first == '+':
if seen_change:
ltype = 'A'
else:
ltype = 'T'
elif first == ' ':
ltype = 'C'
else:
ltype = 'U'
if line[-2] == '\r':
line=line[0:-2] + '\n' # remove carriage return
return line, ltype, seen_change
class DiffContent:
"This is a generator-like object returning annotated lines of a diff."
def __init__(self, cmd):
self.seen_change = False
# By default we choose to incorporate child stderr into the output
self.pipe = subprocess.Popen(cmd, stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
close_fds=sys.platform != "win32")
def __nonzero__(self):
# we always have some items
return True
def __getitem__(self, idx):
if self.pipe is None:
raise IndexError
line = self.pipe.stdout.readline()
if not line:
# wait on the child so we don't end up with a billion zombies
self.pipe.wait()
self.pipe = None
raise IndexError
line, ltype, self.seen_change = _classify_diff_line(line, self.seen_change)
return _data(
raw=line,
text=line[1:-1], # remove indicator and newline
type=ltype,
)
class DifflibDiffContent():
"This is a generator-like object returning annotated lines of a diff."
def __init__(self, label_from, label_to, from_file, to_file):
import difflib
self.seen_change = False
fromlines = open(from_file, 'U').readlines()
tolines = open(to_file, 'U').readlines()
self.diff = difflib.unified_diff(fromlines, tolines,
label_from, label_to)
def __nonzero__(self):
# we always have some items
return True
def __getitem__(self, idx):
try:
line = self.diff.next()
except StopIteration:
raise IndexError
line, ltype, self.seen_change = _classify_diff_line(line, self.seen_change)
return _data(
raw=line,
text=line[1:-1], # remove indicator and newline
type=ltype,
)
class TextCommitRenderer:
"This class will render the commit mail in plain text."
def __init__(self, output):
self.output = output
def render(self, data):
"Render the commit defined by 'data'."
w = self.output.write
w('Author: %s\nDate: %s\nNew Revision: %s\n' % (data.author,
data.date,
data.rev))
if data.commit_url:
w('URL: %s\n\n' % data.commit_url)
else:
w('\n')
w('Log:\n%s\n\n' % data.log.strip())
# print summary sections
self._render_list('Added', data.added_data)
self._render_list('Replaced', data.replaced_data)
self._render_list('Deleted', data.deleted_data)
self._render_list('Modified', data.modified_data)
if data.other_added_data or data.other_replaced_data \
or data.other_deleted_data or data.other_modified_data:
if data.show_nonmatching_paths:
w('\nChanges in other areas also in this revision:\n')
self._render_list('Added', data.other_added_data)
self._render_list('Replaced', data.other_replaced_data)
self._render_list('Deleted', data.other_deleted_data)
self._render_list('Modified', data.other_modified_data)
else:
w('and changes in other areas\n')
self._render_diffs(data.diffs, '')
if data.other_diffs:
self._render_diffs(data.other_diffs,
'\nDiffs of changes in other areas also'
' in this revision:\n')
def _render_list(self, header, data_list):
if not data_list:
return
w = self.output.write
w(header + ':\n')
for d in data_list:
if d.is_dir:
is_dir = '/'
else:
is_dir = ''
if d.props_changed:
if d.text_changed:
props = ' (contents, props changed)'
else:
props = ' (props changed)'
else:
props = ''
w(' %s%s%s\n' % (d.path, is_dir, props))
if d.copied:
if is_dir:
text = ''
elif d.text_changed:
text = ', changed'
else:
text = ' unchanged'
w(' - copied%s from r%d, %s%s\n'
% (text, d.base_rev, d.base_path, is_dir))
def _render_diffs(self, diffs, section_header):
"""Render diffs. Write the SECTION_HEADER if there are actually
any diffs to render."""
if not diffs:
return
w = self.output.write
section_header_printed = False
for diff in diffs:
if not diff.diff and not diff.diff_url:
continue
if not section_header_printed:
w(section_header)
section_header_printed = True
if diff.kind == 'D':
w('\nDeleted: %s\n' % diff.base_path)
elif diff.kind == 'A':
w('\nAdded: %s\n' % diff.path)
elif diff.kind == 'C':
w('\nCopied: %s (from r%d, %s)\n'
% (diff.path, diff.base_rev, diff.base_path))
elif diff.kind == 'W':
w('\nCopied and modified: %s (from r%d, %s)\n'
% (diff.path, diff.base_rev, diff.base_path))
else:
# kind == 'M'
w('\nModified: %s\n' % diff.path)
if diff.diff_url:
w('URL: %s\n' % diff.diff_url)
if not diff.diff:
continue
w(SEPARATOR + '\n')
if diff.binary:
if diff.singular:
w('Binary file. No diff available.\n')
else:
w('Binary file (source and/or target). No diff available.\n')
continue
for line in diff.content:
w(line.raw)
class Repository:
"Hold roots and other information about the repository."
def __init__(self, repos_dir, rev, pool):
self.repos_dir = repos_dir
self.rev = rev
self.pool = pool
self.repos_ptr = svn.repos.open(repos_dir, pool)
self.fs_ptr = svn.repos.fs(self.repos_ptr)
self.roots = { }
self.root_this = self.get_root(rev)
self.author = self.get_rev_prop(svn.core.SVN_PROP_REVISION_AUTHOR)
def get_rev_prop(self, propname, rev = None):
if not rev:
rev = self.rev
return svn.fs.revision_prop(self.fs_ptr, rev, propname, self.pool)
def get_root(self, rev):
try:
return self.roots[rev]
except KeyError:
pass
root = self.roots[rev] = svn.fs.revision_root(self.fs_ptr, rev, self.pool)
return root
class Config:
# The predefined configuration sections. These are omitted from the
# set of groups.
_predefined = ('general', 'defaults', 'maps')
def __init__(self, fname, repos, global_params):
cp = configparser.ConfigParser()
cp.read(fname)
# record the (non-default) groups that we find
self._groups = [ ]
for section in cp.sections():
if not hasattr(self, section):
section_ob = _sub_section()
setattr(self, section, section_ob)
if section not in self._predefined:
self._groups.append(section)
else:
section_ob = getattr(self, section)
for option in cp.options(section):
# get the raw value -- we use the same format for *our* interpolation
value = cp.get(section, option, raw=1)
setattr(section_ob, option, value)
# be compatible with old format config files
if hasattr(self.general, 'diff') and not hasattr(self.defaults, 'diff'):
self.defaults.diff = self.general.diff
if not hasattr(self, 'maps'):
self.maps = _sub_section()
# these params are always available, although they may be overridden
self._global_params = global_params.copy()
# prepare maps. this may remove sections from consideration as a group.
self._prep_maps()
# process all the group sections.
self._prep_groups(repos)
def is_set(self, option):
"""Return None if the option is not set; otherwise, its value is returned.
The option is specified as a dotted symbol, such as 'general.mail_command'
"""
ob = self
for part in option.split('.'):
if not hasattr(ob, part):
return None
ob = getattr(ob, part)
return ob
def get(self, option, group, params):
"Get a config value with appropriate substitutions and value mapping."
# find the right value
value = None
if group:
sub = getattr(self, group)
value = getattr(sub, option, None)
if value is None:
value = getattr(self.defaults, option, '')
# parameterize it
if params is not None:
value = value % params
# apply any mapper
mapper = getattr(self.maps, option, None)
if mapper is not None:
value = mapper(value)
# Apply any parameters that may now be available for
# substitution that were not before the mapping.
if value is not None and params is not None:
value = value % params
return value
def get_diff_cmd(self, group, args):
"Get a diff command as a list of argv elements."
### do some better splitting to enable quoting of spaces
diff_cmd = self.get('diff', group, None).split()
cmd = [ ]
for part in diff_cmd:
cmd.append(part % args)
return cmd
def _prep_maps(self):
"Rewrite the [maps] options into callables that look up values."
mapsections = []
for optname, mapvalue in vars(self.maps).items():
if mapvalue[:1] == '[':
# a section is acting as a mapping
sectname = mapvalue[1:-1]
if not hasattr(self, sectname):
raise UnknownMappingSection(sectname)
# construct a lambda to look up the given value as an option name,
# and return the option's value. if the option is not present,
# then just return the value unchanged.
setattr(self.maps, optname,
lambda value,
sect=getattr(self, sectname): getattr(sect,
value.lower(),
value))
# mark for removal when all optnames are done
if sectname not in mapsections:
mapsections.append(sectname)
# elif test for other mapper types. possible examples:
# dbm:filename.db
# file:two-column-file.txt
# ldap:some-query-spec
# just craft a mapper function and insert it appropriately
else:
raise UnknownMappingSpec(mapvalue)
# remove each mapping section from consideration as a group
for sectname in mapsections:
self._groups.remove(sectname)
def _prep_groups(self, repos):
self._group_re = [ ]
repos_dir = os.path.abspath(repos.repos_dir)
# compute the default repository-based parameters. start with some
# basic parameters, then bring in the regex-based params.
self._default_params = self._global_params
try:
match = re.match(self.defaults.for_repos, repos_dir)
if match:
self._default_params = self._default_params.copy()
self._default_params.update(match.groupdict())
except AttributeError:
# there is no self.defaults.for_repos
pass
# select the groups that apply to this repository
for group in self._groups:
sub = getattr(self, group)
params = self._default_params
if hasattr(sub, 'for_repos'):
match = re.match(sub.for_repos, repos_dir)
if not match:
continue
params = params.copy()
params.update(match.groupdict())
# if a matching rule hasn't been given, then use the empty string
# as it will match all paths
for_paths = getattr(sub, 'for_paths', '')
exclude_paths = getattr(sub, 'exclude_paths', None)
if exclude_paths:
exclude_paths_re = re.compile(exclude_paths)
else:
exclude_paths_re = None
# check search_logmsg re
search_logmsg = getattr(sub, 'search_logmsg', None)
if search_logmsg is not None:
search_logmsg_re = re.compile(search_logmsg)
else:
search_logmsg_re = None
self._group_re.append((group,
re.compile(for_paths),
exclude_paths_re,
params,
search_logmsg_re))
# after all the groups are done, add in the default group
try:
self._group_re.append((None,
re.compile(self.defaults.for_paths),
None,
self._default_params,
None))
except AttributeError:
# there is no self.defaults.for_paths
pass
def which_groups(self, path, logmsg):
"Return the path's associated groups."
groups = []
for group, pattern, exclude_pattern, repos_params, search_logmsg_re in self._group_re:
match = pattern.match(path)
if match:
if exclude_pattern and exclude_pattern.match(path):
continue
params = repos_params.copy()
params.update(match.groupdict())
if search_logmsg_re is None:
groups.append((group, params))
else:
if logmsg is None:
logmsg = ''
for match in search_logmsg_re.finditer(logmsg):
# Add captured variables to (a copy of) params
msg_params = params.copy()
msg_params.update(match.groupdict())
groups.append((group, msg_params))
if not groups:
groups.append((None, self._default_params))
return groups
class _sub_section:
pass
class _data:
"Helper class to define an attribute-based hunk o' data."
def __init__(self, **kw):
vars(self).update(kw)
class MissingConfig(Exception):
pass
class UnknownMappingSection(Exception):
pass
class UnknownMappingSpec(Exception):
pass
class UnknownSubcommand(Exception):
pass
if __name__ == '__main__':
def usage():
scriptname = os.path.basename(sys.argv[0])
sys.stderr.write(
"""USAGE: %s commit REPOS REVISION [CONFIG-FILE]
%s propchange REPOS REVISION AUTHOR REVPROPNAME [CONFIG-FILE]
%s propchange2 REPOS REVISION AUTHOR REVPROPNAME ACTION [CONFIG-FILE]
%s lock REPOS AUTHOR [CONFIG-FILE]
%s unlock REPOS AUTHOR [CONFIG-FILE]
If no CONFIG-FILE is provided, the script will first search for a mailer.conf
file in REPOS/conf/. Failing that, it will search the directory in which
the script itself resides.
ACTION was added as a fifth argument to the post-revprop-change hook
in Subversion 1.2.0. Its value is one of 'A', 'M' or 'D' to indicate
if the property was added, modified or deleted, respectively.
""" % (scriptname, scriptname, scriptname, scriptname, scriptname))
sys.exit(1)
# Command list: subcommand -> number of arguments expected (not including
# the repository directory and config-file)
cmd_list = {'commit' : 1,
'propchange' : 3,
'propchange2': 4,
'lock' : 1,
'unlock' : 1,
}
config_fname = None
argc = len(sys.argv)
if argc < 3:
usage()
cmd = sys.argv[1]
repos_dir = svn.core.svn_path_canonicalize(sys.argv[2])
try:
expected_args = cmd_list[cmd]
except KeyError:
usage()
if argc < (expected_args + 3):
usage()
elif argc > expected_args + 4:
usage()
elif argc == (expected_args + 4):
config_fname = sys.argv[expected_args + 3]
# Settle on a config file location, and open it.
if config_fname is None:
# Default to REPOS-DIR/conf/mailer.conf.
config_fname = os.path.join(repos_dir, 'conf', 'mailer.conf')
if not os.path.exists(config_fname):
# Okay. Look for 'mailer.conf' as a sibling of this script.
config_fname = os.path.join(os.path.dirname(sys.argv[0]), 'mailer.conf')
if not os.path.exists(config_fname):
raise MissingConfig(config_fname)
svn.core.run_app(main, cmd, config_fname, repos_dir,
sys.argv[3:3+expected_args])
# ------------------------------------------------------------------------
# TODO
#
# * add configuration options
# - each group defines delivery info:
# o whether to set Reply-To and/or Mail-Followup-To
# (btw: it is legal do set Reply-To since this is the originator of the
# mail; i.e. different from MLMs that munge it)
# - each group defines content construction:
# o max size of diff before trimming
# o max size of entire commit message before truncation
# - per-repository configuration
# o extra config living in repos
# o optional, non-mail log file
# o look up authors (username -> email; for the From: header) in a
# file(s) or DBM
# * get rid of global functions that should properly be class methods |