1"""
2These classes perform some python magic that we use to implement the nesting of exploration technique methods.
3This process is formalized as a "hooking" of a python method - each exploration technique's methods "hooks" a method of the same name on the simulation manager class.
4"""
5
6class HookSet:
7    """
8    A HookSet is a static class that provides the capability to apply many hooks to an object.
9    """
10    @staticmethod
11    def install_hooks(target, **hooks):
12        """
13        Given the target `target`, apply the hooks given as keyword arguments to it.
14        If any targeted method has already been hooked, the hooks will not be overridden but will instead be pushed
15        into a list of pending hooks. The final behavior should be that all hooks call each other in a nested stack.
16
17        :param target:  Any object. Its methods named as keys in `hooks` will be replaced by `HookedMethod` objects.
18        :param hooks:   Any keywords will be interpreted as hooks to apply. Each method named will hooked with the
19                        coresponding function value.
20        """
21        for name, hook in hooks.items():
22            func = getattr(target, name)
23            if not isinstance(func, HookedMethod):
24                func = HookedMethod(func)
25                setattr(target, name, func)
26            func.pending.append(hook)
27
28    @staticmethod
29    def remove_hooks(target, **hooks):
30        """
31        Remove the given hooks from the given target.
32
33        :param target:  The object from which to remove hooks. If all hooks are removed from a given method, the
34                        HookedMethod object will be removed and replaced with the original function.
35        :param hooks:   Any keywords will be interpreted as hooks to remove. You must provide the exact hook that was applied
36                        so that it can it can be identified for removal among any other hooks.
37        """
38        for name, hook in hooks.items():
39            hooked = getattr(target, name)
40            if hook in hooked.pending:
41                try:
42                    hooked.pending.remove(hook)
43                except ValueError as e:
44                    raise ValueError("%s is not hooked by %s" % (target, hook)) from e
45            if not hooked.pending:
46                setattr(target, name, hooked.func)
47
48
49class HookedMethod:
50    """
51    HookedMethod is a callable object which provides a stack of nested hooks.
52
53    :param func:    The bottom-most function which provides the original functionality that is being hooked
54
55    :ivar func:     Same as the eponymous parameter
56    :ivar pending:  The stack of hooks that have yet to be called. When this object is called, it will pop the last
57                    function in this list and call it. The function should call this object again in order to request
58                    the functionality of the original method, at which point the pop-dispatch mechanism will run
59                    recursively until the stack is exhausted, at which point the original function will be called.
60                    When the call returns, the hook will be restored to the stack.
61    """
62
63    def __init__(self, func):
64        self.func = func
65        self.pending = []
66
67    def __repr__(self):
68        return "<HookedMethod(%s.%s, %d pending)>" % \
69                (self.func.__self__.__class__.__name__, self.func.__name__, len(self.pending))
70
71    def __call__(self, *args, **kwargs):
72        if self.pending:
73            current_hook = self.pending.pop()
74            try:
75                result = current_hook(self.func.__self__, *args, **kwargs)
76            finally:
77                self.pending.append(current_hook)
78            return result
79        else:
80            return self.func(*args, **kwargs)
81