← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] lp:~cjwatson/launchpad/snapshot-modifying-helper into lp:launchpad

 

Colin Watson has proposed merging lp:~cjwatson/launchpad/snapshot-modifying-helper into lp:launchpad.

Commit message:
Add a notify_modified context manager to make modified notifications less tedious, and refactor Git-related code to use it.

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

For more details, see:
https://code.launchpad.net/~cjwatson/launchpad/snapshot-modifying-helper/+merge/357598

I was getting very fed up of typing this out by hand while doing UI work for Git per-branch permissions, and it didn't feel right, so here's a helper.

There were a few similar but domain-specific helpers in the codebase.  One collided with the name I (independently) wanted to use, but there was an alternative already in place, so I migrated everything over to it.

I have a much more complete branch locally that migrates nearly all the by-hand equivalents of this to notify_modified, mainly to prove to myself that it could be done.  That's a few thousand more lines of patch and isn't urgent, so I'm not including it here.  From that, the remaining cases that notify_modified doesn't handle well are:

 * cases where the user argument would need to depend on the operation in the contained block;
 * cases where the operation is unconditional but notification is conditional (maybe contextlib.ExitStack or similar could help?);
 * cases where we need to return the resulting event;
 * cases where there's a customised delta object.

Even with that caveat, I think it's still a significant readability improvement.
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~cjwatson/launchpad/snapshot-modifying-helper into lp:launchpad.
=== modified file 'lib/lp/code/interfaces/branchmergeproposal.py'
--- lib/lp/code/interfaces/branchmergeproposal.py	2018-04-25 12:01:33 +0000
+++ lib/lp/code/interfaces/branchmergeproposal.py	2018-10-21 21:17:37 +0000
@@ -25,11 +25,9 @@
     'IReviewRequestedEmailJobSource',
     'IUpdatePreviewDiffJob',
     'IUpdatePreviewDiffJobSource',
-    'notify_modified',
     ]
 
 
-from lazr.lifecycle.event import ObjectModifiedEvent
 from lazr.restful.declarations import (
     call_with,
     export_as_webservice_entry,
@@ -49,7 +47,6 @@
     Reference,
     ReferenceChoice,
     )
-from zope.event import notify
 from zope.interface import (
     Attribute,
     Interface,
@@ -954,22 +951,3 @@
         :param delta_text: The text representation of the changed fields.
         :param editor: The person who did the editing.
         """
-
-
-# XXX: JonathanLange 2010-01-06: This is only used in the scanner, perhaps it
-# should be moved there.
-
-def notify_modified(proposal, func, *args, **kwargs):
-    """Call func, then notify about the changes it made.
-
-    :param proposal: the merge proposal to notify about.
-    :param func: The callable that will modify the merge proposal.
-    :param args: Additional arguments for the method.
-    :param kwargs: Keyword arguments for the method.
-    :return: The return value of the method.
-    """
-    from lp.code.adapters.branch import BranchMergeProposalNoPreviewDiffDelta
-    snapshot = BranchMergeProposalNoPreviewDiffDelta.snapshot(proposal)
-    result = func(*args, **kwargs)
-    notify(ObjectModifiedEvent(proposal, snapshot, []))
-    return result

=== modified file 'lib/lp/code/model/gitrepository.py'
--- lib/lp/code/model/gitrepository.py	2018-10-18 16:06:38 +0000
+++ lib/lp/code/model/gitrepository.py	2018-10-21 21:17:37 +0000
@@ -70,6 +70,7 @@
     IPrivacy,
     )
 from lp.app.interfaces.services import IService
+from lp.code.adapters.branch import BranchMergeProposalNoPreviewDiffDelta
 from lp.code.enums import (
     BranchMergeProposalStatus,
     GitGranteeType,
@@ -86,7 +87,6 @@
 from lp.code.event.git import GitRefsUpdatedEvent
 from lp.code.interfaces.branchmergeproposal import (
     BRANCH_MERGE_PROPOSAL_FINAL_STATES,
-    notify_modified,
     )
 from lp.code.interfaces.codeimport import ICodeImportSet
 from lp.code.interfaces.gitactivity import IGitActivitySet
@@ -1091,9 +1091,8 @@
                 "Merge detected: %s => %s",
                 proposal.source_git_ref.identity,
                 proposal.target_git_ref.identity)
-        notify_modified(
-            proposal, proposal.markAsMerged,
-            merged_revision_id=merged_revision_id)
+        with BranchMergeProposalNoPreviewDiffDelta.monitor(proposal):
+            proposal.markAsMerged(merged_revision_id=merged_revision_id)
 
     def detectMerges(self, paths, logger=None):
         """See `IGitRepository`."""

=== modified file 'lib/lp/code/model/gitrule.py'
--- lib/lp/code/model/gitrule.py	2018-10-19 23:10:41 +0000
+++ lib/lp/code/model/gitrule.py	2018-10-21 21:17:37 +0000
@@ -14,8 +14,6 @@
 from collections import OrderedDict
 
 from lazr.enum import DBItem
-from lazr.lifecycle.event import ObjectModifiedEvent
-from lazr.lifecycle.snapshot import Snapshot
 from lazr.restful.interfaces import (
     IFieldMarshaller,
     IJSONPublishable,
@@ -35,11 +33,7 @@
     getMultiAdapter,
     getUtility,
     )
-from zope.event import notify
-from zope.interface import (
-    implementer,
-    providedBy,
-    )
+from zope.interface import implementer
 from zope.security.proxy import removeSecurityProxy
 
 from lp.code.enums import (
@@ -64,6 +58,7 @@
 from lp.services.database.enumcol import DBEnum
 from lp.services.database.stormbase import StormBase
 from lp.services.fields import InlineObject
+from lp.services.webapp.snapshot import notify_modified
 
 
 def git_rule_modified(rule, event):
@@ -191,15 +186,12 @@
                     can_push=new_grant.can_push,
                     can_force_push=new_grant.can_force_push)
             else:
-                grant_before_modification = Snapshot(
-                    grant, providing=providedBy(grant))
                 edited_fields = []
-                for field in ("can_create", "can_push", "can_force_push"):
-                    if getattr(grant, field) != getattr(new_grant, field):
-                        setattr(grant, field, getattr(new_grant, field))
-                        edited_fields.append(field)
-                notify(ObjectModifiedEvent(
-                    grant, grant_before_modification, edited_fields))
+                with notify_modified(grant, edited_fields):
+                    for field in ("can_create", "can_push", "can_force_push"):
+                        if getattr(grant, field) != getattr(new_grant, field):
+                            setattr(grant, field, getattr(new_grant, field))
+                            edited_fields.append(field)
 
     def destroySelf(self, user):
         """See `IGitRule`."""

=== modified file 'lib/lp/code/model/tests/test_branchmergeproposal.py'
--- lib/lp/code/model/tests/test_branchmergeproposal.py	2018-09-19 08:46:20 +0000
+++ lib/lp/code/model/tests/test_branchmergeproposal.py	2018-10-21 21:17:37 +0000
@@ -15,10 +15,7 @@
 import hashlib
 from unittest import TestCase
 
-from lazr.lifecycle.event import (
-    ObjectCreatedEvent,
-    ObjectModifiedEvent,
-    )
+from lazr.lifecycle.event import ObjectCreatedEvent
 from lazr.restfulclient.errors import BadRequest
 from pytz import UTC
 from sqlobject import SQLObjectNotFound
@@ -62,7 +59,6 @@
     IBranchMergeProposal,
     IBranchMergeProposalGetter,
     IBranchMergeProposalJobSource,
-    notify_modified,
     )
 from lp.code.model.branchmergeproposal import (
     BranchMergeProposal,
@@ -1681,25 +1677,6 @@
         self.assertEqual("TooManyRelatedBugs", self.oopses[0]["type"])
 
 
-class TestNotifyModified(TestCaseWithFactory):
-
-    layer = DatabaseFunctionalLayer
-
-    def test_notify_modified_generates_notification(self):
-        """notify_modified generates an event.
-
-        notify_modified runs the callable with the specified args and kwargs,
-        and generates a ObjectModifiedEvent.
-        """
-        bmp = self.factory.makeBranchMergeProposal()
-        login_person(bmp.target_branch.owner)
-        self.assertNotifies(
-            ObjectModifiedEvent, False, notify_modified, bmp, bmp.markAsMerged,
-            merge_reporter=bmp.target_branch.owner)
-        self.assertEqual(BranchMergeProposalStatus.MERGED, bmp.queue_status)
-        self.assertEqual(bmp.target_branch.owner, bmp.merge_reporter)
-
-
 class TestBranchMergeProposalNominateReviewer(TestCaseWithFactory):
     """Test that the appropriate vote references get created."""
 

=== modified file 'lib/lp/code/model/tests/test_codereviewkarma.py'
--- lib/lp/code/model/tests/test_codereviewkarma.py	2017-10-04 01:53:48 +0000
+++ lib/lp/code/model/tests/test_codereviewkarma.py	2018-10-21 21:17:37 +0000
@@ -1,4 +1,4 @@
-# Copyright 2009-2017 Canonical Ltd.  This software is licensed under the
+# Copyright 2009-2018 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 """Tests for karma allocated for code reviews."""
@@ -7,7 +7,7 @@
 
 __metaclass__ = type
 
-from lp.code.interfaces.branchmergeproposal import notify_modified
+from lp.code.adapters.branch import BranchMergeProposalNoPreviewDiffDelta
 from lp.registry.interfaces.karma import IKarmaAssignedEvent
 from lp.registry.interfaces.person import IPerson
 from lp.testing import (
@@ -117,8 +117,8 @@
         reviewer = proposal.target_branch.owner
         self.karma_events = []
         login_person(reviewer)
-        notify_modified(
-            proposal, proposal.approveBranch, reviewer, "A rev id.")
+        with BranchMergeProposalNoPreviewDiffDelta.monitor(proposal):
+            proposal.approveBranch(reviewer, "A rev id.")
         self.assertOneKarmaEvent(reviewer, 'branchmergeapproved')
 
     def test_approvingOwnCodeReview(self):
@@ -129,8 +129,8 @@
             target_branch=target_branch, registrant=reviewer)
         self.karma_events = []
         login_person(reviewer)
-        notify_modified(
-            proposal, proposal.approveBranch, reviewer, "A rev id.")
+        with BranchMergeProposalNoPreviewDiffDelta.monitor(proposal):
+            proposal.approveBranch(reviewer, "A rev id.")
         self.assertOneKarmaEvent(reviewer, 'branchmergeapprovedown')
 
     def test_rejectedCodeReview(self):
@@ -140,7 +140,8 @@
         reviewer = proposal.target_branch.owner
         self.karma_events = []
         login_person(reviewer)
-        notify_modified(proposal, proposal.rejectBranch, reviewer, "A rev id.")
+        with BranchMergeProposalNoPreviewDiffDelta.monitor(proposal):
+            proposal.rejectBranch(reviewer, "A rev id.")
         self.assertOneKarmaEvent(reviewer, 'branchmergerejected')
 
     def test_rejectedOwnCodeReview(self):
@@ -153,5 +154,6 @@
             target_branch=target_branch, registrant=reviewer)
         self.karma_events = []
         login_person(reviewer)
-        notify_modified(proposal, proposal.rejectBranch, reviewer, "A rev id.")
+        with BranchMergeProposalNoPreviewDiffDelta.monitor(proposal):
+            proposal.rejectBranch(reviewer, "A rev id.")
         self.assertOneKarmaEvent(reviewer, 'branchmergerejectedown')

=== modified file 'lib/lp/code/model/tests/test_gitjob.py'
--- lib/lp/code/model/tests/test_gitjob.py	2018-10-16 10:10:10 +0000
+++ lib/lp/code/model/tests/test_gitjob.py	2018-10-21 21:17:37 +0000
@@ -13,7 +13,6 @@
     )
 import hashlib
 
-from lazr.lifecycle.event import ObjectModifiedEvent
 from lazr.lifecycle.snapshot import Snapshot
 import pytz
 from testtools.matchers import (
@@ -24,7 +23,6 @@
     MatchesStructure,
     )
 import transaction
-from zope.event import notify
 from zope.interface import providedBy
 from zope.security.proxy import removeSecurityProxy
 
@@ -56,6 +54,7 @@
 from lp.services.job.runner import JobRunner
 from lp.services.utils import seconds_since_epoch
 from lp.services.webapp import canonical_url
+from lp.services.webapp.snapshot import notify_modified
 from lp.testing import (
     person_logged_in,
     TestCaseWithFactory,
@@ -395,10 +394,9 @@
             repository=repository, ref_pattern="refs/heads/foo")
         transaction.commit()
         snapshot = Snapshot(repository, providing=providedBy(repository))
-        rule_snapshot = Snapshot(rule, providing=providedBy(rule))
         with person_logged_in(repository.owner):
-            rule.ref_pattern = "refs/heads/bar"
-            notify(ObjectModifiedEvent(rule, rule_snapshot, ["ref_pattern"]))
+            with notify_modified(rule, ["ref_pattern"]):
+                rule.ref_pattern = "refs/heads/bar"
         self.assertDeltaDescriptionEqual(
             [], ["Changed protected ref: refs/heads/foo => refs/heads/bar"],
             snapshot, repository)
@@ -451,10 +449,9 @@
             can_create=True)
         transaction.commit()
         snapshot = Snapshot(repository, providing=providedBy(repository))
-        grant_snapshot = Snapshot(grant, providing=providedBy(grant))
         with person_logged_in(repository.owner):
-            grant.can_push = True
-            notify(ObjectModifiedEvent(grant, grant_snapshot, ["can_push"]))
+            with notify_modified(grant, ["can_push"]):
+                grant.can_push = True
         self.assertDeltaDescriptionEqual(
             [],
             ["Changed access for ~{grantee} to refs/heads/*: "

=== modified file 'lib/lp/code/model/tests/test_gitrepository.py'
--- lib/lp/code/model/tests/test_gitrepository.py	2018-10-16 10:10:10 +0000
+++ lib/lp/code/model/tests/test_gitrepository.py	2018-10-21 21:17:37 +0000
@@ -15,7 +15,6 @@
 
 from bzrlib import urlutils
 from lazr.lifecycle.event import ObjectModifiedEvent
-from lazr.lifecycle.snapshot import Snapshot
 import pytz
 from sqlobject import SQLObjectNotFound
 from storm.exceptions import LostObjectError
@@ -29,8 +28,6 @@
     )
 import transaction
 from zope.component import getUtility
-from zope.event import notify
-from zope.interface import providedBy
 from zope.security.interfaces import Unauthorized
 from zope.security.proxy import removeSecurityProxy
 
@@ -130,6 +127,7 @@
 from lp.services.utils import seconds_since_epoch
 from lp.services.webapp.authorization import check_permission
 from lp.services.webapp.interfaces import OAuthPermission
+from lp.services.webapp.snapshot import notify_modified
 from lp.testing import (
     admin_logged_in,
     ANONYMOUS,
@@ -929,9 +927,10 @@
         # modified date is set to UTC_NOW.
         repository = self.factory.makeGitRepository(
             date_created=datetime(2015, 2, 4, 17, 42, 0, tzinfo=pytz.UTC))
-        notify(ObjectModifiedEvent(
-            removeSecurityProxy(repository), repository,
-            [IGitRepository["name"]], user=repository.owner))
+        with notify_modified(
+                removeSecurityProxy(repository), ["name"],
+                user=repository.owner):
+            pass
         self.assertSqlAttributeEqualsDate(
             repository, "date_last_modified", UTC_NOW)
 
@@ -987,26 +986,22 @@
         subscriber_address = (
             removeSecurityProxy(subscriber.preferredemail).email)
         transaction.commit()
-        repository_before_modification = Snapshot(
-            repository, providing=providedBy(repository))
         with person_logged_in(repository.owner):
-            repository.subscribe(
-                repository.owner,
-                BranchSubscriptionNotificationLevel.ATTRIBUTEONLY,
-                BranchSubscriptionDiffSize.NODIFF,
-                CodeReviewNotificationLevel.NOEMAIL,
-                repository.owner)
-            repository.subscribe(
-                subscriber,
-                BranchSubscriptionNotificationLevel.ATTRIBUTEONLY,
-                BranchSubscriptionDiffSize.NODIFF,
-                CodeReviewNotificationLevel.NOEMAIL,
-                repository.owner)
-            repository.setName("bar", repository.owner)
-            repository.addRule("refs/heads/stable/*", repository.owner)
-            notify(ObjectModifiedEvent(
-                repository, repository_before_modification, ["name"],
-                user=repository.owner))
+            with notify_modified(repository, ["name"], user=repository.owner):
+                repository.subscribe(
+                    repository.owner,
+                    BranchSubscriptionNotificationLevel.ATTRIBUTEONLY,
+                    BranchSubscriptionDiffSize.NODIFF,
+                    CodeReviewNotificationLevel.NOEMAIL,
+                    repository.owner)
+                repository.subscribe(
+                    subscriber,
+                    BranchSubscriptionNotificationLevel.ATTRIBUTEONLY,
+                    BranchSubscriptionDiffSize.NODIFF,
+                    CodeReviewNotificationLevel.NOEMAIL,
+                    repository.owner)
+                repository.setName("bar", repository.owner)
+                repository.addRule("refs/heads/stable/*", repository.owner)
         with dbuser(config.IGitRepositoryModifiedMailJobSource.dbuser):
             JobRunner.fromReady(
                 getUtility(IGitRepositoryModifiedMailJobSource)).runAll()

=== modified file 'lib/lp/code/model/tests/test_gitrule.py'
--- lib/lp/code/model/tests/test_gitrule.py	2018-10-19 23:10:41 +0000
+++ lib/lp/code/model/tests/test_gitrule.py	2018-10-21 21:17:37 +0000
@@ -7,8 +7,6 @@
 
 __metaclass__ = type
 
-from lazr.lifecycle.event import ObjectModifiedEvent
-from lazr.lifecycle.snapshot import Snapshot
 from storm.store import Store
 from testtools.matchers import (
     Equals,
@@ -19,8 +17,6 @@
     MatchesStructure,
     )
 import transaction
-from zope.event import notify
-from zope.interface import providedBy
 from zope.security.proxy import removeSecurityProxy
 
 from lp.code.enums import (
@@ -34,6 +30,7 @@
     IGitRuleGrant,
     )
 from lp.services.database.sqlbase import get_transaction_timestamp
+from lp.services.webapp.snapshot import notify_modified
 from lp.testing import (
     person_logged_in,
     TestCaseWithFactory,
@@ -490,11 +487,9 @@
         repository = self.factory.makeGitRepository(owner=owner)
         rule = self.factory.makeGitRule(
             repository=repository, ref_pattern="refs/heads/*")
-        rule_before_modification = Snapshot(rule, providing=providedBy(rule))
         with person_logged_in(member):
-            rule.ref_pattern = "refs/heads/other/*"
-            notify(ObjectModifiedEvent(
-                rule, rule_before_modification, ["ref_pattern"]))
+            with notify_modified(rule, ["ref_pattern"]):
+                rule.ref_pattern = "refs/heads/other/*"
         self.assertThat(repository.getActivity().first(), MatchesStructure(
             repository=Equals(repository),
             changer=Equals(member),
@@ -686,14 +681,10 @@
         grant = self.factory.makeGitRuleGrant(
             repository=repository, grantee=GitGranteeType.REPOSITORY_OWNER,
             can_create=True)
-        grant_before_modification = Snapshot(
-            grant, providing=providedBy(grant))
         with person_logged_in(member):
-            grant.can_create = False
-            grant.can_force_push = True
-            notify(ObjectModifiedEvent(
-                grant, grant_before_modification,
-                ["can_create", "can_force_push"]))
+            with notify_modified(grant, ["can_create", "can_force_push"]):
+                grant.can_create = False
+                grant.can_force_push = True
         self.assertThat(repository.getActivity().first(), MatchesStructure(
             repository=Equals(repository),
             changer=Equals(member),

=== modified file 'lib/lp/codehosting/scanner/mergedetection.py'
--- lib/lp/codehosting/scanner/mergedetection.py	2018-04-10 21:47:06 +0000
+++ lib/lp/codehosting/scanner/mergedetection.py	2018-10-21 21:17:37 +0000
@@ -1,4 +1,4 @@
-# Copyright 2009 Canonical Ltd.  This software is licensed under the
+# Copyright 2009-2018 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 """The way the branch scanner handles merges."""
@@ -12,11 +12,11 @@
 from bzrlib.revision import NULL_REVISION
 from zope.component import getUtility
 
+from lp.code.adapters.branch import BranchMergeProposalNoPreviewDiffDelta
 from lp.code.enums import BranchLifecycleStatus
 from lp.code.interfaces.branchcollection import IAllBranches
 from lp.code.interfaces.branchmergeproposal import (
     BRANCH_MERGE_PROPOSAL_FINAL_STATES,
-    notify_modified,
     )
 from lp.services.utils import CachingIterator
 
@@ -62,7 +62,8 @@
         if is_development_focus(target):
             mark_branch_merged(logger, source)
     else:
-        notify_modified(proposal, proposal.markAsMerged, merge_revno)
+        with BranchMergeProposalNoPreviewDiffDelta.monitor(proposal):
+            proposal.markAsMerged(merge_revno)
         # If there is an explicit merge proposal, change the branch's
         # status when it's been merged into a development focus or any
         # other series branch.

=== modified file 'lib/lp/services/webapp/snapshot.py'
--- lib/lp/services/webapp/snapshot.py	2015-10-14 15:22:01 +0000
+++ lib/lp/services/webapp/snapshot.py	2018-10-21 21:17:37 +0000
@@ -1,12 +1,20 @@
-# Copyright 2009 Canonical Ltd.  This software is licensed under the
+# Copyright 2009-2018 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
-"""Snapshot adapter for the Storm result set."""
-
+"""Snapshot helpers."""
+
+from contextlib import contextmanager
+
+from lazr.lifecycle.event import ObjectModifiedEvent
 from lazr.lifecycle.interfaces import ISnapshotValueFactory
+from lazr.lifecycle.snapshot import Snapshot
 from storm.zope.interfaces import IResultSet
 from zope.component import adapter
-from zope.interface import implementer
+from zope.event import notify
+from zope.interface import (
+    implementer,
+    providedBy,
+    )
 
 from lp.services.helpers import shortlist
 
@@ -17,9 +25,58 @@
 @implementer(ISnapshotValueFactory)
 @adapter(IResultSet)  # And ISQLObjectResultSet.
 def snapshot_sql_result(value):
+    """Snapshot adapter for the Storm result set."""
     # SQLMultipleJoin and SQLRelatedJoin return
     # SelectResults, which doesn't really help the Snapshot
     # object. We therefore list()ify the values; this isn't
     # perfect but allows deltas to be generated reliably.
     return shortlist(
         value, longest_expected=100, hardlimit=HARD_LIMIT_FOR_SNAPSHOT)
+
+
+@contextmanager
+def notify_modified(obj, edited_fields, snapshot_names=None, user=None):
+    """A context manager that notifies about modifications to an object.
+
+    Use this as follows::
+
+        with notify_modified(obj, ["attr"]):
+            obj.attr = value
+
+    Or::
+
+        edited_fields = set()
+        with notify_modified(obj, edited_fields):
+            if obj.attr != new_attr:
+                obj.attr = new_attr
+                edited_fields.add("attr")
+
+    Or even::
+
+        edited_fields = set()
+        with notify_modified(obj, edited_fields) as previous_obj:
+            do_something()
+            if obj.attr != previous_obj.attr:
+                edited_fields.add("attr")
+
+    :param obj: The object being modified.
+    :param edited_fields: An iterable of fields being modified.  This is not
+        used until after the block wrapped by the context manager has
+        finished executing, so you can safely pass a mutable object and add
+        items to it from inside the block as you determine which fields are
+        being modified.  A notification will only be sent if `edited_fields`
+        is non-empty.
+    :param snapshot_names: If not None, only snapshot these names.  This may
+        be used if snapshotting some of the object's attributes is expensive
+        in some contexts (and they can't be wrapped by `doNotSnapshot` for
+        some reason).
+    :param user: If not None, the user making these changes.  If None,
+        defaults to the principal registered in the current interaction.
+    """
+    obj_before_modification = Snapshot(
+        obj, names=snapshot_names, providing=providedBy(obj))
+    yield obj_before_modification
+    edited_fields = list(edited_fields)
+    if edited_fields:
+        notify(ObjectModifiedEvent(
+            obj, obj_before_modification, edited_fields, user=user))

=== added file 'lib/lp/services/webapp/tests/test_snapshot.py'
--- lib/lp/services/webapp/tests/test_snapshot.py	1970-01-01 00:00:00 +0000
+++ lib/lp/services/webapp/tests/test_snapshot.py	2018-10-21 21:17:37 +0000
@@ -0,0 +1,107 @@
+# Copyright 2018 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+from __future__ import absolute_import, print_function, unicode_literals
+
+__metaclass__ = type
+
+from lazr.lifecycle.interfaces import IObjectModifiedEvent
+from lazr.lifecycle.snapshot import Snapshot
+from testtools.matchers import (
+    Equals,
+    MatchesAll,
+    MatchesListwise,
+    MatchesStructure,
+    )
+from zope.authentication.interfaces import IUnauthenticatedPrincipal
+from zope.interface import (
+    implementer,
+    Interface,
+    )
+from zope.schema import Int
+
+from lp.services.webapp.snapshot import notify_modified
+from lp.testing import (
+    EventRecorder,
+    TestCaseWithFactory,
+    )
+from lp.testing.layers import ZopelessDatabaseLayer
+from lp.testing.matchers import Provides
+
+
+class IThing(Interface):
+
+    attr = Int()
+
+
+@implementer(IThing)
+class Thing:
+
+    def __init__(self, attr):
+        self.attr = attr
+
+
+class TestNotifyModified(TestCaseWithFactory):
+
+    layer = ZopelessDatabaseLayer
+
+    def test_generates_notification(self):
+        obj = Thing(0)
+        with EventRecorder() as recorder:
+            with notify_modified(obj, ["attr"]):
+                obj.attr = 1
+        self.assertThat(recorder.events, MatchesListwise([
+            MatchesAll(
+                Provides(IObjectModifiedEvent),
+                MatchesStructure(
+                    object=MatchesStructure(attr=Equals(1)),
+                    object_before_modification=MatchesAll(
+                        Provides(IThing),
+                        MatchesStructure(attr=Equals(0))),
+                    edited_fields=Equals(["attr"]),
+                    user=Provides(IUnauthenticatedPrincipal))),
+            ]))
+
+    def test_mutate_edited_fields_within_block(self):
+        obj = Thing(0)
+        with EventRecorder() as recorder:
+            edited_fields = set()
+            with notify_modified(obj, edited_fields):
+                obj.attr = 1
+                edited_fields.add("attr")
+        self.assertThat(recorder.events, MatchesListwise([
+            MatchesAll(
+                Provides(IObjectModifiedEvent),
+                MatchesStructure(
+                    object=MatchesStructure(attr=Equals(1)),
+                    object_before_modification=MatchesAll(
+                        Provides(IThing),
+                        MatchesStructure(attr=Equals(0))),
+                    edited_fields=Equals(["attr"]),
+                    user=Provides(IUnauthenticatedPrincipal))),
+            ]))
+
+    def test_yields_previous_object(self):
+        obj = Thing(0)
+        with notify_modified(obj, []) as previous_obj:
+            obj.attr = 1
+            self.assertIsInstance(previous_obj, Snapshot)
+            self.assertEqual(0, previous_obj.attr)
+
+    def test_different_user(self):
+        obj = Thing(0)
+        user = self.factory.makePerson()
+        with EventRecorder() as recorder:
+            with notify_modified(obj, ["attr"], user=user):
+                obj.attr = 1
+        self.assertThat(recorder.events, MatchesListwise([
+            MatchesAll(
+                Provides(IObjectModifiedEvent),
+                MatchesStructure(
+                    object=MatchesStructure(attr=Equals(1)),
+                    object_before_modification=MatchesAll(
+                        Provides(IThing),
+                        MatchesStructure(attr=Equals(0))),
+                    edited_fields=Equals(["attr"]),
+                    user=Equals(user))),
+            ]))


Follow ups