1

I'm trying to make a class that behaves like a dictionary, except any time one of its methods is called or one of its attributes is accessed the fact is logged. I'll clarify what I mean by showing the naive implementation I made (repetitive code is replaced with ellipsis):

class logdict(dict):
    def __init__(self, *args, **kwargs):
        self._log = [
            {'name': '__init__',
             'args': tuple(map(repr, args)),
             'kwargs': dict((key, repr(kwargs[key])) for key in kwargs)
             }
            ]
        return super().__init__(*args, **kwargs)
    def __getitem__(self, key):
        self._log.append({
            'name': '__getitem__',
            'args': (repr(key),),
            'kwargs': {}
            })
        return super().__getitem__(key)
    def __setitem__(self, key, value):
        ...
    def __delitem__(self, key):
        ...
    def __getattribute__(self, name):
        if name == '_log': #avoiding infinite recursion
            return super().__getattribute__(name)
        ...
    def __contains__(self, key):
        ...
    def logrepr(self):
        log = ''
        for logitem in self._log: #this is just formatting, nothing interesting here
            log += '{fun}({rargs}{optsep}{rkwargs})\n'.format(
                fun = logitem['name'],
                rargs = ', '.join(logitem['args']),
                optsep = ', ' if len(logitem['kwargs'])>0 else '',
                rkwargs = ', '.join('{} = {}'.format(key, logitem['kwargs'][key])
                                    for key in logitem['kwargs'])
                )
        return log

here, at least for the methods I overloaded, I'm saving which method is being called and the repr of its arguments (if I just saved the arguments, I run the risk of seeing the latest "version" of a mutable object instead of the old one). This implementation kinda works:

d = logdict()
d['1'] = 3
d['1'] += .5
print('1' in d)
print('log:')
print(d.logrepr())

produces:

True
log:
__init__()
__setitem__('1', 3)
__getitem__('1')
__setitem__('1', 3.5)
__contains__('1')
__getattribute__('logrepr')

however it's rather clunky and I'm never sure if I covered all the possible methods. Is there a more efficient way to do this, ideally that generalizes to any given class (and which wraps and logs all dunder methods, not only visible ones)?

Note: this is not a duplicate of this question, as the problem in it was how to avoid infinite recursion rather than how to automate/simplify the process of writing the derived class.

sortai
  • 187
  • 8

2 Answers2

1

I will choose different approach.

I have created simple decorator class, called EventLogger. Now your LogDict will inherit from this class so the log of events will be part of LogDict. If you want to log events, you can just simply decorate the methods which you want to track using @EventLogger.log.

If you need you extend this EventLogger with other logging functions. If in some methods want to track other details e.g. time of running time, or log data to other log you can do it with ease.

from functools import wraps


class EventLogger:

    _logged_events = list()

    @property
    def logged_events(self):
        return self._logged_events

    def log(func):
        @wraps(func)
        def wrapped(self, *args, **kwargs):
            self.__to_logger(self, func_name=func.__name__, *args, **kwargs)
            return func(self, *args, **kwargs)
        return wrapped

    def __to_logger(self, *args, **kwargs):
        func_name = kwargs.pop('func_name')
        args = args[1:]  # first param is self
        # TODO: implement the logging format
        self._logged_events.append(
            dict(func=func_name,
                 args=args,
                 kwargs=kwargs)
        )


class LogDict(dict, EventLogger):

    @EventLogger.log
    def __init__(self, *args, **kwargs):
        return super().__init__(*args, **kwargs)

    @EventLogger.log
    def __setitem__(self, key, value):
        return super().__setitem__(key, value)

    @EventLogger.log
    def __getitem__(self, key):
        return super().__getitem__(key)


ld = LogDict(a=10)
ld['aa'] = 5
print(ld)
print(ld.logged_events)

Peter Trcka
  • 1,279
  • 1
  • 16
  • 21
  • while this achieves what I was doing with shorter code, I still need to manually overload every method I want to log, which is not what I was interested in doing. Thanks anyway. – sortai Oct 04 '21 at 07:29
  • 1
    Understood. You can use `for` cycle as @L3viathan proposed. My solution had the biggest benefit in the easy implementation of other loggers. If there is no need to create other loggers decorating every method manually is overwhelming. – Peter Trcka Oct 04 '21 at 09:04
1

You could just auto-generate all methods of dict (with some exceptions), then you don't have to repeat yourself so much:

from functools import wraps


class LogDict(dict):
    logs = {}

    def _make_wrapper(name):
        @wraps(getattr(dict, name))
        def wrapper(self, *args, **kwargs):
            LogDict.logs.setdefault(id(self), []).append((name, args, kwargs))
            return getattr(super(), name)(*args, **kwargs)

        return wrapper

    for attr in dir(dict):
        if callable(getattr(dict, attr)):
            if attr in ("fromkeys", "__new__"):  # "classmethod-y"
                continue
            locals()[attr] = _make_wrapper(attr)

    def logrepr(self):
        return "".join(
            "{fun}({rargs}{optsep}{rkwargs})\n".format(
                fun=fun,
                rargs=", ".join(repr(arg) for arg in args),
                optsep=", " if kwargs else "",
                rkwargs=", ".join(
                    "{} = {}".format(key, value) for key, value in kwargs.items()
                ),
            )
            for fun, args, kwargs in LogDict.logs[id(self)]
        )


d = LogDict()
d["1"] = 3
d["1"] += 0.5
print("1" in d)
print("log:")
print(d.logrepr())

This prints the same thing as your solution.

In my version I also store the log on the class object, then I can avoid the __getattribute__ trickery.

L3viathan
  • 26,748
  • 2
  • 58
  • 81
  • Do you often use this `for` cycle inside class? I have never seen it before. Can you please more expand the usage (when it it good) or add some reffence? This is really cool. – Peter Trcka Oct 03 '21 at 20:44
  • 2
    @PeterTrcka what's inside the class body is executed like most other code in python, the main difference is when it's executed and its context, respectively: when you make an instance of a given class, and inside the object being created (meaning that all local names like "__init__" are actually attributes of the object being instantiated). Using this sort of loop tends to obfuscate your code, since you can't see what names you're defining, so you should try to avoid it if there's an alternative. Take a look at "class instantiation", "new-style classes" and "metaclasses" to know more. – sortai Oct 04 '21 at 07:19
  • @L3viathan thanks a lot, this is what I was looking for (didn't think of using a loop!). I want to clarify though that I did want to save the string representation of the arguments and not the arguments themselves: if I do something like set a dict value to an empty list, and then I change the empty list to `[3]`, with your code I'd see the first assignment as `__setitem__('key', [3])` instead of `__setitem__('key', [])`. – sortai Oct 04 '21 at 07:23
  • 1
    @sortai In that case, move the `repr`ification to the place where we append to the log. – L3viathan Oct 04 '21 at 15:08
  • 1
    @PeterTrcka Pretty much what sortai said. I don't often use it; there's a few things here that I would try to avoid whereever possible, including interacting with the result of `locals()`. – L3viathan Oct 04 '21 at 15:10