aboutsummaryrefslogtreecommitdiff
blob: 6239b207a60dac4e2c2c80eff4cb3abb5312f6c5 (plain)
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
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
"""
Object trickery including delayed instantiation and proxying

Note that the delayed instantiation/proxying that is in use here goes several steps
beyond your average proxy implementation- this functionality will take every
step possible to make the proxy appear as if there was _no_ proxy at all.

Specifically this functionality is aware of cpython VM/interpreter semantics-
cpython doesn't use __getattribute__ to pull certain methods (`__str__` is an
example most people are aware of of, `__call__`, `__getitem__`, etc are ones most
aren't).  Delayed instantiation is significantly complicated when these methods
aren't properly handled- you wind up having to pay very close attention to exactly
where those instances are passed to, what access them, do they trigger any of the slotted
special methods, etc.  There is a catch to this however- you can't just define
all slotted methods since if the proxy has those slotted methods, it will use them
and you can't just throw TypeErrors- the execution path has differeed.

Part of the purpose of snakeoil is to make things that should just work, *work*- this
implementation exists to do just that via removing those concerns.  The proxying
knows what the resultant class will be and uses a custom proxy that will appear
the same to the python machinery for slotted methods- literally the python VM
won't try to __iadd__ the proxy unless the resultant class would've supported that
functionality, it will consider it callable if the resultant class would be, etc.

By and large, this proxying is transparent if dealing in python objects (newstyle or old)-
for example,

>>> from snakeoil.obj import DelayedInstantiation_kls
>>> class foo:
...   def __init__(self, value):
...     print("instance was created")
...     self.attribute = value
...   pass
>>> delayed = DelayedInstantiation_kls(foo, "bar")
>>> print(isinstance(DelayedInstantiation_kls(foo), foo))
True
>>> print(delayed.attribute)
instance was created
bar

This proxying however cannot cover up certain cpython internal issues- specifically
builtins.

>>> from snakeoil.obj import DelayedInstantiation
>>> delayed_tuple = DelayedInstantiation(tuple, lambda x: tuple(x), range(5))
>>> print(delayed_tuple + (5, 6, 7))
(0, 1, 2, 3, 4, 5, 6, 7)
>>> print((5, 6, 7) + delayed_tuple)
Traceback (most recent call last):
TypeError: can only concatenate tuple (not "CustomDelayedObject") to tuple
>>> # the reason this differs comes down to the cpython vm translating the previous
>>> # call into essentially the following-
>>> print((5, 6, 7).__add__(delayed_tuple))
Traceback (most recent call last):
TypeError: can only concatenate tuple (not "CustomDelayedObject") to tuple

Simply put, while we can emulate/proxy at the python VM layer appearing like the target,
at the c level (which is where tuples are implemented) they expect a certain object
structure, and reach in access it directly in certain cases.  This cannot be safely
proxied (nor realistically can it be proxied in general without extremely horrible
thunking tricks), so it is not attempted.

Essentially, if DelayedInstantiation's/proxies are transparent when dealing in native
python objects- you will not see issues and you don't have to care where those objects
are used, passed to, etc.  If you're trying to proxy a builtin, it's possible, but you
do need to keep an eye on where that instance is passed to since it's not fully transparent.

As demonstrated above, if you're trying to proxy a builtin object, the consuming code
will have to order its operations appropriately- prefering the proxy's methods
over builtin methods (essentially have the proxy on the left for general ops).

If that doesn't make sense to the reader, it's probably best that the reader not
try to proxy builtin objects like tuples, lists, dicts, sets, etc.
"""

__all__ = ("DelayedInstantiation", "DelayedInstantiation_kls", "make_kls", "popattr")

from . import klass

# For our proxy, we have two sets of descriptors-
# common, "always there" descriptors that come from
# object itself (this is the base_kls_descriptors sequence)
# and kls_descriptors. We have a minor optimization in place
# to try and use BaseDelayedObject wherever possible to avoid
# pointless class creation- thus having two separate lists.

base_kls_descriptors = [
    "__delattr__",
    "__hash__",
    "__reduce__",
    "__reduce_ex__",
    "__repr__",
    "__setattr__",
    "__str__",
    "__format__",
    "__subclasshook__",  # >=py2.6
    "__le__",
    "__lt__",
    "__ge__",
    "__gt__",
    "__eq__",
    "__ne__",  # py3
    "__dir__",  # >=py3.3
]
if hasattr(object, "__sizeof__"):
    base_kls_descriptors.append("__sizeof__")
base_kls_descriptors = frozenset(base_kls_descriptors)


def popattr(obj, name, default=klass.sentinel):
    """Remove and return an attribute from an object if it exists."""
    try:
        return obj.__dict__.pop(name)
    except KeyError:
        if default is not klass.sentinel:
            return default
        # force AttributeError to be raised
        getattr(obj, name)


class BaseDelayedObject:
    """
    Base proxying object

    This instance specifically has slotted methods matching object's slottings-
    it's basically a base object proxy, defined specifically to avoid having
    to generate a custom class for object derivatives that don't modify slotted
    methods.
    """

    def __new__(cls, desired_kls, func, *a, **kwd):
        """
        :param desired_kls: the class we'll be proxying to
        :param func: a callable to get the actual object

        All other args and keywords are passed to func during instantiation
        """
        o = object.__new__(cls)
        object.__setattr__(o, "__delayed__", (desired_kls, func, a, kwd))
        object.__setattr__(o, "__obj__", None)
        return o

    def __getattribute__(self, attr):
        obj = object.__getattribute__(self, "__obj__")
        if obj is None:
            if attr == "__class__":
                return object.__getattribute__(self, "__delayed__")[0]
            elif attr == "__doc__":
                kls = object.__getattribute__(self, "__delayed__")[0]
                return getattr(kls, "__doc__", None)

            obj = object.__getattribute__(self, "__instantiate_proxy_instance__")()

        if attr == "__obj__":
            # special casing for klass.alias_method
            return obj
        return getattr(obj, attr)

    def __instantiate_proxy_instance__(self):
        delayed = object.__getattribute__(self, "__delayed__")
        obj = delayed[1](*delayed[2], **delayed[3])
        object.__setattr__(self, "__obj__", obj)
        object.__delattr__(self, "__delayed__")
        return obj

    # special case the normal descriptors
    for x in base_kls_descriptors:
        locals()[x] = klass.alias_method(
            "__obj__.%s" % (x,), doc=getattr(getattr(object, x), "__doc__", None)
        )
    # pylint: disable=undefined-loop-variable
    del x


# note that we ignore __getattribute__; we already handle it.
kls_descriptors = frozenset(
    [
        # rich comparison protocol...
        "__le__",
        "__lt__",
        "__eq__",
        "__ne__",
        "__gt__",
        "__ge__",
        # unicode conversion
        "__unicode__",
        # truth...
        "__bool__",
        # container protocol...
        "__len__",
        "__getitem__",
        "__setitem__",
        "__delitem__",
        "__iter__",
        "__contains__",
        "__index__",
        "__reversed__",
        # deprecated sequence protocol bits...
        "__getslice__",
        "__setslice__",
        "__delslice__",
        # numeric...
        "__add__",
        "__sub__",
        "__mul__",
        "__floordiv__",
        "__mod__",
        "__divmod__",
        "__pow__",
        "__lshift__",
        "__rshift__",
        "__and__",
        "__xor__",
        "__or__",
        "__div__",
        "__truediv__",
        "__rad__",
        "__rsub__",
        "__rmul__",
        "__rdiv__",
        "__rtruediv__",
        "__rfloordiv__",
        "__rmod__",
        "__rdivmod__",
        "__rpow__",
        "__rlshift__",
        "__rrshift__",
        "__rand__",
        "__rxor__",
        "__ror__",
        "__iadd__",
        "__isub__",
        "__imul__",
        "__idiv__",
        "__itruediv__",
        "__ifloordiv__",
        "__imod__",
        "__ipow__",
        "__ilshift__",
        "__irshift__",
        "__iand__",
        "__ixor__",
        "__ior__",
        "__neg__",
        "__pos__",
        "__abs__",
        "__invert__",
        "__complex__",
        "__int__",
        "__long__",
        "__float__",
        "__oct__",
        "__hex__",
        "__coerce__",
        "__trunc__",
        "__radd__",
        "__floor__",
        "__ceil__",
        "__round__",
        # remaining...
        "__call__",
        "__sizeof__",
    ]
)


kls_descriptors = kls_descriptors.difference(base_kls_descriptors)
descriptor_overrides = {k: klass.alias_method(f"__obj__.{k}") for k in kls_descriptors}


_method_cache = {}


def make_kls(kls, proxy_base=BaseDelayedObject):
    special_descriptors = kls_descriptors.intersection(dir(kls))
    doc = getattr(kls, "__doc__", None)
    if not special_descriptors and doc is None:
        return proxy_base
    key = (tuple(sorted(special_descriptors)), doc)
    o = _method_cache.get(key, None)
    if o is None:

        class CustomDelayedObject(proxy_base):
            locals().update((k, descriptor_overrides[k]) for k in special_descriptors)
            __doc__ = doc

        o = CustomDelayedObject
        _method_cache[key] = o
    return o


def DelayedInstantiation_kls(kls, *a, **kwd):
    r"""Wrapper for DelayedInstantiation

    This just invokes DelayedInstantiation(kls, kls \*a, \*\*kwd)

    See :func:`DelayedInstantiation` for argument specifics.
    """
    return DelayedInstantiation(kls, kls, *a, **kwd)


_class_cache = {}


def DelayedInstantiation(resultant_kls, func, *a, **kwd):
    """Generate an objects that does not get initialized before it is used.

    The returned object can be passed around without triggering
    initialization. The first time it is actually used (an attribute
    is accessed) it is initialized once.

    The returned "fake" object cannot completely reliably mimic a
    builtin type. It will usually work but some corner cases may fail
    in confusing ways. Make sure to test if DelayedInstantiation has
    no unwanted side effects.

    :param resultant_kls: type object to fake an instance of.
    :param func: callable, the return value is used as initialized object.

    All other positional args and keywords are passed to func during instantiation.
    """
    o = _class_cache.get(resultant_kls, None)
    if o is None:
        o = make_kls(resultant_kls)
        _class_cache[resultant_kls] = o
    return o(resultant_kls, func, *a, **kwd)