GrahamDumpleton / wrapt

A Python module for decorators, wrappers and monkey patching.
BSD 2-Clause "Simplified" License
2.06k stars 231 forks source link

Idea: Sticky/Viral ObjectProxy #244

Open mentalisttraceur opened 1 year ago

mentalisttraceur commented 1 year ago

I'm picturing a subclass of ObjectProxy (suggested name: StickyObjectProxy or ViralObjectProxy) which wraps the result of every method called on it with itself:

class StickyObjectProxy(ObjectProxy):
    @staticmethod
    def can_stick(from_object, to_object):
        raise NotImplementedError("sticky proxies must define their stickiness")
    ...
    def __str__(self):
        result = str(self.__wrapped__)
        if type(self).can_stick(self, result):
            return type(self)(result)
        return result
    ...
    def __add__(self, other):
        result = self.__wrapped__ + other
        if type(self).can_stick(self, result):
            return type(self)(result)
        return result

Subclasses would override can_stick with logic that is appropriate for that wrapper.

[Edit: I have updated this proposal since Graham's first reply.]

(Of course I also suggest adding a corresponding Callable{Sticky,Viral}ObjectProxy.)

This would enable propagating

  1. annotations (for example, when debugging/exploring complex code, I sometimes really want to know where a value "came from", which sometimes means "what data paths fed into this value? what was this computed from"? the can_stick for such a thing would probably be just to_object is not None to keep it from breaking is None checks) and

  2. behaviors (for example, ergonomics improvements like adding an operator overload for function composition) (in this example, the can_stick check would be callable(to_object).)

through code with minimal boilerplate.

The big reason for having this inside of wrapt is that wrapt is already in the business of knowing when a new dunder method is added to the language, and a StickyObjectProxy would have to override almost every method that ObjectProxy has to override, and if ObjectProxy has to change (for example, adding a new dunder method or a new workaround for newer versions of Python) then StickyObjectProxy probably needs the same exact change, so it would be a really big efficiency gain to consolidate that effort, visibility, and need-for-awareness in one place.

GrahamDumpleton commented 1 year ago

In the case of __str__(), or similarly __repr__(), if you want the representation to reflect something about the wrapper, the customised implementation would need to be supplied in place and return a string. In your example you are attempting to return an object instance which cannot work. The assumption that you could supply a string as argument to the wrapper is also wrong.

There is no generic recipe for providing an implementation of __str__() or __repr__() is this sort of arrangement with a proxy wrapper.

Even __add__() could well be problematic in a generic way because the argument could technically be a different type to the wrapped object it is being added to. So using the same wrapper type to wrap the result may not be the correct thing to do.

So with arithmetic operands like that, again may not be a generic recipe which one could use.

Can you provide a better example, with code, of the actual problem you are trying to solve that you think it needs to behave this way? As it stands it doesn't look like a generic object proxy would be appropriate and a more customised wrapper would be need for the specific types you intend wrapping.

mentalisttraceur commented 1 year ago

Okay, I'll provide a more concrete example when I have the time+motivation+spoons+etc.

In the meantime, a few quick comments:

if you want the representation to reflect something about the wrapper,

I don't. I just used two dunder methods as examples, one unary and one binary, to show the shape of the code I had in mind.

The assumption that you could supply a string as argument to the wrapper is also wrong.

If that assumption wasn't true for a wrapper, we wouldn't make that wrapper inherit from StickyObjectProxy (or it would override __new__ to limit what it sticks to).

Even add() could well be problematic in a generic way because the argument could technically be a different type to the wrapped object it is being added to. So using the same wrapper type to wrap the result may not be the correct thing to do.

So with arithmetic operands like that, again may not be a generic recipe which one could use.

Right, and again the implementer of a sticky proxy would be expected to know what their proxy is valid for, and override __new__ to narrow what it sticks to.

...hmm, I think the "override __new__" idea is muddling things (and has other flaws). Here's a better design:

def StickyObjectProxy(ObjectProxy):
    @staticmethod
    def can_stick(from_object, to_object):
        # Check if this wrapper should "stick" from one object to another.
        # Sticky proxy subclasses override this as-needed.
        return True
    ...
    def __str__(self):
        result = str(self.__wrapped__)
        if type(self).can_stick(self, result):
            return type(self)(result)
        return result
    ...
    def __add__(self, other):
        result = self.__wrapped__ + other
        if type(self).can_stick(self, result):
            return type(self)(result)
        return result
    ...

So for example, if I write a sticky proxy which only sticks to callables, then my can_stick method would just be:

@staticmethod
def can_stick(from_object, to_object):
    return callable(to_object)

But if I write a debugging-aiding "track everything that caused this value" annotation wrapper, then the default always-true can_stick is perfect.

and a more customised wrapper would be need for the specific types you intend wrapping.

Usually yes, a sticky proxy would be a customized subclass, to narrow things down, but I think the generic stick-to-everything behavior is very useful as a base (reduces work+boilerplate).

dg-pb commented 1 year ago

I think this is great idea! I had a need for similar object several times.

Few times it was just wrapping an attribute and one time proxying a list of objects, where proxy method executes methods of all objects and reconstructs the proxy with result list - this was mostly with numpy objects.