Welcome, guest | Sign In | My Account | Store | Cart

The IsChangedMixin can be added to any class and queried to determine if the class instance contents, or any other instances contained in the class instance, have been altered.

Python, 79 lines
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
import types, copy

SeqType = [types.DictType, types.ListType]

class IsChangedMixin:
    def __init__(self):
        self.ResetChanges()

    def ResetChanges(self):
        """ Create a snapshot of own namespace dictionary. Halfway between a
            shallow and a deep copy - recursively make shallow copies of all 
            lists and dictionaries but object instances are copied as-is. Such
            objects will have their own IsModified attribute if they need to be
            tested for modification
        """
        self._snapshot = self._CopyItem(self.__dict__)

    def _CopyItem(self, item):
        """ Return shallow copy of item. If item is a sequence, recursively
            shallow copy each member that is also a sequence
        """ 
        newitem = copy.copy(item)
        if type(newitem) is types.DictType:
            for key in newitem:
                if type(newitem[key]) in SeqType:
                    newitem[key] = self._CopyItem(newitem[key])
        elif type(newitem) is types.ListType:
            for k in range(len(newitem)):
                if type(newitem[k]) in SeqType:
                    newitem[k] = self._CopyItem(newitem[k])
        return newitem

    def IsModified(self):
        """ Return True if current namespace dictionary is different to snapshot
            Examine contained objects having an IsModified attribute
            and return True if any of them are True.
        """
        return  self._CheckSequence(self.__dict__, self._snapshot, checklen=False)

    def _CheckSequence(self, newseq, oldseq, checklen=True):
        """ Scan sequence comparing new and old values of individual items
            return True when the first difference is found.
            Compare sequence lengths if checklen is True. It is False on first 
            call because self.__dict__ has _snapshot as an extra entry 
        """
        if checklen and len(newseq) <> len(oldseq):
            return True
        if type(newseq) is types.DictType:
            for key in newseq:
                if key == '_snapshot':
                    continue
                if key not in oldseq:
                    return True
                if self._CheckItem(newseq[key], oldseq[key]):
                    return True
        else:
            for k in range(len(newseq)):
                if self._CheckItem(newseq[k], oldseq[k]):
                    return True
        return 0

    def _CheckItem(self, newitem, olditem):
        """ Compare the values of newitem and olditem.
            If item types are sequences, make recursive call to _CheckSequence.
            If item types are instances of objects with an IsModified attribute,                     
            return True if IsModified() is True,
            otherwise return True if items are different
        """
        if type(newitem) in SeqType:
            return self._CheckSequence(newitem, olditem)
        elif type(newitem) is types.InstanceType:
            if newitem is not olditem:      # not the same instance
                return True
            if hasattr(newitem, 'IsModified') and newitem.IsModified():
                return True
        elif newitem <> olditem:
            return True
        else:
            return False

A frequent real-life requirement, eg. when a user closes a window, is to check for changes and warn that, if continued, all changes will be lost. If the user-changeable content is contained (if only temporarily) in a single class instance, the IsChangedMixin can be used for this purpose:

MyDataClass([... ,] IsChangedMixin):

It is not usually necessary to call the __init__ method of the mixin, but to take a snapshot after the initial data load eg:

...
self.ResetChanges()

In a typical application, the instance may contain many instances of other classes as in the (tired old) example of an invoice, containing header data and an unlimited number of detail lines, or instances. In this case the details class would also use an IsChangedMixin and each instance would take a snapshot of its initial state.

There is no reason why the detail-instances cannot themselves contain other instances, such as product-details. In practice those are not likely to be modifiable in the current activity, but may well be modifiable elsewhere. For this reason, it can be useful to add a default "use_mixin=False" parameter to the class constructor, to avoid the overhead of copying self.__dict__ except when necessary.

The original implementation of this recipe used copy.deepcopy() for each __dict__, but when the number of contained instances reached several hundred, the time overhead grew correspondingly to several seconds. Using a recursive shallow copy does not noticeably increase the total time to instantiate and populate the whole data structure.

When required, the check for modification is relatively simple, such as:

On Close(): __d = .... __if d.IsModified(): ____if not confirm('Not saved. Changes will be lost.'): ______return # abort the close __d.Close()

Also, when changes have been saved or committed, a fresh snapshot, i.e. self.Resetchanges(), should be taken at the end of the 'Save' method of each affected class.

If any class that uses the mixin contains state-data, that is, instance variables that may change but which do not form part of the user-modified data, these can be delegated to an instance of a purpose-declared, subordinate class (without a mixin), so that while remaining accessible to the main class, they are not visible to the IsModified() method.