3 # mailer.py: send email describing a commit
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 \
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.
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.
52 "You need version %s or better of the Subversion Python bindings.\n" \
53 % string
.join(map(lambda x
: str(x
), _MIN_SVN_VERSION
), '.'))
55 if _MIN_SVN_VERSION
> [svn
.core
.SVN_VER_MAJOR
,
56 svn
.core
.SVN_VER_MINOR
,
57 svn
.core
.SVN_VER_PATCH
]:
59 "You need version %s or better of the Subversion Python bindings.\n" \
60 % string
.join(map(lambda x
: str(x
), _MIN_SVN_VERSION
), '.'))
66 def main(pool
, cmd
, config_fname
, repos_dir
, cmd_args
):
67 ### TODO: Sanity check the incoming args
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])
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
82 cfg
= Config(config_fname
, repos
, { 'author' : author
})
83 messenger
= PropChange(pool
, cfg
, repos
, author
, propname
, action
)
84 elif cmd
== 'lock' or cmd
== 'unlock':
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
89 cfg
= Config(config_fname
, repos
, { 'author' : author
})
90 messenger
= Lock(pool
, cfg
, repos
, author
, cmd
== 'lock')
92 raise UnknownSubcommand(cmd
)
97 # Minimal, incomplete, versions of popen2.Popen[34] for those platforms
98 # for which popen2 does not provide them.
100 Popen3
= popen2
.Popen3
101 Popen4
= popen2
.Popen4
102 except AttributeError:
104 def __init__(self
, cmd
, capturestderr
= False):
105 if type(cmd
) != types
.StringType
:
106 cmd
= svn
.core
.argv_to_command_string(cmd
)
108 self
.fromchild
, self
.tochild
, self
.childerr \
109 = popen2
.popen3(cmd
, mode
='b')
111 self
.fromchild
, self
.tochild
= popen2
.popen2(cmd
, mode
='b')
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
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')
128 rv
= self
.fromchild
.close()
129 rv
= self
.tochild
.close() or rv
132 def remove_leading_slashes(path
):
133 while path
and path
[0] == '/':
139 "Abstract base class to formalize the inteface of output methods"
141 def __init__(self
, cfg
, repos
, prefix_param
):
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.
151 def make_subject(self
, group
, params
):
152 prefix
= self
.cfg
.get(self
.prefix_param
, group
, params
)
154 subject
= prefix
+ ' ' + self
.subject
156 subject
= self
.subject
159 truncate_subject
= int(
160 self
.cfg
.get('truncate_subject', group
, params
))
164 if truncate_subject
and len(subject
) > truncate_subject
:
165 subject
= subject
[:(truncate_subject
- 3)] + "..."
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
178 """Override this method.
179 Flush any cached information and finish writing the output
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
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
)
197 buf
= pipe_ob
.fromchild
.read(self
._CHUNKSIZE
)
199 # wait on the child so we don't end up with a billion zombies
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] == ']':
217 filter(None, string
.split(to_addr_in
[3:], to_addr_in
[1]))
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
)
237 subject
.encode('ascii')
239 from email
.Header
import Header
240 subject
= Header(subject
, 'utf-8').encode()
241 hdrs
= 'From: %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
)
249 hdrs
= '%sReply-To: %s\n' % (hdrs
, self
.reply_to
)
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
))
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())
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")
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
))
315 # signal that we're done sending content
316 self
.pipe
.tochild
.close()
318 # wait to avoid zombies
323 def __init__(self
, pool
, cfg
, repos
, prefix_param
):
328 if cfg
.is_set('general.mail_command'):
330 elif cfg
.is_set('general.smtp_hostname'):
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
, \
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
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()
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
)]
364 self
.groups
[group
, tuple(param_list
)] = (params
, paths
)
366 # figure out the changed directories
368 for path
, change
in self
.changelist
:
369 if change
.item_kind
== svn
.core
.svn_node_dir
:
372 idx
= string
.rfind(path
, '/')
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.
384 dirlist
= string
.join(dirlist
)
386 self
.output
.subject
= 'r%d - in %s: %s' % (repos
.rev
, commondir
, dirlist
)
388 self
.output
.subject
= 'r%d - %s' % (repos
.rev
, dirlist
)
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
)
412 svn
.core
.svn_pool_clear(subpool
)
414 svn
.core
.svn_pool_destroy(subpool
)
418 from tempfile
import NamedTemporaryFile
420 # NamedTemporaryFile was added in Python 2.3, so we need to emulate it
422 class NamedTemporaryFile
:
424 self
.name
= tempfile
.mktemp()
425 self
.file = open(self
.name
, 'w+b')
428 def write(self
, data
):
429 self
.file.write(data
)
434 class PropChange(Messenger
):
435 def __init__(self
, pool
, cfg
, repos
, author
, propname
, action
):
436 Messenger
.__init
__(self
, pool
, cfg
, repos
, 'propchange_subject_prefix')
438 self
.propname
= propname
441 # collect the set of groups and the unique sets of params for the options
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()
447 self
.groups
[group
, tuple(param_list
)] = params
449 self
.output
.subject
= 'r%d - %s' % (repos
.rev
, propname
)
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'
457 'Property Name: %s\n'
460 % (self
.author
, self
.repos
.rev
, self
.propname
,
461 actions
.get(self
.action
, 'Unknown (\'%s\')' \
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())
472 tempfile2
= NamedTemporaryFile()
473 tempfile2
.write(self
.repos
.get_rev_prop(self
.propname
))
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
,
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
:
494 common
= string
.split(dirlist
[0], '/')
495 for j
in range(1, len(dirlist
)):
497 parts
= string
.split(d
, '/')
498 for i
in range(len(common
)):
499 if i
== len(parts
) or common
[i
] != parts
[i
]:
502 commondir
= string
.join(common
, '/')
504 # strip the common portion from each directory
505 l
= len(commondir
) + 1
511 newdirs
.append(d
[l
:])
513 # nothing in common, so reset the list of directories
516 return commondir
, newdirs
519 class Lock(Messenger
):
520 def __init__(self
, pool
, cfg
, repos
, author
, do_lock
):
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
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()
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
)]
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.
550 dirlist
= string
.join(dirlist
)
552 self
.output
.subject
= '%s: %s' % (commondir
, dirlist
)
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
)
562 for (group
, param_tuple
), (params
, paths
) in self
.groups
.items():
563 self
.output
.start(group
, params
)
565 self
.output
.write('Author: %s\n'
567 (self
.author
, self
.do_lock
and 'Locked' or 'Unlocked'))
570 for dir in self
.dirlist
:
571 self
.output
.write(' %s\n\n' % dir)
574 self
.output
.write('Comment:\n%s\n' % (self
.lock
.comment
or ''))
579 class DiffSelections
:
580 def __init__(self
, cfg
, group
, params
):
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.
592 list = string
.split(gen_diffs
, " ")
607 ### These options are deprecated
608 suppress
= cfg
.get('suppress_deletes', group
, params
)
609 if suppress
== 'yes':
611 suppress
= cfg
.get('suppress_adds', group
, params
)
612 if suppress
== 'yes':
616 class DiffURLSelections
:
617 def __init__(self
, cfg
, group
, 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
) \
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
,
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
) \
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
)
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
712 for path
, change
in changelist
:
713 if selection(change
) and paths
.has_key(path
) == in_paths
:
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
,
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
737 self
.in_paths
= in_paths
743 self
.diffsels
= diffsels
744 self
.diffurls
= diffurls
747 self
.diff
= self
.diff_url
= None
751 def __nonzero__(self
):
752 # we always have some items
755 def __getitem__(self
, idx
):
757 if self
.idx
== len(self
.changelist
):
760 path
, change
= self
.changelist
[self
.idx
]
761 self
.idx
= self
.idx
+ 1
763 diff
= diff_url
= None
773 # just skip directories. they have no diffs.
774 if change
.item_kind
== svn
.core
.svn_node_dir
:
777 # is this change in (or out of) the set of matched paths?
778 if self
.paths
.has_key(path
) != self
.in_paths
:
781 if change
.base_rev
!= -1:
782 svndate
= self
.repos
.get_rev_prop(svn
.core
.SVN_PROP_REVISION_DATE
,
784 ### pick a different date format?
785 base_date
= time
.ctime(svn
.core
.secs_from_timestr(svndate
, self
.pool
))
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
:
796 # get the diff url, if any is specified
797 diff_url
= self
.diffurls
.get_delete_url(self
.repos
.rev
, change
)
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)'
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.
817 # get the diff url, if any is specified
818 diff_url
= self
.diffurls
.get_copy_url(self
.repos
.rev
, change
)
821 if self
.diffsels
.modify
:
822 diff
= svn
.fs
.FileDiff(self
.repos
.get_root(change
.base_rev
),
824 self
.repos
.root_this
, change
.path
,
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
)
832 # this file was copied.
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
)
844 # the file was added.
847 # get the diff url, if any is specified
848 diff_url
= self
.diffurls
.get_add_url(self
.repos
.rev
, change
)
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
)
860 elif not change
.text_changed
:
861 # the text didn't change, so nothing to show.
864 # a simple modification.
867 # get the diff url, if any is specified
868 diff_url
= self
.diffurls
.get_modify_url(self
.repos
.rev
, change
)
871 if self
.diffsels
.modify
:
872 diff
= svn
.fs
.FileDiff(self
.repos
.get_root(change
.base_rev
),
874 self
.repos
.root_this
, change
.path
,
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
)
883 binary
= diff
.either_binary()
885 content
= src_fname
= dst_fname
= None
887 src_fname
, dst_fname
= diff
.get_files()
888 content
= DiffContent(self
.cfg
.get_diff_cmd(self
.group
, {
889 'label_from' : label1
,
895 # return a data item for this diff
899 base_rev
=change
.base_rev
,
905 from_fname
=src_fname
,
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
926 def __getitem__(self
, idx
):
927 if self
.pipe
is None:
930 line
= self
.pipe
.fromchild
.readline()
932 # wait on the child so we don't end up with a billion zombies
937 # classify the type of line.
940 self
.seen_change
= True
958 line
=line
[0:-2] + '\n' # remove carriage return
962 text
=line
[1:-1], # remove indicator and newline
967 class TextCommitRenderer
:
968 "This class will render the commit mail in plain text."
970 def __init__(self
, 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
,
983 w('URL: %s\n\n' % data
.commit_url
)
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
)
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
):
1016 w
= self
.output
.write
1025 props
= ' (contents, props changed)'
1027 props
= ' (props changed)'
1030 w(' %s%s%s\n' % (d
.path
, is_dir
, props
))
1034 elif d
.text_changed
:
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."""
1046 w
= self
.output
.write
1047 section_header_printed
= False
1050 if not diff
.diff
and not diff
.diff_url
:
1052 if not section_header_printed
:
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
))
1067 w('\nModified: %s\n' % diff
.path
)
1070 w('URL: %s\n' % diff
.diff_url
)
1079 w('Binary file. No diff available.\n')
1081 w('Binary file (source and/or target). No diff available.\n')
1084 for line
in diff
.content
:
1089 "Hold roots and other information about the repository."
1091 def __init__(self
, repos_dir
, rev
, pool
):
1092 self
.repos_dir
= repos_dir
1096 self
.repos_ptr
= svn
.repos
.open(repos_dir
, pool
)
1097 self
.fs_ptr
= svn
.repos
.fs(self
.repos_ptr
)
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):
1108 return svn
.fs
.revision_prop(self
.fs_ptr
, rev
, propname
, self
.pool
)
1110 def get_root(self
, rev
):
1112 return self
.roots
[rev
]
1115 root
= self
.roots
[rev
] = svn
.fs
.revision_root(self
.fs_ptr
, rev
, self
.pool
)
1121 # The predefined configuration sections. These are omitted from the
1123 _predefined
= ('general', 'defaults', 'maps')
1125 def __init__(self
, fname
, repos
, global_params
):
1126 cp
= ConfigParser
.ConfigParser()
1129 # record the (non-default) groups that we find
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
)
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.
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'
1166 for part
in string
.split(option
, '.'):
1167 if not hasattr(ob
, part
):
1169 ob
= getattr(ob
, part
)
1172 def get(self
, option
, group
, params
):
1173 "Get a config value with appropriate substitutions and value mapping."
1175 # find the right value
1178 sub
= getattr(self
, group
)
1179 value
= getattr(sub
, option
, None)
1181 value
= getattr(self
.defaults
, option
, '')
1184 if params
is not None:
1185 value
= value
% params
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
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))
1205 for part
in diff_cmd
:
1206 cmd
.append(part
% args
)
1209 def _prep_maps(self
):
1210 "Rewrite the [maps] options into callables that look up values."
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
,
1225 sect
=getattr(self
, sectname
): getattr(sect
,
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:
1234 # file:two-column-file.txt
1235 # ldap:some-query-spec
1236 # just craft a mapper function and insert it appropriately
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
1256 match
= re
.match(self
.defaults
.for_repos
, repos_dir
)
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
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
)
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)
1280 exclude_paths_re
= re
.compile(exclude_paths
)
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
1289 self
._group
_re
.append((None,
1290 re
.compile(self
.defaults
.for_paths
),
1292 self
._default
_params
))
1293 except AttributeError:
1294 # there is no self.defaults.for_paths
1297 def which_groups(self
, path
):
1298 "Return the path's associated groups."
1300 for group
, pattern
, exclude_pattern
, repos_params
in self
._group
_re
:
1301 match
= pattern
.match(path
)
1303 if exclude_pattern
and exclude_pattern
.match(path
):
1305 params
= repos_params
.copy()
1306 params
.update(match
.groupdict())
1307 groups
.append((group
, params
))
1309 groups
.append((None, self
._default
_params
))
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):
1323 class UnknownMappingSection(Exception):
1325 class UnknownMappingSpec(Exception):
1327 class UnknownSubcommand(Exception):
1331 # enable True/False in older vsns of Python
1339 if __name__
== '__main__':
1341 scriptname
= os
.path
.basename(sys
.argv
[0])
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
))
1360 # Command list: subcommand -> number of arguments expected (not including
1361 # the repository directory and config-file)
1362 cmd_list
= {'commit' : 1,
1370 argc
= len(sys
.argv
)
1375 repos_dir
= svn
.core
.svn_path_canonicalize(sys
.argv
[2])
1377 expected_args
= cmd_list
[cmd
]
1381 if argc
< (expected_args
+ 3):
1383 elif argc
> expected_args
+ 4:
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 # ------------------------------------------------------------------------
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
1417 # * get rid of global functions that should properly be class methods