1#!/usr/bin/env python
2
3class autoprop(object):
4
5    __version__ = '1.0.1'
6    property = type('property', (property,), {})
7
8    class _Cache:
9        __slots__ = ('value', 'is_stale')
10
11        def __init__(self):
12            self.value = None
13            self.is_stale = True
14
15        def __repr__(self):
16            import sys
17            return '{}(value={!r}, is_stale={!r})'.format(
18                    self.__class__.__qualname__ if sys.version_info[0] >= 3 else self.__class__.__name__,
19                    self.value,
20                    self.is_stale,
21            )
22
23    def __new__(autoprop, cls):
24        # These imports have to be inside autoprop(), otherwise the sys.modules
25        # hack below somehow makes them unavailable when the decorator is
26        # applied.
27        import inspect, re
28        from collections import defaultdict
29        from functools import wraps
30
31        if not hasattr(cls, '__class__'):
32            raise TypeError('@autoprop can only be used with new-style classes')
33
34        accessors = defaultdict(dict)
35        expected_num_args = {'get': 0, 'set': 1, 'del': 0}
36
37        # The accessors we're searching for are considered methods in python2
38        # and functions in python3.  They behave the same either way.
39        ismethod = lambda x: inspect.ismethod(x) or inspect.isfunction(x)
40
41        for method_name, method in inspect.getmembers(cls, ismethod):
42            accessor_match = re.match('(get|set|del)_(.+)', method_name)
43            if not accessor_match:
44                continue
45
46            # Suppress a warning by using getfullargspec() if it's available
47            # and getargspec() if it's not.
48            try: from inspect import getfullargspec as getargspec
49            except ImportError: from inspect import getargspec
50
51            prefix, name = accessor_match.groups()
52            arg_spec = getargspec(method)
53            num_args = len(arg_spec.args) - len(arg_spec.defaults or ())
54            num_args_minus_self = num_args - 1
55
56            if num_args_minus_self != expected_num_args[prefix]:
57                continue
58
59            accessors[name][prefix] = method
60
61        def get_cache(self, getter):
62            if not hasattr(self, '_autoprop_cache'):
63                self._autoprop_cache = {}
64            return self._autoprop_cache.setdefault(getter, autoprop._Cache())
65
66        def use_cache(getter):
67
68            @wraps(getter)
69            def wrapper(self, *args, **kwargs):
70                cache = get_cache(self, getter)
71
72                if cache.is_stale:
73                    cache.value = getter(self, *args, **kwargs)
74                    cache.is_stale = False
75
76                return cache.value
77
78            return wrapper
79
80        def clear_cache(f, getter):
81            if f is None:
82                return None
83
84            @wraps(f)
85            def wrapper(self, *args, **kwargs):
86                get_cache(self, getter).is_stale = True
87                return f(self, *args, **kwargs)
88
89            return wrapper
90
91        for name in accessors:
92            try:
93                attr = getattr(cls, name)
94                ok_to_overwrite = isinstance(attr, autoprop.property)
95            except AttributeError:
96                ok_to_overwrite = True
97
98            getter  = accessors[name].get('get')
99            setter  = accessors[name].get('set')
100            deleter = accessors[name].get('del')
101
102            # Cache the return value of the getter, if requested.  For some
103            # reason setting attributes on method objects
104            if getter and getattr(getter, '_autoprop_mark_cache', False):
105                setter  = clear_cache(setter,  getter)
106                deleter = clear_cache(deleter, getter)
107                getter  = use_cache(getter)
108
109            if ok_to_overwrite:
110                property = autoprop.property(getter, setter, deleter)
111                setattr(cls, name, property)
112
113        return cls
114
115    @staticmethod
116    def cache(f):
117        if not f.__name__.startswith('get_'):
118            import sys
119            raise ValueError("{}() cannot be cached; it's not a getter".format(
120                f.__qualname__ if sys.version_info[0] >= 3 else f.__name__
121            ))
122
123        f._autoprop_mark_cache = True
124        return f
125
126# Abuse the import system so that the module itself can be used as a decorator.
127# This is a simple module intended only to cut-down on boilerplate, so I think
128# the trade-off between magicalness and ease-of-use is justified in this case.
129import sys
130sys.modules[__name__] = autoprop
131