Source code for enamlnative.core.bridge

"""
Copyright (c) 2017-2022, CodeLV.

Distributed under the terms of the MIT License.

The full license is in the file LICENSE, distributed with this software.

Created on June 21, 2017
"""
import functools
import msgpack
from asyncio import Future
from contextlib import contextmanager
from typing import Any, ClassVar, Optional, Union, Type
from weakref import WeakValueDictionary
from types import GenericAlias
from atom.api import Atom, Dict, ForwardInstance, Instance, Int, Property, Str

CACHE: WeakValueDictionary[int, Union[Future, "BridgeObject"]] = WeakValueDictionary()
__global_id__: int = 0
__method_id__: int = 0


#: Mapping of nativeclass str to subclasses
REGISTRY: dict[str, "BridgeObject"] = {}


class Command:
    CREATE = "c"
    PROXY = "p"
    METHOD = "m"
    STATIC_METHOD = "sm"
    FIELD = "f"
    DELETE = "d"
    RESULT = "r"
    ERROR = "e"
    DEF = "def"


class ExtType:
    REF = 1
    PROXY = 2


[docs]def generate_id() -> int: """Generate an id for an object""" global __global_id__ __global_id__ += 1 return __global_id__
def method_id(): global __method_id__ __method_id__ += 1 return __method_id__
[docs]def convert_arg(arg: Any) -> Optional[str]: """Convert an argument to a string""" if isinstance(arg, str): return arg if hasattr(arg, "__nativeclass__"): return arg.__nativeclass__ if arg is None: return None if isinstance(arg, GenericAlias): return str(arg) # eg list[int] if isinstance(arg, dict): args = tuple(arg.values()) assert len(args) == 1 return convert_arg(args[0]) assert hasattr(arg, "__name__"), "Signature argument must be a type or str" return arg.__name__
[docs]def tag_object_with_id(obj): """Generate and assign a id for the object""" obj_id = obj.__id__ = generate_id() CACHE[obj_id] = obj return obj
[docs]def get_object_with_id(id): """Get the object with the given id in the cache""" return CACHE[id]
def _cleanup_id(obj): """Removes the object from the""" try: del CACHE[obj.__id__] except KeyError: pass
[docs]def get_app_class(): """Avoid circular import. Probably indicates a poor design... """ from .app import BridgedApplication return BridgedApplication
[docs]def encode(obj): """Encode an object for proper decoding by Java or ObjC""" if hasattr(obj, "__id__"): return msgpack.ExtType(ExtType.REF, msgpack.packb(obj.__id__)) return obj
[docs]def msgpack_encoder(sig, obj): """When passing a BridgeObject encode it in a special way so it can properly be interpreted as a reference. TODO: This should use the object hooks for doing this automatically """ # if isinstance(obj, (list, tuple)): # return sig, [encode(o) for o in obj] return sig, encode(obj)
[docs]def dumps(data): """Encodes events for sending over the bridge""" return msgpack.dumps(data)
[docs]def loads(data): """Decodes and processes events received from the bridge""" # if not data: # raise ValueError("Tried to load empty data!") return msgpack.loads(data, use_list=False, raw=False)
[docs]class BridgeReferenceError(ReferenceError): """This exception occurs when an event comes in from the bridge and python does not have any reference in the cache. """ pass
[docs]class BridgeException(Exception): """This exception occurs when a remote method call fails.""" pass
[docs]def get_handler(ptr: int, method: str) -> tuple: """Dereference the pointer and return the handler method.""" obj = CACHE.get(ptr, None) if obj is None: msg = f"Reference id={ptr} never existed or has already been destroyed" raise BridgeReferenceError(msg) handler = getattr(obj, method, None) if handler is None: raise NotImplementedError(f"{obj}.{method} is not implemented.") return obj, handler
[docs]class BridgeObject(Atom): """A proxy to a class in java. This sends the commands over the bridge for execution. The object is stored in a map with the given id and is valid until this object is deleted. Parameters ---------- __id__: Int, Future, or None If an __id__ keyword argument is passed during creation, then If the __id__ is an int, this will assume the object was already created and only a reference to the object with the given id is needed. If the __id__ is a Future (as specified by the app event loop), then the __id__ of he future will be used. When the future completes this object will then be put into the cache. This allows passing results directly instead of using the `.then()` method. """ __slots__ = ("__weakref__", "__constructor_ids__") #: Native Class name __nativeclass__: ClassVar[str] = "" #: Constructor signature __signature__: ClassVar[list[Union[dict, str, "BridgeObject", type]]] = [] #: Constructor cache id __constructor_ids__: ClassVar[list[int]] #: Suppressed methods / fields __suppressed__ = Dict() #: Callbacks __callbacks__ = Dict() #: Bridge object ID __id__ = Int(0, factory=generate_id) #: Prefix to add to all names used during method and property calls #: used for nested objects __prefix__ = Str() #: Bridge __app__ = ForwardInstance(get_app_class)
[docs] @classmethod def __init_subclass__(cls, *args, **kwargs): #: Convert signature to string cls.__signature__ = [convert_arg(arg) for arg in cls.__signature__] # Create a method id for each signature length as some may be # optional. TODO: Rethink this... n = max(1, len(cls.__signature__)) cls.__constructor_ids__ = [method_id() for i in range(n)] REGISTRY[cls.__nativeclass__] = cls
def _default___app__(self): return get_app_class().instance() def getId(self): return self.__id__
[docs] def __init__(self, *args, **kwargs): """Sends the event to create this object in Java.""" #: Send the event over the bridge to construct the view __id__ = kwargs.pop("__id__", None) cache = True if __id__ is not None: if isinstance(__id__, int): kwargs["__id__"] = __id__ elif isinstance(__id__, Future): #: If a future is given don't store this object in the cache #: until after the future completes f = __id__ def set_result(f): CACHE[f.__id__] = self f.add_done_callback(set_result) #: The future is used to return the result kwargs["__id__"] = f.__id__ #: Save it into the cache when the result from the future #: arrives over the bridge. cache = False else: raise TypeError( "Invalid __id__ reference, expected an int or" f"a future/deferred object, got: {__id__}" ) #: Construct the object super().__init__(**kwargs) if cache: CACHE[self.__id__] = self if __id__ is None: if args: constructor_id = self.__constructor_ids__[len(args) - 1] else: constructor_id = self.__constructor_ids__[0] self.__app__.send_event( Command.CREATE, #: method self.__id__, #: id to assign in bridge cache constructor_id, self.__nativeclass__, [ msgpack_encoder(sig, arg) for sig, arg in zip(self.__signature__, args) ], )
[docs] def __del__(self): """Destroy this object and send a command to destroy the actual object reference the bridge implementation holds (allowing it to be released). """ ref = self.__id__ self.__app__.send_event(Command.DELETE, ref) try: del CACHE[ref] except KeyError: pass
[docs]class NestedBridgeObject(BridgeObject): """A nested object allows you to invoke methods and set properties of an object that is a property of another object using the dot notation. Useful for setting nested properties without needing to first create a reference bridge object (thus saving the time waiting for the bridge to reply) for example: UIView view = [UIView new]; view.yoga.width = YES; Would require to create a reference to the "yoga" object first but instead we just add our nested object's prefix and let the bridge resolve the actual property. It works like a regular BridgeObject but appends the "name'. This object is NOT in the cache on either side of the bridge. """ #: Reference to the object this is referenced under __root__ = Instance(BridgeObject)
[docs] def __init__(self, root, attr, **kwargs): kwargs["__id__"] = root.getId() kwargs["__prefix__"] = f"{attr}." Atom.__init__(self, **kwargs)
[docs] def __del__(self): # Not necessary, it's not in the cache pass
[docs]class BridgeMethod(Property): """A method that is callable via the bridge. When called, this serializes the call, packs the arguments, and delegates handling to a bridge in native code. #: Define it class View(BridgeObject): addView = BridgeMethod('android.view.View') #: Create instance view = View() view2 = View() #: Use it view.addView(view2) """ __slots__ = ("__signature__", "__returns__", "__cache__", "__method_id__") __returns__: Optional[tuple[str, type]] __signature__: tuple[str, ...] __cache__: dict[int, Future] __method_id__: int
[docs] def __init__(self, *args, **kwargs): return_type = kwargs.get("returns", None) if return_type is None: self.__returns__ = None else: self.__returns__ = (convert_arg(return_type), return_type) self.__signature__ = tuple(convert_arg(arg) for arg in args) self.__cache__ = {} # Result cache otherwise gc cleans up self.__method_id__ = method_id() super().__init__(self.__fget__)
[docs] @contextmanager def suppressed(self, obj: BridgeObject): """Suppress calls within this context to avoid feedback loops""" obj.__suppressed__[self.name] = True yield obj.__suppressed__[self.name] = False
def __fget__(self, obj): f = functools.partial(self.__call__, obj) f.suppressed = functools.partial(self.suppressed, obj) return f
[docs] def __call__(self, obj, *args, **kwargs): """The Swift like syntax is used""" if obj.__suppressed__.get(self.name): return #: Format the args as needed method_name, method_args = self.pack_args(obj, *args, **kwargs) #: Create a future to retrieve the result if needed app = obj.__app__ if self.__returns__: result = app.create_future(self.__returns__[1]) #: Store in local cache or global cache (weakref) removes it #: resulting in a Reference error when the result is returned result_id = result.__id__ self.__cache__[result_id] = result def cleanup(r): #: Remove from local cache to free future del self.__cache__[result_id] #: Delete from the local cache once resolved. result.add_done_callback(cleanup) else: result = None result_id = 0 app.send_event( Command.METHOD, #: method obj.__id__, result_id, self.__method_id__, f"{obj.__prefix__}{method_name}", #: method name method_args, #: args **kwargs, #: kwargs to send_event ) return result
[docs] def pack_args(self, obj, *args, **kwargs): """Subclasses should implement this to pack args as needed for the native bridge implementation. Must return a tuple containing ("methodName", [list, of, encoded, args]) """ raise NotImplementedError
[docs]class BridgeStaticMethod(Property): """A method that is callable via the bridge. When called, this serializes the call, packs the arguments, and delegates handling to a bridge in native code. #: Define it class Toast(BridgeObject): makeToast = BridgeStaticMethod(*args) #: Use result = Toast.makeToast(*args) """ __slots__ = ( "__signature__", "__returns__", "__cache__", "__owner__", "__method_id__", ) #: Return type __returns__: Optional[tuple[str, type]] #: Function signature __signature__: tuple[str, ...] #: Cache for results __cache__: dict[int, Future] __owner__: Optional[Type[BridgeObject]] __method_id__: int
[docs] def __init__(self, *args, **kwargs): return_type = kwargs.get("returns", None) if return_type is None: self.__returns__ = None else: self.__returns__ = (convert_arg(return_type), return_type) self.__signature__ = tuple(convert_arg(arg) for arg in args) self.__owner__ = None self.__cache__ = {} # Result cache otherwise gc cleans up self.__method_id__ = method_id() super().__init__()
[docs] def __get__(self, instance, owner): #: Save the object this class referencesf if self.__owner__ is None: self.__owner__ = owner return super().__get__(instance, owner)
[docs] def __call__(self, *args, **kwargs): #: Format the args as needed method_name, method_args = self.pack_args(*args, **kwargs) app = get_app_class().instance() #: Create a future to retrieve the result if needed if self.__returns__: result = app.create_future(self.__returns__[1]) result_id = result.__id__ #: Store in local cache or global cache (weakref) removes it #: resulting in a Reference error when the result is returned self.__cache__[result_id] = result def cleanup(r): #: Remove from local cache to free future del self.__cache__[result_id] #: Delete from the local cache once resolved. result.add_done_callback(cleanup) else: result = None result_id = 0 app.send_event( Command.STATIC_METHOD, #: method self.__owner__.__nativeclass__, result_id, self.__method_id__, # Function cache method_name, #: method name method_args, #: args **kwargs, #: kwargs to send_event ) return result
[docs] def pack_args(self, obj, *args, **kwargs): """Subclasses should implement this to pack args as needed for the native bridge implementation. Must return a tuple containing ("methodName", [list, of, encoded, args]) """ raise NotImplementedError
[docs]class BridgeField(Property): """Allows you to set fields or properties over the bridge using normal python syntax. #: Define it class View(BridgeObject): width = BridgeField('int') #: Create instance view = View() #: Set field view.width = 200 """ __slots__ = ("__signature__", "__method_id__") #: Field type __signature__: str __method_id__: int
[docs] def __init__(self, arg): self.__signature__ = convert_arg(arg) self.__method_id__ = method_id() super().__init__(self.__fget__, self.__fset__)
[docs] @contextmanager def suppressed(self, obj): """Suppress calls within this context to avoid feedback loops""" obj.__suppressed__[self.name] = True yield obj.__suppressed__[self.name] = False
def __fset__(self, obj, arg): if obj.__suppressed__.get(self.name): return obj.__app__.send_event( Command.FIELD, #: method obj.__id__, self.__method_id__, f"{obj.__prefix__}{self.name}", #: method name [msgpack_encoder(self.__signature__, arg)], #: args )
[docs] def __fget__(self, obj): """Return an object that can be used to retrieve the value.""" raise NotImplementedError("Reading attributes is not yet supported")
[docs]class BridgeCallback(BridgeMethod): """Description of a callback method of a View (or subclass) in Objc or Java. When called,it fires the connected callback. If no callback is connected it will try to lookup a default callback implementation matching the name `_impl_<name>`. If that does not exist, it will simply do nothing. This is triggered when it receives an event from the bridge indicating the call has occurred. #: Define it class View(BridgeObject): onClick = BridgeCallback() #: Create instance view = View() def on_click(): print("Clicked!") #: Connect to callback view.onClick.connect(on_click) You can define a "default" callback implementation by implementing the method with name of `_impl_<name>`. Connecting a callback will override this behavior. #: Define it class LocationManager(BridgeObject): hashCode = BridgeCallback() def _impl_hashCode(self): return self.__id__ """ def __fget__(self, obj): f = super().__fget__(obj) #: Add a method so it can be connected like in Qt f.connect = functools.partial(self.connect, obj) f.disconnect = functools.partial(self.disconnect, obj) return f
[docs] def __call__(self, obj, *args): """Fire the callback if one is connected""" if obj.__suppressed__.get(self.name): return callback = obj.__callbacks__.get(self.name) if not callback: #: Try to get the default callback callback = getattr(obj, f"_impl_{self.name}", None) if callback: return callback(*args)
[docs] def connect(self, obj, callback): """Set the callback to be fired when the event occurs.""" obj.__callbacks__[self.name] = callback
[docs] def disconnect(self, obj, callback=None): """Remove the callback to be fired when the event occurs.""" del obj.__callbacks__[self.name]
[docs]class BridgeFuture(Future): """A future which automatically resolves to the return type""" __id__: int __returns__: Optional[type]
[docs] def __init__(self, return_type: Optional[type] = None): result_id = self.__id__ = generate_id() CACHE[result_id] = self self.__returns__ = return_type super().__init__()
[docs] def set_result(self, result): return_type = self.__returns__ if ( isinstance(result, int) and isinstance(return_type, type) and issubclass(return_type, BridgeObject) ): result = return_type(__id__=result) super().set_result(result)