Merge lp:~torkvemada/bzr/commit_hooks into lp:bzr

Proposed by Vsevolod Velichko
Status: Needs review
Proposed branch: lp:~torkvemada/bzr/commit_hooks
Merge into: lp:bzr
Diff against target: 244 lines (+153/-20)
3 files modified
bzrlib/commit.py (+94/-0)
bzrlib/tests/per_workingtree/test_commit.py (+23/-20)
bzrlib/tests/test_commit.py (+36/-0)
To merge this branch: bzr merge lp:~torkvemada/bzr/commit_hooks
Reviewer Review Type Date Requested Status
Martin Packman (community) Abstain
Review via email: mp+115348@code.launchpad.net

Description of the change

New class of hooks: commit ones; and the corresponding Commit.start_commit hook to enable commit metadata modification.

Rationale: there's a set of the metadata properties that are stored along with the commit itself, but at the moment there is no way to fetch or set any of them.
I suppose that since the interaction with all the metadata strictly belongs to commit itself, it's reasonable to create the hook, allowing plugin to modify the metadata for the Commit class.

As a proof of concept I have implemented the 'cimage' plugin[1], which utilizes the start_hook invented in this diff to add the user webcam photo to commit metadata. Later the photo can be fetched from metadata by appropriate command. Practically, all the cimage branch commits are equipped with the stored photos so one can easily install plugin, checkout that branch and view them.

[1] http://bazaar.launchpad.net/~torkvemada/+junk/bzr-cimage/view/head:/__init__.py

To post a comment you must log in.
lp:~torkvemada/bzr/commit_hooks updated
6540. By Vsevolod Velichko

Test for commit.start_commit hook.

6541. By Vsevolod Velichko

Merge with trunk

6542. By Vsevolod Velichko

Remove unnecessary import in hook test

Revision history for this message
Martin Packman (gz) wrote :

Thanks for making these changes and explaining the merge proposal a little more. I've asked Jelmer to have a look.

review: Abstain
Revision history for this message
Jelmer Vernooij (jelmer) wrote :

This doesn't look unreasonable.

I'm wary about passing an instance of the Commit object to a hook; Commit at the moment is fairly private, exposing it this way makes it harder to change things there in the future.

Revision history for this message
Vsevolod Velichko (torkvemada) wrote :

Jelmer, thanks for your look.
I thought about the idea passing the properties dict itself to the hook, but finally I supposed that one may wish to access commit timestamp or something like that.
Obviously, if you are sure, that one should never wish things like that, I can modify the hook and pass the properties dict :)
Or may be there should be some special struct having only "safe" fields?

Revision history for this message
Martin Packman (gz) wrote :

In the past, hooks have been given custom objects with just a selection of fields, see for instance bzrlib.status.StatusHookParams - doing the same thing here would make sense, though there are a lot of parameters passed to Commit.commit and we probably don't want to expose them all. Perhaps just revprops, message (though that's complicated, there are already two commit message hooks fighting over it), committer, and maybe the two time ones.

I'd be careful about what the hook is allowed to modify, particularly a test that sets an invalid revision property (see bzrlib.revision.Revision._check_properties) should raise a ValueError rather than breaking the commit in another manner.

lp:~torkvemada/bzr/commit_hooks updated
6543. By Vsevolod Velichko

Do not pass all the commit data to the hook, but only some restricted set of properties.

6544. By Vsevolod Velichko

Some test cases on which Commit.start_commit hook should fail.

Revision history for this message
Vsevolod Velichko (torkvemada) wrote :

Martin, the idea of class with custom fields is cool (before your comment I was unsure about it, since I hadn't seen other hooks with such idea in bzr).
I've updated the hook, so it passes only limited set of params (I've eliminated even the message, I suppose that set_commit_message hook should be enough) and added some tests for invalid data.

Unmerged revisions

6544. By Vsevolod Velichko

Some test cases on which Commit.start_commit hook should fail.

6543. By Vsevolod Velichko

Do not pass all the commit data to the hook, but only some restricted set of properties.

6542. By Vsevolod Velichko

Remove unnecessary import in hook test

6541. By Vsevolod Velichko

Merge with trunk

6540. By Vsevolod Velichko

Test for commit.start_commit hook.

6539. By Vsevolod Velichko

Add commit.Commit hooks class and start_commit hook

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
=== modified file 'bzrlib/commit.py'
--- bzrlib/commit.py 2012-02-17 16:48:41 +0000
+++ bzrlib/commit.py 2012-10-12 16:19:21 +0000
@@ -56,6 +56,7 @@
56 trace,56 trace,
57 tree,57 tree,
58 ui,58 ui,
59 hooks,
59 )60 )
60from bzrlib.branch import Branch61from bzrlib.branch import Branch
61from bzrlib.cleanup import OperationWithCleanups62from bzrlib.cleanup import OperationWithCleanups
@@ -74,6 +75,7 @@
74from bzrlib import symbol_versioning75from bzrlib import symbol_versioning
75from bzrlib.urlutils import unescape_for_display76from bzrlib.urlutils import unescape_for_display
76from bzrlib.i18n import gettext77from bzrlib.i18n import gettext
78from bzrlib.osutils import contains_whitespace
7779
78class NullCommitReporter(object):80class NullCommitReporter(object):
79 """I report on progress of a commit."""81 """I report on progress of a commit."""
@@ -402,6 +404,9 @@
402 if len(self.parents) > 1 and self.exclude:404 if len(self.parents) > 1 and self.exclude:
403 raise errors.CannotCommitSelectedFileMerge(self.exclude)405 raise errors.CannotCommitSelectedFileMerge(self.exclude)
404406
407 # process start_commit hooks
408 self._process_commit_hooks('start_commit')
409
405 # Collect the changes410 # Collect the changes
406 self._set_progress_stage("Collecting changes", counter=True)411 self._set_progress_stage("Collecting changes", counter=True)
407 self._lossy = lossy412 self._lossy = lossy
@@ -618,6 +623,50 @@
618 # process new style post commit hooks623 # process new style post commit hooks
619 self._process_hooks("post_commit", old_revno, new_revno)624 self._process_hooks("post_commit", old_revno, new_revno)
620625
626 def _merge_hook_properties(self, hookData):
627 """
628 Verify that all commit properties were not corrupted by hook
629 and merge them into self.
630 :type hookData: CommitHookParams
631 :param hookData: properties which were modified by hook
632 """
633 for name, value in hookData.revprops.iteritems():
634 if not isinstance(name, basestring) or contains_whitespace(name):
635 raise ValueError("invalid property name %r" % name)
636 if not isinstance(value, basestring):
637 raise ValueError("invalid property value %r for %r" % (value, name))
638 if hookData.timestamp is not None and not isinstance(hookData.timestamp, (int, float)):
639 raise ValueError("Invalid timestamp type: %r" % hookData.timestamp.__class__)
640 if hookData.timezone is not None and not isinstance(hookData.timezone, (int, float)):
641 raise ValueError("Invalid timezone type: %r" % hookData.timezone.__class__)
642 if hookData.committer is not None and not isinstance(hookData.committer, basestring):
643 raise ValueError("Invalid committer type: %r" % hookData.committer.__class__)
644 self.revprops = hookData.revprops
645 self.timestamp = hookData.timestamp
646 self.timezone = hookData.timezone
647 self.committer = hookData.committer
648
649 def _process_commit_hooks(self, hook_name):
650 """Process any registered commit hooks."""
651 self._set_progress_stage("Running %s hooks" % hook_name)
652 if not Commit.hooks[hook_name]:
653 return
654 for hook in Commit.hooks[hook_name]:
655 # show the running hook in the progress bar. As hooks may
656 # end up doing nothing (e.g. because they are not configured by
657 # the user) this is still showing progress, not showing overall
658 # actions - its up to each plugin to show a UI if it want's to
659 # (such as 'Emailing diff to foo@example.com').
660 self.pb_stage_name = "Running %s hooks [%s]" % \
661 (hook_name, Commit.hooks.get_hook_name(hook))
662 self._emit_progress()
663 if 'hooks' in debug.debug_flags:
664 mutter("Invoking commit hook: %r", hook)
665 hookData = CommitHookParams(self.revprops, self.timestamp, self.timezone, self.committer)
666 hook(hookData)
667 self._merge_hook_properties(hookData)
668
669
621 def _process_hooks(self, hook_name, old_revno, new_revno):670 def _process_hooks(self, hook_name, old_revno, new_revno):
622 if not Branch.hooks[hook_name]:671 if not Branch.hooks[hook_name]:
623 return672 return
@@ -1017,3 +1066,48 @@
1017 self.specific_files, [self.basis_tree, self.work_tree])1066 self.specific_files, [self.basis_tree, self.work_tree])
1018 else:1067 else:
1019 self.specific_file_ids = None1068 self.specific_file_ids = None
1069
1070class CommitHookParams(object):
1071 """
1072 Object holding params passed to Commit hooks.
1073
1074 :ivar revprops: commit metadata properties, dict of basicstring => basicstring
1075 :ivar timestamp: commit timestamp
1076 :ivar timezone: commit timezone
1077 :ivar committer: committer name
1078 """
1079 def __init__(self, revprops, timestamp, timezone, committer):
1080 self.revprops = revprops
1081 self.timestamp = timestamp
1082 self.timezone = timezone
1083 self.committer = committer
1084
1085 def __eq__(self, other):
1086 if self is other:
1087 return True
1088 return (self.revprops == other.revprops
1089 and self.timestamp == other.timestamp
1090 and self.timezone == other.timezone
1091 and self.committer == other.committer)
1092
1093 def __repr__(self):
1094 return "<%s(%s, %s, %s, %s)>" % (self.__class__.__name__,
1095 self.revprops, self.timestamp, self.timezone, self.committer)
1096
1097class CommitHooks(hooks.Hooks):
1098 """A dictionary mapping a hook name to a list of callables for commit hooks.
1099 """
1100
1101 def __init__(self):
1102 """Create the default hooks.
1103
1104 """
1105 hooks.Hooks.__init__(self, "bzrlib.commit", "Commit.hooks")
1106 self.add_hook('start_commit',
1107 'Called before a commit is performed on a tree. The start commit '
1108 'hook is able to change the commit properties before the commit '
1109 'takes place. start_commit is called with the bzrlib.commit.Commit '
1110 'being performed. The bzrlib.commit.CommitHookParams instance is '
1111 'passed as as hook argument.', (2, 6))
1112
1113Commit.hooks = CommitHooks()
10201114
=== modified file 'bzrlib/tests/per_workingtree/test_commit.py'
--- bzrlib/tests/per_workingtree/test_commit.py 2012-09-19 07:58:27 +0000
+++ bzrlib/tests/per_workingtree/test_commit.py 2012-10-12 16:19:21 +0000
@@ -494,12 +494,13 @@
494 tree.commit('second post', specific_files=['b'])494 tree.commit('second post', specific_files=['b'])
495 # 5 steps, the first of which is reported 2 times, once per dir495 # 5 steps, the first of which is reported 2 times, once per dir
496 self.assertEqual(496 self.assertEqual(
497 [('update', 1, 5, 'Collecting changes [0] - Stage'),497 [('update', 1, 5, 'Running start_commit hooks - Stage'),
498 ('update', 1, 5, 'Collecting changes [1] - Stage'),498 ('update', 2, 5, 'Collecting changes [0] - Stage'),
499 ('update', 2, 5, 'Saving data locally - Stage'),499 ('update', 2, 5, 'Collecting changes [1] - Stage'),
500 ('update', 3, 5, 'Running pre_commit hooks - Stage'),500 ('update', 3, 5, 'Saving data locally - Stage'),
501 ('update', 4, 5, 'Updating the working tree - Stage'),501 ('update', 4, 5, 'Running pre_commit hooks - Stage'),
502 ('update', 5, 5, 'Running post_commit hooks - Stage')],502 ('update', 5, 5, 'Updating the working tree - Stage'),
503 ('update', 6, 5, 'Running post_commit hooks - Stage')],
503 factory._calls504 factory._calls
504 )505 )
505506
@@ -515,13 +516,14 @@
515 'hook name')516 'hook name')
516 tree.commit('first post')517 tree.commit('first post')
517 self.assertEqual(518 self.assertEqual(
518 [('update', 1, 5, 'Collecting changes [0] - Stage'),519 [('update', 1, 5, 'Running start_commit hooks - Stage'),
519 ('update', 1, 5, 'Collecting changes [1] - Stage'),520 ('update', 2, 5, 'Collecting changes [0] - Stage'),
520 ('update', 2, 5, 'Saving data locally - Stage'),521 ('update', 2, 5, 'Collecting changes [1] - Stage'),
521 ('update', 3, 5, 'Running pre_commit hooks - Stage'),522 ('update', 3, 5, 'Saving data locally - Stage'),
522 ('update', 4, 5, 'Updating the working tree - Stage'),523 ('update', 4, 5, 'Running pre_commit hooks - Stage'),
523 ('update', 5, 5, 'Running post_commit hooks - Stage'),524 ('update', 5, 5, 'Updating the working tree - Stage'),
524 ('update', 5, 5, 'Running post_commit hooks [hook name] - Stage'),525 ('update', 6, 5, 'Running post_commit hooks - Stage'),
526 ('update', 6, 5, 'Running post_commit hooks [hook name] - Stage'),
525 ],527 ],
526 factory._calls528 factory._calls
527 )529 )
@@ -538,13 +540,14 @@
538 'hook name')540 'hook name')
539 tree.commit('first post')541 tree.commit('first post')
540 self.assertEqual(542 self.assertEqual(
541 [('update', 1, 5, 'Collecting changes [0] - Stage'),543 [('update', 1, 5, 'Running start_commit hooks - Stage'),
542 ('update', 1, 5, 'Collecting changes [1] - Stage'),544 ('update', 2, 5, 'Collecting changes [0] - Stage'),
543 ('update', 2, 5, 'Saving data locally - Stage'),545 ('update', 2, 5, 'Collecting changes [1] - Stage'),
544 ('update', 3, 5, 'Running pre_commit hooks - Stage'),546 ('update', 3, 5, 'Saving data locally - Stage'),
545 ('update', 3, 5, 'Running pre_commit hooks [hook name] - Stage'),547 ('update', 4, 5, 'Running pre_commit hooks - Stage'),
546 ('update', 4, 5, 'Updating the working tree - Stage'),548 ('update', 4, 5, 'Running pre_commit hooks [hook name] - Stage'),
547 ('update', 5, 5, 'Running post_commit hooks - Stage'),549 ('update', 5, 5, 'Updating the working tree - Stage'),
550 ('update', 6, 5, 'Running post_commit hooks - Stage'),
548 ],551 ],
549 factory._calls552 factory._calls
550 )553 )
551554
=== modified file 'bzrlib/tests/test_commit.py'
--- bzrlib/tests/test_commit.py 2012-02-23 23:26:35 +0000
+++ bzrlib/tests/test_commit.py 2012-10-12 16:19:21 +0000
@@ -484,6 +484,42 @@
484 finally:484 finally:
485 del bzrlib.ahook485 del bzrlib.ahook
486486
487 def test_commit_start_commit_hook(self):
488 import bzrlib.commit as cmt
489 wt = self.make_branch_and_tree('.')
490 branch = wt.branch
491 def test_start_commit_hook(commit):
492 commit.revprops['test_prop'] = '42'
493 def broken_start_commit_hook_revprops(commit):
494 commit.revprops[17] = 22
495 def broken_start_commit_hook_committer(commit):
496 commit.committer = 42
497 def broken_start_commit_hook_timestamp(commit):
498 commit.timestamp = 'vasya'
499 def broken_start_commit_hook_timezone(commit):
500 commit.timezone = 'vasya'
501 broken_hooks = [broken_start_commit_hook_revprops,
502 broken_start_commit_hook_timezone,
503 broken_start_commit_hook_timestamp,
504 broken_start_commit_hook_committer]
505 try:
506 cmt.Commit.hooks.install_named_hook('start_commit', test_start_commit_hook, 'test_prop_setter')
507 cmt.Commit().commit(
508 message="base", allow_pointless=True, rev_id='A',
509 working_tree=wt)
510 finally:
511 cmt.Commit.hooks.uninstall_named_hook('start_commit', 'test_prop_setter')
512 for hook in broken_hooks:
513 try:
514 cmt.Commit.hooks.install_named_hook('start_commit', hook, 'test_broken_prop_setter')
515 self.assertRaises(ValueError, cmt.Commit().commit,
516 message='base', allow_pointless=True, rev_id='A',
517 working_tree=wt)
518 finally:
519 cmt.Commit.hooks.uninstall_named_hook('start_commit', 'test_broken_prop_setter')
520
521 self.assertEquals(branch.repository.get_revision('A').properties['test_prop'], '42')
522
487 def test_commit_object_doesnt_set_nick(self):523 def test_commit_object_doesnt_set_nick(self):
488 # using the Commit object directly does not set the branch nick.524 # using the Commit object directly does not set the branch nick.
489 wt = self.make_branch_and_tree('.')525 wt = self.make_branch_and_tree('.')