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

A proxy object that delegates method calls to an instance, but that also calls hooks for that method on the proxy, or for all methods. This can be used to implement logging of all method calls and values on an instance.

Python, 170 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
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
#!/usr/bin/env python
#
# $Source$
# $Id$
#

"""
Proxy objects for any library, that allow you to add hooks before or after
methods on a specific object.

"""

__version__ = "$Revision$"
__author__ = "Martin Blais <blais@furius.ca>"

#===============================================================================
# EXTERNAL DECLARATIONS
#===============================================================================

import types
from pprint import pformat

#===============================================================================
# PUBLIC DECLARATIONS
#===============================================================================

__all__ = ['HookProxy']

#-------------------------------------------------------------------------------
#
class ProxyMethodWrapper:
    """
    Wrapper object for a method to be called.
    """

    def __init__( self, obj, func, name ):
        self.obj, self.func, self.name = obj, func, name
        assert obj is not None
        assert func is not None
        assert name is not None

    def __call__( self, *args, **kwds ):
        return self.obj._method_call(self.name, self.func, *args, **kwds)

#-------------------------------------------------------------------------------
#
class HookProxy(object):
    """
    Proxy object that delegates methods and attributes that don't start with _.
    You can derive from this and add appropriate hooks where needed.
    Override _pre/_post to do something before/afer all method calls.
    Override _pre_<name>/_post_<name> to hook before/after a specific call.
    """

    def __init__( self, objname, obj ):
        self._objname, self._obj = objname, obj

    def __getattribute__( self, name ):
        """
        Return a proxy wrapper object if this is a method call.
        """
        if name.startswith('_'):
            return object.__getattribute__(self, name)
        else:
            att = getattr(self._obj, name)
            if type(att) is types.MethodType:
                return ProxyMethodWrapper(self, att, name)
            else:
                return att

    def __setitem__( self, key, value ):
        """
        Delegate [] syntax.
        """
        name = '__setitem__'
        att = getattr(self._obj, name)
        pmeth = ProxyMethodWrapper(self, att, name)
        pmeth(key, value)

    def _call_str( self, name, *args, **kwds ):
        """
        Returns a printable version of the call.
        This can be used for tracing.
        """
        pargs = [pformat(x) for x in args]
        for k, v in kwds.iteritems():
            pargs.append('%s=%s' % (k, pformat(v)))
        return '%s.%s(%s)' % (self._objname, name, ', '.join(pargs))

    def _method_call( self, name, func, *args, **kwds ):
        """
        This method gets called before a method is called.
        """
        # pre-call hook for all calls.
        try:
            prefunc = getattr(self, '_pre')
        except AttributeError:
            pass
        else:
            prefunc(name, *args, **kwds)

        # pre-call hook for specific method.
        try:
            prefunc = getattr(self, '_pre_%s' % name)
        except AttributeError:
            pass
        else:
            prefunc(*args, **kwds)

        # get real method to call and call it
        rval = func(*args, **kwds)

        # post-call hook for specific method.
        try:
            postfunc = getattr(self, '_post_%s' % name)
        except AttributeError:
            pass
        else:
            postfunc(*args, **kwds)

        # post-call hook for all calls.
        try:
            postfunc = getattr(self, '_post')
        except AttributeError:
            pass
        else:
            postfunc(name, *args, **kwds)

        return rval


#===============================================================================
# TEST
#===============================================================================

def test():
    import sys

    class Foo:
        def foo( self, bli ):
            print '       (running foo -> %s)' % bli
            return 42

    class BabblingFoo(HookProxy):
        "Proxy for Foo."
        def _pre( self, name, *args, **kwds ):
            print >> sys.stderr, \
                  "LOG :: %s" % self._call_str(name, *args, **kwds)

        def _post( self, name, *args, **kwds ):
            print 'after all'

        def _pre_foo( self, *args, **kwds ):
            print 'before foo...'

        def _post_foo( self, *args, **kwds ):
            print 'after foo...'

    f = BabblingFoo('f', Foo())
    print 'rval = %s' % f.foo(17)

    # try calling non-existing method
    try:
        f.nonexisting()
        raise RuntimeError
    except AttributeError:
        pass

if __name__ == '__main__':
    test()

I needed the ability to "trace" all function calls made to a particular object instance, in order to be able to "replay" them later in the same order and with the same values. (This was useful for testing, I have tests that are generated with randomness and I wanted to be able to reproduce the random test run exactly when it found a bug.)

So I wrote this little recipe, which allows you to create a proxy object to any existing instance, with hooks for particular method calls, or for all method calls.

Derive from HookProxy and add the specific hooks you need to do logging the way you want it, and then use an instance of that in-place for your object. There is a simple example in the code. You could do other stuff in the hooks. (This idea is vaguely similar to Emacs's "advice" package, i.e. adding code to be run before/after an existing function.)

(Note: there is a convenience method in HookProxy that formats the arguments and keywords in a way that they could be re-executed later, you can use that to do logging of the calls made on the proxied-instance.)

(Any improvements/comments welcome, I "cooked" this up late at night without looking into all the possibilities but it has been tested and runs in my tests.)

5 comments

Martin Blais (author) 16 years, 10 months ago  # | flag

a subtle bug, and a fix. In all the method calls that take args and *kwds as arguments, you should replace "name" by "_name" and "func" by "_func" in the formal parameters. Otherwise if the delegated method already has a "name" parameter, then the function get "name" twice: once with the normal actual parameter, and once in the "kwds" dict, and this results in::

TypeError: _method_call() got multiple values for keyword argument 'name'

Using _name fixes the common case (hopefully you don't use "_name" for your formal parameters).

Nicolas Chauvat 16 years, 10 months ago  # | flag

see also aspects. You might be interested in the following module which is more generic as it does aspect-oriented programming in Python.

http://www.logilab.org/projects/aspects

Martin Blais (author) 16 years, 9 months ago  # | flag

one more improvement. I improved my copy with a small but useful improvement: when calling the _post and _post_ functions, I pass the return value from the delegated call.

here is the diff

diff -u -3 -p -r1.2 hookproxy.py
--- hookproxy.py        11 Feb 2005 04:20:26 -0000      1.2
+++ hookproxy.py        12 Mar 2005 21:39:38 -0000
@@ -112,7 +112,7 @@ class HookProxy(object):

         # post-call hook for specific method.
         try:
-            postfunc = getattr(self, '_post_%s' % _name)
+            postfunc = getattr(self, '_post_%s' % _name, rval)
         except AttributeError:
             pass
         else:
@@ -120,7 +120,7 @@ class HookProxy(object):

         # post-call hook for all calls.
         try:
-            postfunc = getattr(self, '_post')
+            postfunc = getattr(self, '_post', rval)
         except AttributeError:
             pass
         else:
@@ -145,16 +145,16 @@ def test():
         "Proxy for Foo."
         def _pre( self, _name, *args, **kwds ):
             print >> sys.stderr, \
-                  "LOG :: %s" % self._call_str(name, *args, **kwds)
+                  "LOG :: %s" % self._call_str(_name, *args, **kwds)

-        def _post( self, _name, *args, **kwds ):
-            print 'after all'
+        def _post( self, _name, retval, *args, **kwds ):
+            print 'after all', retval

         def _pre_foo( self, *args, **kwds ):
             print 'before foo...'

-        def _post_foo( self, *args, **kwds ):
-            print 'after foo...'
+        def _post_foo( self, retval, *args, **kwds ):
+            print 'after foo...', retval

     f = BabblingFoo('f', Foo())
     print 'rval = %s' % f.foo(17)
Martin Blais (author) 16 years, 9 months ago  # | flag

different approach. indeed, very interesting stuff, thx for the pointer

the difference between the two could be summarized like this:

  • logilab-aspects modifies the original object methods, setting them to wrapped methods, whereas hookproxy only keeps a reference to the original object

  • hookproxy is a simple one-file solution, logilab-aspects is more involved, and more powerful. (i suppose if you like simplicity hookproxy still has some value.)

(i don't quite see how logilab-aspects is more generic, plz define "generic")

Martin Blais (author) 16 years, 9 months ago  # | flag

new version. damnit, i'm getting ahead of myself. what we need for this cookbook thing is a svn repository...

there are some bugs in the diffs sent. also, i added a way to catch exceptions for all method calls. i will write some more tests and send the new versions after some more testing.