From f5418e5dbd5c28f2cce888fa454669f820a663b1 Mon Sep 17 00:00:00 2001 From: Suthep Pomjaksilp Date: Thu, 20 Mar 2025 13:27:04 +0100 Subject: [PATCH 01/20] Make RemoteHERO instances hashable --- src/heros/heros.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/heros/heros.py b/src/heros/heros.py index c4f208c..130fc6e 100644 --- a/src/heros/heros.py +++ b/src/heros/heros.py @@ -276,6 +276,9 @@ class RemoteHERO(HERO): self._realm == other._realm and \ self._name == other._name + def __hash__(self): + return hash((self.__class__.__name__, self._realm, self._name)) + class LocalHERO(HERO): """ -- GitLab From 6d39ab5b23b7a8c44cd8462c5df03278025d4d0e Mon Sep 17 00:00:00 2001 From: Suthep Pomjaksilp Date: Thu, 20 Mar 2025 13:28:11 +0100 Subject: [PATCH 02/20] Mark RemoteHERO methods --- src/heros/heros.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/heros/heros.py b/src/heros/heros.py index 130fc6e..b7c689c 100644 --- a/src/heros/heros.py +++ b/src/heros/heros.py @@ -3,7 +3,7 @@ import traceback from functools import partial from .capabilities import Capability, MethodCapability, AttributeCapability, EventCapability from .event import RemoteEvent -from .inspect import is_hero_event, is_local_only, local_only +from .inspect import is_hero_event, is_local_only, local_only, mark_hero_method, is_hero_method from heros import serdes from heros.zenoh import session_manager as default_session_manager from .helper import object_name_from_keyexpr, log @@ -249,7 +249,7 @@ class RemoteHERO(HERO): return self._query_endpoint(_cap.name, payload=_cap.call_dict(*args, **kwargs)) # attach stub function to self - setattr(self, cap.name, f) + setattr(self, cap.name, mark_hero_method(f)) getattr(self, cap.name).__signature__ = cap.to_signature() getattr(self, cap.name).__name__ = cap.name getattr(self, cap.name).__self__ = self -- GitLab From c0b9e4917378485a3e4ca8b921eeec3a7f0676c4 Mon Sep 17 00:00:00 2001 From: Suthep Pomjaksilp Date: Thu, 20 Mar 2025 13:31:47 +0100 Subject: [PATCH 03/20] Rework heros.event to Descriptor/Handler approach This means that remote events (RemoteEventHandler) instances now keep track of their creating instance. Additionally we introduce the abilityt to attach "local" callbacks to LocalHERO methods decorated with `@event`. --- src/heros/event.py | 183 +++++++++++++++++++++++++++++++++++++++------ src/heros/heros.py | 7 +- 2 files changed, 164 insertions(+), 26 deletions(-) diff --git a/src/heros/event.py b/src/heros/event.py index 4358b5d..0c18244 100644 --- a/src/heros/event.py +++ b/src/heros/event.py @@ -1,38 +1,175 @@ -from .inspect import mark_hero_event, is_hero_event +import functools -def event(func: callable): - """ - decorator for events. Only use on mehtods bound to objects since this assumes - that the first argument passed to the decorated function is `self`. - """ - def _inner(*args, **kwargs): - result = func(*args, **kwargs) +from .helper import log +from .inspect import mark_hero_event, is_hero_event, is_hero_method, HERO_EVENT_ATTRIBUTE - # extract object - self = args[0] - # publish result - self._session.put(f"{self._endpoint_base_path}/{func.__name__}", self._serialize(result)) +def remote_hero_method_to_str(func: callable) -> str: + return f"{func.__self__._name}.{func.__name__}" - # mark the method as event - _inner = mark_hero_event(_inner) - return _inner +class EventHandler: + """Base class for event handlers""" + def connect(self, callback: callable): + """Connect a callback function.""" + raise NotImplementedError -class RemoteEvent: - def __init__(self, name): - self.name = name + def disconnect(self, callback: callable): + """Disconnect a callback function.""" + raise NotImplementedError + + def is_callback(self, func: callable) -> bool: + """Check if `func` is a callback""" + raise NotImplementedError + + def get_callbacks(self): + """Return all callbacks""" + raise NotImplementedError + + +class LocalEventHandler(EventHandler): + """Handles event connections for a specific instance.""" + + def __init__(self, instance, func): + self.instance = instance + self.func = func + # store callbacks for this instance self.callbacks = [] + self.remote_hero_callbacks = {} + # mark as an hero event + setattr(self, HERO_EVENT_ATTRIBUTE, True) + # preserve signature and metadata of `func` + functools.update_wrapper(self, func) + + def __call__(self, *args, **kwargs): + """Call the original function and trigger callbacks.""" + # call the method + result = self.func(self.instance, *args, **kwargs) + # publish result (triggers RemoteEvent callbacks) + endpoint = f"{self.instance._endpoint_base_path}/{self.func.__name__}" + self.instance._session.put(endpoint, self.instance._serialize(result)) + # execute attached (local) callbacks + for callback in self.callbacks: + callback(result) + # execute callbacks on RemoteHERO methods + for key, callback in self.remote_hero_callbacks.items(): + callback(result) + # try: + # callback(result) + # except Exception as ex: # TODO specify exception + # log.error(f"callback {key} failed with {ex}") + # self.disconnect(callback) + return result def connect(self, callback: callable): - if callback not in self.callbacks: + """Connect a callback function to be triggered when the method is called.""" + if is_hero_method(callback): + log.debug(f"connecting callback {remote_hero_method_to_str(callback)}") + self.remote_hero_callbacks[remote_hero_method_to_str(callback)] = callback + else: + log.debug(f"connecting callback {callback}") self.callbacks.append(callback) - def disconnect(self, callback: callable): - if callback in self.callbacks: - del self.callbacks[self.callbacks.index(callback)] + def disconnect(self, callback: callable) -> None: + """Disconnect a callback function.""" + if not self.is_callback(callback): + return + if is_hero_method(callback): + log.debug(f"disconnecting callback {remote_hero_method_to_str(callback)}") + del self.remote_hero_callbacks[remote_hero_method_to_str(callback)] + else: + log.debug(f"disconnecting callback {callback}") + self.callbacks.remove(callback) + + def is_callback(self, func: callable) -> bool: + """Check if given callable is already a registered callback""" + if is_hero_method(func): + return remote_hero_method_to_str(func) in self.remote_hero_callbacks.keys() + else: + return func in self.callbacks + + def get_callbacks(self): + """Return a list of registered callback functions.""" + return self.callbacks, self.remote_hero_callbacks + - def trigger(self, payload): +class RemoteEventHandler(EventHandler): + """Handles remote events for a specific instance.""" + + def __init__(self, instance, func: callable = None): + self.instance = instance + # store callbacks for this instance + self.callbacks = [] + # mark as an hero event + setattr(self, HERO_EVENT_ATTRIBUTE, True) + + def __call__(self, payload): + """Trigger all callbacks connect to the event.""" for callback in self.callbacks: callback(payload) + + def connect(self, callback: callable): + """Connect a callback function to be triggered when the event is received.""" + if callback not in self.callbacks: + log.debug(f"connecting callback {callback}") + self.callbacks.append(callback) + + def disconnect(self, callback: callable) -> None: + """Disconnect a callback function.""" + if callback in self.callbacks: + log.debug(f"disconnecting callback {callback}") + self.callbacks.remove(callback) + + def is_callback(self, func: callable) -> bool: + """Check if given callable is already a registered callback""" + return func in self.callbacks + + def get_callbacks(self): + """Return a list of registered callback functions.""" + return self.callbacks + + +class EventDescriptor: + """A descriptor to handle instance-specific event connections.""" + + def __init__(self, func: callable = None): + # store the original function + self.func = func + # store callbacks for each instance + self._instances = {} + + @staticmethod + def _get_event_handler_cls(): + raise NotImplementedError + + def __get__(self, instance, owner): + """Ensure the method and event-handling functions are bound to the instance.""" + if instance is None: + # return descriptor itself if accessed via the class + return self + # create an event handler for this instance if not already created + if instance not in self._instances: + self._instances[instance] = self._get_event_handler_cls()(instance, self.func) + # return the instance-bound event handler + return self._instances[instance] + + +class LocalEventDescriptor(EventDescriptor): + @staticmethod + def _get_event_handler_cls(): + return LocalEventHandler + + +class RemoteEventDescriptor(EventDescriptor): + @staticmethod + def _get_event_handler_cls(): + return RemoteEventHandler + + +def event(func: callable): + """ + Decorator for events. Only use on mehtods bound to objects since this assumes + that the first argument passed to the decorated function is `self`. + """ + return LocalEventDescriptor(func) diff --git a/src/heros/heros.py b/src/heros/heros.py index b7c689c..31c2851 100644 --- a/src/heros/heros.py +++ b/src/heros/heros.py @@ -2,7 +2,7 @@ import zenoh import traceback from functools import partial from .capabilities import Capability, MethodCapability, AttributeCapability, EventCapability -from .event import RemoteEvent +from .event import RemoteEventDescriptor from .inspect import is_hero_event, is_local_only, local_only, mark_hero_method, is_hero_method from heros import serdes from heros.zenoh import session_manager as default_session_manager @@ -267,9 +267,10 @@ class RemoteHERO(HERO): setattr(self.__class__, cap.name, property(partial(f_getter, cap), partial(f_setter, cap))) elif isinstance(cap, EventCapability): - remote_event = RemoteEvent(name=cap.name) - self._subscribe_endpoint(cap.name, remote_event.trigger) + remote_event = RemoteEventDescriptor() setattr(self.__class__, cap.name, remote_event) + getattr(self, cap.name).__name__ = cap.name + self._subscribe_endpoint(cap.name, getattr(self, cap.name)) # calls __call__ def __eq__(self, other): return self.__class__.__name__ == other.__class__.__name__ and \ -- GitLab From cf33bfcddfe507c1339194090f613f89ebe1a783 Mon Sep 17 00:00:00 2001 From: Suthep Pomjaksilp Date: Thu, 20 Mar 2025 13:34:40 +0100 Subject: [PATCH 04/20] Make events and HERO methods serializable --- src/heros/serdes.py | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/src/heros/serdes.py b/src/heros/serdes.py index 906aad7..a45fa42 100644 --- a/src/heros/serdes.py +++ b/src/heros/serdes.py @@ -3,11 +3,14 @@ import numpy as np import weakref from heros.helper import full_classname +from .inspect import is_hero_event, is_hero_method ndarray_tag = 4242 hero_tag = 9000 unserializable_tag = 9001 unserializable_object_reference_tag = 9002 +hero_event_tag = 9003 +hero_method_tag = 9004 def is_builtin_class_instance(obj): @@ -88,6 +91,16 @@ def cbor_default_encoder(encoder, value): # encode a HERO encoder.encode(cbor2.CBORTag(hero_tag, [value._realm, value._name])) + elif is_hero_event(value): + # encode an event of a remote hero + # value.instance corresponds is the HERO instance + encoder.encode(cbor2.CBORTag(hero_event_tag, [value.instance, value.__name__])) + + elif is_hero_method(value): + # encode a method of a remote hero + # value.__self__ corresponds is the HERO instance + encoder.encode(cbor2.CBORTag(hero_method_tag, [value.__self__, value.__name__])) + elif type(value) is UnserializableRemoteObject: # encode an reference on an remote object that cannot be serialized encoder.encode(cbor2.CBORTag(unserializable_object_reference_tag, value.id)) @@ -112,6 +125,16 @@ def cbor_tag_hook(decoder, tag, shareable_index=None): realm, name = tag.value return RemoteHERO(name, realm=realm) + if tag.tag == hero_event_tag: + # decode a remote HERO event + remote_hero, event_name = tag.value + return getattr(remote_hero, event_name) + + if tag.tag == hero_method_tag: + # decode a remote HERO method + remote_hero, method_name = tag.value + return getattr(remote_hero, method_name) + if tag.tag == unserializable_tag: # decode t, i, s = tag.value -- GitLab From 191bd7a4f66453917881a72a89c6dcf9c5d70e8a Mon Sep 17 00:00:00 2001 From: Suthep Pomjaksilp Date: Fri, 21 Mar 2025 14:49:23 +0100 Subject: [PATCH 05/20] Add transparent direct p2p callbacks --- src/heros/event.py | 24 ++++++++++++++++-------- src/heros/heros.py | 27 ++++++++++++++++++++++++--- src/heros/inspect.py | 29 +++++++++++++++++++++++++++++ 3 files changed, 69 insertions(+), 11 deletions(-) diff --git a/src/heros/event.py b/src/heros/event.py index 0c18244..a30aa99 100644 --- a/src/heros/event.py +++ b/src/heros/event.py @@ -65,10 +65,11 @@ class LocalEventHandler(EventHandler): def connect(self, callback: callable): """Connect a callback function to be triggered when the method is called.""" if is_hero_method(callback): - log.debug(f"connecting callback {remote_hero_method_to_str(callback)}") + log.debug(f"{self.__class__} connecting RemoteHERO callback " + + f"{self.__name__} -> {remote_hero_method_to_str(callback)}") self.remote_hero_callbacks[remote_hero_method_to_str(callback)] = callback else: - log.debug(f"connecting callback {callback}") + log.debug(f"{self.__class__} connecting direct callback {self.__name__} -> {callback}") self.callbacks.append(callback) def disconnect(self, callback: callable) -> None: @@ -76,10 +77,11 @@ class LocalEventHandler(EventHandler): if not self.is_callback(callback): return if is_hero_method(callback): - log.debug(f"disconnecting callback {remote_hero_method_to_str(callback)}") + log.debug(f"{self.__class__} disconnecting RemoteHERO callback " + + f"{self.__name__} -> {remote_hero_method_to_str(callback)}") del self.remote_hero_callbacks[remote_hero_method_to_str(callback)] else: - log.debug(f"disconnecting callback {callback}") + log.debug(f"{self.__class__} disconnecting direct callback {self.__name__} -> {callback}") self.callbacks.remove(callback) def is_callback(self, func: callable) -> bool: @@ -111,14 +113,20 @@ class RemoteEventHandler(EventHandler): def connect(self, callback: callable): """Connect a callback function to be triggered when the event is received.""" - if callback not in self.callbacks: - log.debug(f"connecting callback {callback}") + if is_hero_method(callback): + log.debug(f"{self.__class__} connecting LocalHERO callback {self.__name__} -> {callback}") + self.instance.connect_local_hero_callback(event=self, remote_hero_method=callback) + elif callback not in self.callbacks: + log.debug(f"{self.__class__} connecting direct callback {self.__name__} -> {callback}") self.callbacks.append(callback) def disconnect(self, callback: callable) -> None: """Disconnect a callback function.""" + if is_hero_method(callback): + log.debug(f"{self.__class__} disconnecting LocalHERO callback {self.__name__} -> {callback}") + self.instance.disconnect_local_hero_callback(event=self, remote_hero_method=callback) if callback in self.callbacks: - log.debug(f"disconnecting callback {callback}") + log.debug(f"{self.__class__} disconnecting direct callback {self.__name__} -> {callback}") self.callbacks.remove(callback) def is_callback(self, func: callable) -> bool: @@ -127,7 +135,7 @@ class RemoteEventHandler(EventHandler): def get_callbacks(self): """Return a list of registered callback functions.""" - return self.callbacks + return self.callbacks, self.instance.get_local_hero_callbacks(event=self) class EventDescriptor: diff --git a/src/heros/heros.py b/src/heros/heros.py index 31c2851..77d59a5 100644 --- a/src/heros/heros.py +++ b/src/heros/heros.py @@ -3,7 +3,10 @@ import traceback from functools import partial from .capabilities import Capability, MethodCapability, AttributeCapability, EventCapability from .event import RemoteEventDescriptor -from .inspect import is_hero_event, is_local_only, local_only, mark_hero_method, is_hero_method +from .inspect import ( + is_hero_event, is_local_only, is_force_publish, is_hero_method, + local_only, force_publish, mark_hero_method +) from heros import serdes from heros.zenoh import session_manager as default_session_manager from .helper import object_name_from_keyexpr, log @@ -327,8 +330,12 @@ class LocalHERO(HERO): self.capabilities = [] for member_name in dir(self.__class__): # skip members we do not want to expose - if (member_name.startswith("_") or member_name in dir(LocalHERO) or - is_local_only(getattr(self.__class__, member_name))): + exclude = any([ + member_name.startswith("_"), + member_name in dir(LocalHERO), + is_local_only(getattr(self.__class__, member_name)) + ]) + if exclude and (not is_force_publish(getattr(self.__class__, member_name))): continue if callable(getattr(self, member_name)): @@ -379,6 +386,20 @@ class LocalHERO(HERO): self._session.declare_queryable(f"{self._endpoint_base_path}/{member_name}", partial(wrapper, cap)) self.capabilities.append(cap) + @force_publish + def connect_local_hero_callback(self, event: callable , remote_hero_method: callable): + log.debug(f"connecting callback to {event.__name__}: {remote_hero_method}") + getattr(self, event.__name__).connect(remote_hero_method) + + @force_publish + def disconnect_local_hero_callback(self, event: callable , remote_hero_method: callable): + log.debug(f"disconnecting callback from {event.__name__}: {remote_hero_method}") + getattr(self, event.__name__).disconnect(remote_hero_method) + + @force_publish + def get_local_hero_callbacks(self, event: callable): + return getattr(self, event.__name__).get_callbacks() + class HEROObserver(HEROPeer): """ diff --git a/src/heros/inspect.py b/src/heros/inspect.py index 307e91d..e0608d3 100644 --- a/src/heros/inspect.py +++ b/src/heros/inspect.py @@ -1,6 +1,7 @@ HERO_EVENT_ATTRIBUTE = "hero_event" HERO_METHOD_ATTRIBUTE = "hero_method" LOCAL_ONLY_ATTRIBUTE = "local_only" +FORCE_PUBLISH_ATTRIBUTE = "force_publish" def _mark(func: callable, marker: str) -> callable: @@ -76,6 +77,21 @@ def mark_local_only(func: callable) -> callable: # rename mark_local_only to use as a decorator local_only = mark_local_only +def mark_force_publish(func: callable) -> callable: + """ + Mark a callable as force publish. + + Args: + func: callable to mark + + Returns: + The marked callable. + """ + return _mark(func, FORCE_PUBLISH_ATTRIBUTE) + +# rename mark_force_publish to use as a decorator +force_publish = mark_force_publish + def is_hero_event(func: callable) -> bool: """ @@ -117,3 +133,16 @@ def is_local_only(func: callable) -> bool: `False` if the marker is not present. """ return _is_marked(func, LOCAL_ONLY_ATTRIBUTE) + +def is_force_publish(func: callable) -> bool: + """ + Check if a callable is a force publish. + + Args: + func: callable to check + + Returns: + The value of the marker. + `False` if the marker is not present. + """ + return _is_marked(func, FORCE_PUBLISH_ATTRIBUTE) -- GitLab From 66f96733970886c3d66ee4d373eb908548ad1d3c Mon Sep 17 00:00:00 2001 From: Suthep Pomjaksilp Date: Fri, 21 Mar 2025 14:50:04 +0100 Subject: [PATCH 06/20] Change event print to log.debug --- src/heros/heros.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/heros/heros.py b/src/heros/heros.py index 77d59a5..c647634 100644 --- a/src/heros/heros.py +++ b/src/heros/heros.py @@ -341,8 +341,7 @@ class LocalHERO(HERO): if callable(getattr(self, member_name)): if is_hero_event(getattr(self, member_name)): - print(f"found event with name {member_name}!") - + log.debug(f"found event with name {member_name}!") cap = EventCapability(name=member_name) wrapper = None else: -- GitLab From 9fb218773f72ce52a1be1a5deedbfc4bfb13d866 Mon Sep 17 00:00:00 2001 From: Suthep Pomjaksilp Date: Mon, 24 Mar 2025 12:22:53 +0100 Subject: [PATCH 07/20] Add unified interface for callbacks This introduces the Callback class to contains single callbacks regardless if they are remote methods or local function calls. Additionally, we introduce the CallbackStorage to store all types of callbacks. --- src/heros/event.py | 337 +++++++++++++++++++++++++++++++++++++-------- src/heros/heros.py | 43 +++++- 2 files changed, 312 insertions(+), 68 deletions(-) diff --git a/src/heros/event.py b/src/heros/event.py index a30aa99..85382c2 100644 --- a/src/heros/event.py +++ b/src/heros/event.py @@ -8,8 +8,149 @@ def remote_hero_method_to_str(func: callable) -> str: return f"{func.__self__._name}.{func.__name__}" +class Callback: + """ + Represent a callback function. + """ + + def __init__(self, func: callable, origin: str = None): + self.func = func + self.origin = origin + if is_hero_method(func): + self.name = remote_hero_method_to_str(func) + self.is_remote_hero = True + else: + self.name = repr(func) + self.is_remote_hero = False + + def __eq__(self, other) -> bool: + """ + Check for equality with other `Callback`. + + Args: + other: other callback instance. + + Returns: + bool: equality result. + """ + if self.is_remote_hero == other.is_remote_hero: + if self.is_remote_hero: + # both True + # check if names are equal + return self.name == other.name + # both False + # check if callback functions are equal + return self.func == other.func + # one is remote and the other one is not + return False + + def __call__(self, *args, **kwargs): + """ + Call the callback function. + """ + return self.func(*args, **kwargs) + + def __hash__(self): + """ + Generate a hash value for this callback using the name in case of remote hero methods + or the callable itself for builtins or local callables. + + Returns: + int: calculated hash. + """ + if self.is_remote_hero: + return hash(self.name) + else: + return hash(self.func) + + def to_dict(self) -> dict: + """ + Generate a dictionary representation of this callback. + + Returns: + dict: dictionary with keys: name, origin, is_remote_hero and func + """ + return {"name": self.name, "origin": self.origin, "is_remote_hero": self.is_remote_hero, "func": self.func} + + +class CallbackStorage: + """ + Store all callbacks. + """ + + def __init__(self): + self._callbacks = {} + + def __iter__(self): + """ + Generate an iteration for this iterable. + """ + return iter(self._callbacks.values()) + + def __contains__(self, func: callable) -> bool: + """ + Implements the `in` operation for this class. + """ + callback = Callback(func) + return callback.name in self._callbacks.keys() + + def append(self, func: callable, origin: str = None) -> str: + """ + Append a given callable to the storage. + + Args: + func: `callable` to append. + origin: `str` (default: `None`) indicating the origin of the callback. + + Returns: + str: name of the callback. + """ + callback = Callback(func, origin) + self._callbacks[callback.name] = callback + return callback.name + + def remove(self, func: callable) -> bool: + """ + Remove a callable from storage. + + Args: + func: `callable` to remove. + + Returns: + bool: truth value indicating if the callable was a callback. + """ + if func in self: + callback = Callback(func) + del self._callbacks[callback.name] + return True + return False + + def is_callback(self, func: callable) -> bool: + """ + Check if given `callable` is a callback. + + Args: + func: `callable` to check. + + Returns: + bool: `callable` is a callback + """ + return func in self + + def get_callbacks(self) -> list: + """ + Get a list of all callbacks dictionaries. + + Returns: + list: dictionary representation of all callbacks + """ + return [cb.to_dict() for name, cb in self._callbacks.items()] + + class EventHandler: - """Base class for event handlers""" + """ + Base class for event handlers. + """ def connect(self, callback: callable): """Connect a callback function.""" @@ -29,117 +170,174 @@ class EventHandler: class LocalEventHandler(EventHandler): - """Handles event connections for a specific instance.""" + """ + Handles event connections for a specific instance. + """ def __init__(self, instance, func): self.instance = instance self.func = func # store callbacks for this instance - self.callbacks = [] - self.remote_hero_callbacks = {} + self.callbacks = CallbackStorage() # mark as an hero event setattr(self, HERO_EVENT_ATTRIBUTE, True) # preserve signature and metadata of `func` functools.update_wrapper(self, func) def __call__(self, *args, **kwargs): - """Call the original function and trigger callbacks.""" + """ + Call the original function and trigger callbacks. + """ # call the method result = self.func(self.instance, *args, **kwargs) # publish result (triggers RemoteEvent callbacks) endpoint = f"{self.instance._endpoint_base_path}/{self.func.__name__}" self.instance._session.put(endpoint, self.instance._serialize(result)) - # execute attached (local) callbacks + # execute callbacks for callback in self.callbacks: callback(result) - # execute callbacks on RemoteHERO methods - for key, callback in self.remote_hero_callbacks.items(): - callback(result) - # try: - # callback(result) - # except Exception as ex: # TODO specify exception - # log.error(f"callback {key} failed with {ex}") - # self.disconnect(callback) return result - def connect(self, callback: callable): - """Connect a callback function to be triggered when the method is called.""" - if is_hero_method(callback): - log.debug(f"{self.__class__} connecting RemoteHERO callback " + - f"{self.__name__} -> {remote_hero_method_to_str(callback)}") - self.remote_hero_callbacks[remote_hero_method_to_str(callback)] = callback - else: - log.debug(f"{self.__class__} connecting direct callback {self.__name__} -> {callback}") - self.callbacks.append(callback) - - def disconnect(self, callback: callable) -> None: - """Disconnect a callback function.""" - if not self.is_callback(callback): - return - if is_hero_method(callback): - log.debug(f"{self.__class__} disconnecting RemoteHERO callback " + - f"{self.__name__} -> {remote_hero_method_to_str(callback)}") - del self.remote_hero_callbacks[remote_hero_method_to_str(callback)] - else: - log.debug(f"{self.__class__} disconnecting direct callback {self.__name__} -> {callback}") - self.callbacks.remove(callback) + def connect(self, callback: callable, origin: str = None) -> str: + """ + Connect a callback function to be triggered when the method is called. + + Args: + callback: `callable` to connect. + origin: (optional) `str` indicting origin of this callback. + + Returns: + str: name of the callback. + """ + callback_name = self.callbacks.append(callback, origin) + log.debug(f"{self.__class__} connecting LocalEvent callback {self.__name__} -> {callback_name} (origin: {origin})") + return callback_name + + def disconnect(self, callback: callable) -> bool: + """ + Disconnect a callback function. + + Args: + callback: `callable` to disconnect. + + Returns: + bool: truth value indicating if the callable was a callback. + """ + log.debug(f"{self.__class__} disconnecting LocalEvent callback {self.__name__} -> {callback}") + return self.callbacks.remove(callback) def is_callback(self, func: callable) -> bool: - """Check if given callable is already a registered callback""" + """ + Check if given callable is already a registered callback. + + Args: + callback: `callable` to check. + + Returns: + bool: truth value indicating if the callable is a callback. + """ if is_hero_method(func): return remote_hero_method_to_str(func) in self.remote_hero_callbacks.keys() else: return func in self.callbacks - def get_callbacks(self): - """Return a list of registered callback functions.""" - return self.callbacks, self.remote_hero_callbacks + def get_callbacks(self) -> list: + """ + Return a list of registered callback functions. + + Returns: + list: dictionary representation of all callbacks + """ + return self.callbacks.get_callbacks() class RemoteEventHandler(EventHandler): - """Handles remote events for a specific instance.""" + """ + Handles remote events for a specific instance. + """ def __init__(self, instance, func: callable = None): self.instance = instance # store callbacks for this instance - self.callbacks = [] + self.callbacks = CallbackStorage() # mark as an hero event setattr(self, HERO_EVENT_ATTRIBUTE, True) def __call__(self, payload): - """Trigger all callbacks connect to the event.""" + """ + Call the original function and trigger callbacks. + """ for callback in self.callbacks: callback(payload) - def connect(self, callback: callable): - """Connect a callback function to be triggered when the event is received.""" + def connect(self, callback: callable) -> str: + """ + Connect a callback function to be triggered when the method is called. + + Args: + callback: `callable` to connect. + origin: (optional) `str` indicting origin of this callback. + + Returns: + str: name of the callback. + """ if is_hero_method(callback): - log.debug(f"{self.__class__} connecting LocalHERO callback {self.__name__} -> {callback}") - self.instance.connect_local_hero_callback(event=self, remote_hero_method=callback) + callback_name = self.instance.connect_local_hero_callback( + event=self, + remote_hero_method=callback, + origin=self.instance._name + ) + log.debug(f"{self.__class__} connecting LocalEvent callback {self.__name__} -> {callback}") elif callback not in self.callbacks: - log.debug(f"{self.__class__} connecting direct callback {self.__name__} -> {callback}") - self.callbacks.append(callback) + callback_name = self.callbacks.append(callback) + log.debug(f"{self.__class__} connecting RemoteEvent callback {self.__name__} -> {callback}") + return callback_name def disconnect(self, callback: callable) -> None: - """Disconnect a callback function.""" + """ + Disconnect a callback function. + + Args: + callback: `callable` to disconnect. + + Returns: + bool: truth value indicating if the callable was a callback. + """ if is_hero_method(callback): - log.debug(f"{self.__class__} disconnecting LocalHERO callback {self.__name__} -> {callback}") - self.instance.disconnect_local_hero_callback(event=self, remote_hero_method=callback) + log.debug(f"{self.__class__} disconnecting LocalEvent callback {self.__name__} -> {callback}") + return self.instance.disconnect_local_hero_callback(event=self, remote_hero_method=callback) if callback in self.callbacks: - log.debug(f"{self.__class__} disconnecting direct callback {self.__name__} -> {callback}") - self.callbacks.remove(callback) + log.debug(f"{self.__class__} disconnecting RemoteEvent callback {self.__name__} -> {callback}") + return self.callbacks.remove(callback) def is_callback(self, func: callable) -> bool: - """Check if given callable is already a registered callback""" + """ + Check if given callable is already a registered callback. + + Args: + callback: `callable` to check. + + Returns: + bool: truth value indicating if the callable is a callback. + """ return func in self.callbacks - def get_callbacks(self): - """Return a list of registered callback functions.""" - return self.callbacks, self.instance.get_local_hero_callbacks(event=self) + def get_callbacks(self) -> list: + """ + Return a list of registered callback functions. + + Returns: + list: dictionary representation of all callbacks + """ + remote_event_cbs = self.callbacks.get_callbacks() + local_event_cbs = self.instance.get_local_hero_callbacks(event=self) + return [*remote_event_cbs, *local_event_cbs] class EventDescriptor: - """A descriptor to handle instance-specific event connections.""" + """ + A descriptor to handle instance-specific event connections. + """ def __init__(self, func: callable = None): # store the original function @@ -152,7 +350,14 @@ class EventDescriptor: raise NotImplementedError def __get__(self, instance, owner): - """Ensure the method and event-handling functions are bound to the instance.""" + """ + Ensure the method and event-handling functions are bound to the instance. + + Args: + self: the EventDescriptor instance + instance: the owning `LocalHERO`/`RemoteHERO`. + owner: + """ if instance is None: # return descriptor itself if accessed via the class return self @@ -164,12 +369,20 @@ class EventDescriptor: class LocalEventDescriptor(EventDescriptor): + """ + Descriptor of `@event` decorated methods of a `LocalHERO`. + """ + @staticmethod def _get_event_handler_cls(): return LocalEventHandler class RemoteEventDescriptor(EventDescriptor): + """ + Descriptor of remote representations of events in a `RemoteHERO`. + """ + @staticmethod def _get_event_handler_cls(): return RemoteEventHandler @@ -177,7 +390,9 @@ class RemoteEventDescriptor(EventDescriptor): def event(func: callable): """ - Decorator for events. Only use on mehtods bound to objects since this assumes - that the first argument passed to the decorated function is `self`. + Decorator for events. + + Note: + Only use on methods bound to objects. """ return LocalEventDescriptor(func) diff --git a/src/heros/heros.py b/src/heros/heros.py index c647634..6ece8aa 100644 --- a/src/heros/heros.py +++ b/src/heros/heros.py @@ -386,17 +386,46 @@ class LocalHERO(HERO): self.capabilities.append(cap) @force_publish - def connect_local_hero_callback(self, event: callable , remote_hero_method: callable): - log.debug(f"connecting callback to {event.__name__}: {remote_hero_method}") - getattr(self, event.__name__).connect(remote_hero_method) + def connect_local_hero_callback(self, event: callable , remote_hero_method: callable, origin: str = None) -> str: + """ + Connect a method of `RemoteHERO` as a callback to an event of the `LocalHERO`. + This leads to a new, direct P2P connection between the `RemoteHERO` and the `LocalHERO` to call the method. + + Args: + event: the event `callable`, i.e. a method that is decorated with `@event` in the `LocalHERO`. + remote_hero_method: `callable` to connect as a callback. + origin: optional `str` indicating the semantic origin of the connection. + + Returns: + str: name of the callback. + """ + return getattr(self, event.__name__).connect(remote_hero_method, origin) @force_publish - def disconnect_local_hero_callback(self, event: callable , remote_hero_method: callable): - log.debug(f"disconnecting callback from {event.__name__}: {remote_hero_method}") - getattr(self, event.__name__).disconnect(remote_hero_method) + def disconnect_local_hero_callback(self, event: callable , remote_hero_method: callable) -> bool: + """ + Disconnect a method of `RemoteHERO` from an event of the `LocalHERO`. + + Args: + event: the event `callable`, i.e. a method that is decorated with `@event` in the `LocalHERO`. + remote_hero_method: `callable` to connect as a callback. + + Returns: + bool: truth value if the remote method was indeed a callback. + """ + return getattr(self, event.__name__).disconnect(remote_hero_method) @force_publish - def get_local_hero_callbacks(self, event: callable): + def get_local_hero_callbacks(self, event: callable) -> list: + """ + Get a list of dictionary representations of the callbacks of an event of the `LocalHERO`. + + Args: + event: the event `callable`, i.e. a method that is decorated with `@event` in the `LocalHERO`. + + Returns: + list: dictionary representations of the callbacks. + """ return getattr(self, event.__name__).get_callbacks() -- GitLab From f8baf5bacd62423c37e3df416a444f34a7728b0a Mon Sep 17 00:00:00 2001 From: Suthep Pomjaksilp Date: Mon, 24 Mar 2025 14:01:01 +0100 Subject: [PATCH 08/20] Add context key to callback dict The context indicates the caller context of the callback (either `LocalHERO` oder `RemoteHERO`). --- src/heros/event.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/heros/event.py b/src/heros/event.py index 85382c2..4cd3bb3 100644 --- a/src/heros/event.py +++ b/src/heros/event.py @@ -248,7 +248,7 @@ class LocalEventHandler(EventHandler): Returns: list: dictionary representation of all callbacks """ - return self.callbacks.get_callbacks() + return [{**cb, "context": "LocalHERO"} for cb in self.callbacks.get_callbacks()] class RemoteEventHandler(EventHandler): @@ -329,8 +329,8 @@ class RemoteEventHandler(EventHandler): Returns: list: dictionary representation of all callbacks """ - remote_event_cbs = self.callbacks.get_callbacks() - local_event_cbs = self.instance.get_local_hero_callbacks(event=self) + remote_event_cbs = [{**cb, "context": "RemoteHERO"} for cb in self.callbacks.get_callbacks()] + local_event_cbs = [{**cb, "context": "LocalHERO"} for cb in self.instance.get_local_hero_callbacks(event=self)] return [*remote_event_cbs, *local_event_cbs] -- GitLab From 329a0c48b01e82c2b0fe2b11fc33d42f276fd127 Mon Sep 17 00:00:00 2001 From: Suthep Pomjaksilp Date: Mon, 24 Mar 2025 14:18:21 +0100 Subject: [PATCH 09/20] Add event example script with 4 cases --- examples/connect_event_callbacks.py | 76 +++++++++++++++++++++++++++++ 1 file changed, 76 insertions(+) create mode 100644 examples/connect_event_callbacks.py diff --git a/examples/connect_event_callbacks.py b/examples/connect_event_callbacks.py new file mode 100644 index 0000000..435c055 --- /dev/null +++ b/examples/connect_event_callbacks.py @@ -0,0 +1,76 @@ +from heros import LocalHERO, event, RemoteHERO, log + +import asyncio + +log.setLevel("DEBUG") + + +class Alice(LocalHERO): + + testme: int = 0 + + def print_from_afar(self, payload)-> str: + print(f"printed from {self._name} {payload}") + + def hello(self) -> str: + self.testme += 1 + return "world" + + @event + def new_data(self, value): + return value + + +class Bob(Alice): + pass + + +# Create two local objects and two remote representations of them. +alice = Alice("alice") +bob = Bob("bob") + +remote_alice = RemoteHERO("alice") +remote_bob = RemoteHERO("bob") + +# When connecting these objects with events and callbacks, we can distinguish 4 cases: +alice.new_data.connect(print) # case A: local object, local callback +alice.new_data.connect(remote_bob.print_from_afar) # case B: local object, remote callback + +remote_alice.new_data.connect(print) # case C: remote object, local callback +remote_alice.new_data.connect(remote_bob.print_from_afar) # case D: remote object, remote callback (p2p dispatch) + +# Print the connected callbacks for all objects +for o in [alice, remote_alice]: + print(f"Connected callbacks for {o}:") + print(o.new_data.get_callbacks()) + print("###") + +# Now remove the all callbacks from remote_alice which are called from remote_alice +for cb in remote_alice.new_data.get_callbacks(): + if cb["context"] == "RemoteHERO": + print(f"Removing {cb['name']} from remote_alice.new_data") + remote_alice.new_data.disconnect(cb["func"]) +print("###") +print(f"Remaining callbacks to remote_alice.new_data") +print(remote_alice.new_data.get_callbacks()) +print("###") + + +# Emit data from the from events +loop = asyncio.new_event_loop() +async def emit_data(): + i = 0 + while True: + alice.new_data(["Alice", i]) + bob.new_data(["Bob", i]) + i += 1 + await asyncio.sleep(1) + +# Keep running +loop.create_task(emit_data()) +try: + loop.run_forever() +except KeyboardInterrupt: + pass +finally: + obj.close() -- GitLab From 9a0c0b89bef5de7f61a45be3c9fec409dd06a5fd Mon Sep 17 00:00:00 2001 From: Suthep Pomjaksilp Date: Wed, 26 Mar 2025 14:15:53 +0100 Subject: [PATCH 10/20] Reformat docs --- docs/index.rst | 21 +++++++++------------ docs/paradigms.rst | 28 +++++++++++++--------------- docs/usage.rst | 30 +++++++++++++++--------------- 3 files changed, 37 insertions(+), 42 deletions(-) diff --git a/docs/index.rst b/docs/index.rst index e5fe232..8bc1430 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -7,18 +7,15 @@ HEROS - Highly Efficient Remote Object Service =============================================== HEROS is a decentralized object sharing service. In simple words it makes your software objects network transparent. -To be fast and efficient, HEROS relies on eclipse-zenoh as a transport layer. It thus supports different network -topologies and hardware transports. Most notably, it can run completely decentralized, avoiding a single point -of failure and at the same time guaranteeing low latency and and high bandwidth communication through p2p connections. - -HEROS provides a logical representation of software objects and is not tied to any specific language. Even non-object -oriented programming languages might provide a collection of functions, variables, and events to be accessible as an -object in HEROS. - -Very much like a Dynamic Invocation Infrastructure (DII) in a Common Object Broker Architecture (CORBA), HEROS handles -objects dynamically during runtime rather than during compile time. While this does not allow to map HEROS objects to -be mapped to the language objects in compiled languages, languages supporting monkey-patching (python, js, ...) are -still able to create proxy objects during runtime. +To be fast and efficient, HEROS relies on eclipse-zenoh as a transport layer. +It thus supports different network topologies and hardware transports. +Most notably, it can run completely decentralized, avoiding a single point of failure and at the same time guaranteeing low latency and and high bandwidth communication through p2p connections. + +HEROS provides a logical representation of software objects and is not tied to any specific language. +Even non-object oriented programming languages might provide a collection of functions, variables, and events to be accessible as an object in HEROS. + +Very much like a Dynamic Invocation Infrastructure (DII) in a Common Object Broker Architecture (CORBA), HEROS handles objects dynamically during runtime rather than during compile time. +While this does not allow to map HEROS objects to be mapped to the language objects in compiled languages, languages supporting monkey-patching (python, js, ...) are still able to create proxy objects during runtime. The working principle is shown in the following. diff --git a/docs/paradigms.rst b/docs/paradigms.rst index ad50852..8427859 100644 --- a/docs/paradigms.rst +++ b/docs/paradigms.rst @@ -4,21 +4,20 @@ Paradigms Realms ------ -To isolate groups of HERO objects from other groups the concept of realms exists in HEROS. You can think of it as a -namespace where objects in the same namespace can talk to each other while communication across realms/namespaces is -not easily possible. Note that this is solely a management feature, not a security feature. All realms share the same -zenoh network and can thus talk to each other on this level. +To isolate groups of HERO objects from other groups the concept of realms exists in HEROS. +You can think of it as a namespace where objects in the same namespace can talk to each other while communication across realms/namespaces is +not easily possible. +Note that this is solely a management feature, not a security feature. +All realms share the same zenoh network and can thus talk to each other on this level. Objects ------- -An object that should be shared via HEROS must inherit from the class `LocalHero`. When python instantiates such -an object, it will parse the methods, class attributes, and events (see event decorator) and automatically generate -a list of capabilities that describes this HEROS object. The capabilities are announced and a liveliness token for the -object is created. HEROSOberserver in the network will thus be notified that our new object joined the realm. +An object that should be shared via HEROS must inherit from the class ``LocalHero``. +When python instantiates such an object, it will parse the methods, class attributes, and :doc:`events ` (see event decorator) and automatically generate a list of capabilities that describes this HEROS object. +The capabilities are announced and a liveliness token for the object is created. ``HEROSOberserver`` in the network will thus be notified that our new object joined the realm. -When the object is destroyed or the link gets lost, the liveliness token disappears and any remote object will notice -this. +When the object is destroyed or the link gets lost, the liveliness token disappears and any remote object will notice this. Capabilities ------------ @@ -33,12 +32,11 @@ A HEROS object is characterized by the capabilities it provides. There are curre Datasource ---------- -A frequent use case for HERO is that of making data (like sensor data or status data) available to interested peers. To cover this use -case, a special class of HERO exists, the DatasourceHERO. It provides a special event `new_data` that is always emitted when new data -is available. RemoteHEROs connect to the emitting HERO will get noticed directly and react accordingly. In addition also a DatasourceObsever class -exist, that efficiently monitors the events of many HEROs in the network without fully instanciating the RemoteHEROs. +A frequent use case for HERO is that of making data (like sensor data or status data) available to interested peers. +To cover this use case, a special class of HERO exists, the ``DatasourceHERO``. It provides a special event ``new_data`` that is always emitted when new data is available. RemoteHEROs connect to the emitting HERO will get noticed directly and react accordingly. +In addition also a ``DatasourceObsever`` class exist, that efficiently monitors the events of many HEROs in the network without fully instantiating the RemoteHEROs. Class Tree ---------- -This is not yet implemented. I will be a method to \ No newline at end of file +This is not yet implemented. diff --git a/docs/usage.rst b/docs/usage.rst index 2e36501..43a7d7e 100644 --- a/docs/usage.rst +++ b/docs/usage.rst @@ -1,19 +1,17 @@ Usage ##### -Building your first HERO is very simple. Just write your custom class and make it inherit from :code:`heros.LocalHero` +Building your first HERO is very simple. Just write your custom class and make it inherit from ``heros.LocalHero``. A simple example can be seen .. literalinclude:: ../examples/share_local_hero_simple.py :language: python -Due to the infinite loop, the script will not terminate and keep the object `obj` alive. Since it inherits from `heros.LocalHERO`, -the object was analyzed upon instantiation and provided as a HERO to the network. This means, we can now simply access the method -attributes of the object from a remote site. +Due to the infinite loop, the script will not terminate and keep the object ``obj`` alive. Since it inherits from ``heros.LocalHERO``, the object was analyzed upon instantiation and provided as a HERO to the network. +This means, we can now simply access the method attributes of the object from a remote site. -To get remote access we can run the following in a different process or on a different machine in the same network (UDP broadcast -needs to reach the other machine for discovery): +To get remote access we can run the following in a different process or on a different machine in the same network (UDP broadcast needs to reach the other machine for discovery): .. literalinclude:: ../examples/access_remote_hero_simple.py :language: python @@ -21,8 +19,10 @@ needs to reach the other machine for discovery): Nested HEROs ------------ -HEROS is able to serialize HEROs as references to a HERO. This allows to pass a HERO from between the local and the remote site. -When either of the sites receives such a reference, it creates a RemoteHERO to access the referenced HERO. This allows things like +HEROS is able to serialize HEROs as references to a HERO. +This allows to pass a HERO from between the local and the remote site. +When either of the sites receives such a reference, it creates a ``RemoteHERO`` to access the referenced HERO. +This allows things like 1. Deep access to HEROs nested inside of HEROs. 2. Passing HEROs as arguments into methods exposed by a HERO. @@ -32,12 +32,12 @@ When either of the sites receives such a reference, it creates a RemoteHERO to a Unserializable Objects ---------------------- -A HERO attribute or method might return an object that is not a HERO and can not be serialized. In that case, the returned object is -cached on the side of the LocalHERO and an identifier is sent to the remote side. The remote side can store the reference locally. -If the reference is sent back to the LocalHERO side, the corresponding object is taken from the cache and inserted into the request -instead of the reference. This allows to instruct the LocalHERO to do something with an object that cannot be transferred. This -allows, for example, to hand over an unserializable object retrieved earlier as argument to a function. +A HERO attribute or method might return an object that is not a HERO and can not be serialized. +In that case, the returned object is cached on the side of the ``LocalHERO`` and an identifier is sent to the remote side. The remote side can store the reference locally. +If the reference is sent back to the ``LocalHERO`` side, the corresponding object is taken from the cache and inserted into the request instead of the reference. +This allows to instruct the LocalHERO to do something with an object that cannot be transferred. +This allows, for example, to hand over an unserializable object retrieved earlier as argument to a function. .. note:: - The cache that keeps the object references uses only weak references to to avoid memory leaks. That means that an object can be garbage - collected if not any other instance keeps a reference on it. \ No newline at end of file + The cache that keeps the object references uses only weak references to to avoid memory leaks. + That means that an object can be garbage collected if not any other instance keeps a reference on it. -- GitLab From dd15532123672b9e6f1d0aa1f1688f65ed603344 Mon Sep 17 00:00:00 2001 From: Suthep Pomjaksilp Date: Wed, 26 Mar 2025 14:16:20 +0100 Subject: [PATCH 11/20] Add docs page for events --- docs/events.rst | 104 ++++++++++++++++++++++++++++++++++++++++++++++++ docs/index.rst | 1 + 2 files changed, 105 insertions(+) create mode 100644 docs/events.rst diff --git a/docs/events.rst b/docs/events.rst new file mode 100644 index 0000000..e1bfd3e --- /dev/null +++ b/docs/events.rst @@ -0,0 +1,104 @@ +Events and Callbacks +#################### + +.. _sec-events: + +Events +------ +Methods of custom classes inheriting from ``LocalHERO`` can be marked as an event using the ``@event`` decorator. +Doing so replaces the method with a ``heros.event.LocalEventHandler`` and implies two things: + + 1. The method now supports callbacks (see :ref:`sec-callbacks`) which are executed using the return value of the decorated method. + 2. The return value of the decorated method is published to a unique endpoint within the realm of the zenoh network. + +In the remote representation of the object - the ``RemoteHERO`` - the event is represented as a ``heros.event.RemoteEventHandler``. +This the remote event supports callbacks which are executed using the payload received via the local event endpoint in the zenoh network. + +.. note:: + The remote representation of an event is not callable itself. + + +.. _sec-callbacks: + +Callbacks +--------- +The syntax for using callbacks is regardless wether the we deal with a local or a remote event. +Connecting or disconnecting a callable ``func`` as a callback to an event ``my_event`` is performed via ``my_event.connect(func)`` and ``my_event.disconnect(func)``, respectively. +Calling ``my_event.get_callbacks()`` returns a list of callbacks. + +Callbacks of events can be categorized into four cases differentiated by the execution context of the event (``LocalEventHandler`` or ``RemoteEventHandler``) and the type of the callable. +Consider the event ``my_event`` of ``alice``: + +.. code-block:: python + + class Alice(LocalHERO): + @event + def my_event(self, value): + return do_sth(value) + + # instantiate a local alice + alice = Alice() + +Additionally, consider the three execution contexts (i.e. three python interpreters on three hosts): + + 1. ``ALICE`` where the ``LocalHERO`` of a class ``Alice`` is instantiated. + 2. ``BOB`` where the ``LocalHERO`` of a class ``Bob`` is instantiated. + 3. ``EVE`` where only remote representations (``RemoteHERO``) of ``alice`` and ``bob`` are present. + +For a full example, also consider ``examples/connect_event_callbacks.py``. + + +Case A: Local object, local callback +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. code-block:: python + + # execution context ALICE + alice.my_event.connect(print) + +Whenever ``my_event`` returns, the function ``print`` is called in the execution context ``ALICE``. +Therefore, the callback can be understood as ``print(alice.my_event(value))``. + + +Case B: Local object, remote callback +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. code-block:: python + + # execution context ALICE + # get a remote representation of bob + remote_bob = RemoteHERO("bob") + alice.my_event.connect(remote_bob.the_builder) + +Whenever ``my_event`` returns, the method ``the_builder`` of the remote representation of ``bob`` is called in the execution context ``ALICE``. +Therefore, the callback can be understood as ``remote_bob.the_builder(alice.my_event(value))``. + + +Case C: Remote object, local callback +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. code-block:: python + + # execution context EVE + # get a remote representation of alice + remote_alice = RemoteHERO("alice") + remote_alice.my_event.connect(print) + +Whenever ``my_event`` of ``alice`` (the ``LocalHERO``) returns, the function ``print`` is called in the execution context of ``EVE``. +In this case, the ``RemoteEventHandler`` representation of the ``my_event`` (``remote_alice.my_event``) calls ``print`` once it receives the return value of ``alice.my_event`` via the zenoh network. + + +Case D: Remote object, remote callback +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. code-block:: python + + # execution context EVE + # get remote representations of alice and bob + remote_alice = RemoteHERO("alice") + remote_bob = RemoteHERO("bob") + remote_alice.my_event.connect(remote_bob.the_builder) + +Connecting the remote callable ``remote_bob.the_builder`` to the remote representation ``remote_alice.my_event`` in the context of ``EVE`` leads to a special behavior. +The callable ``remote_bob.the_builder`` is automatically attached as a remote callback to ``alice`` (the ``LocalHERO``) similar to case B. +This way, calling ``remote_bob.the_builder`` upon return of ``alice.my_event`` is handled as a direct P2P connection between the contexts ``ALICE`` and ``BOB``. diff --git a/docs/index.rst b/docs/index.rst index 8bc1430..1f20d65 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -31,5 +31,6 @@ The working principle is shown in the following. paradigms installation usage + events apiref -- GitLab From 05536af88f2de5d2113b54f4743ce00ca2002af2 Mon Sep 17 00:00:00 2001 From: Suthep Pomjaksilp Date: Wed, 26 Mar 2025 14:15:53 +0100 Subject: [PATCH 12/20] Reformat docs --- docs/index.rst | 21 +++++++++------------ docs/paradigms.rst | 28 +++++++++++++--------------- docs/usage.rst | 30 +++++++++++++++--------------- 3 files changed, 37 insertions(+), 42 deletions(-) diff --git a/docs/index.rst b/docs/index.rst index e5fe232..8bc1430 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -7,18 +7,15 @@ HEROS - Highly Efficient Remote Object Service =============================================== HEROS is a decentralized object sharing service. In simple words it makes your software objects network transparent. -To be fast and efficient, HEROS relies on eclipse-zenoh as a transport layer. It thus supports different network -topologies and hardware transports. Most notably, it can run completely decentralized, avoiding a single point -of failure and at the same time guaranteeing low latency and and high bandwidth communication through p2p connections. - -HEROS provides a logical representation of software objects and is not tied to any specific language. Even non-object -oriented programming languages might provide a collection of functions, variables, and events to be accessible as an -object in HEROS. - -Very much like a Dynamic Invocation Infrastructure (DII) in a Common Object Broker Architecture (CORBA), HEROS handles -objects dynamically during runtime rather than during compile time. While this does not allow to map HEROS objects to -be mapped to the language objects in compiled languages, languages supporting monkey-patching (python, js, ...) are -still able to create proxy objects during runtime. +To be fast and efficient, HEROS relies on eclipse-zenoh as a transport layer. +It thus supports different network topologies and hardware transports. +Most notably, it can run completely decentralized, avoiding a single point of failure and at the same time guaranteeing low latency and and high bandwidth communication through p2p connections. + +HEROS provides a logical representation of software objects and is not tied to any specific language. +Even non-object oriented programming languages might provide a collection of functions, variables, and events to be accessible as an object in HEROS. + +Very much like a Dynamic Invocation Infrastructure (DII) in a Common Object Broker Architecture (CORBA), HEROS handles objects dynamically during runtime rather than during compile time. +While this does not allow to map HEROS objects to be mapped to the language objects in compiled languages, languages supporting monkey-patching (python, js, ...) are still able to create proxy objects during runtime. The working principle is shown in the following. diff --git a/docs/paradigms.rst b/docs/paradigms.rst index ad50852..8427859 100644 --- a/docs/paradigms.rst +++ b/docs/paradigms.rst @@ -4,21 +4,20 @@ Paradigms Realms ------ -To isolate groups of HERO objects from other groups the concept of realms exists in HEROS. You can think of it as a -namespace where objects in the same namespace can talk to each other while communication across realms/namespaces is -not easily possible. Note that this is solely a management feature, not a security feature. All realms share the same -zenoh network and can thus talk to each other on this level. +To isolate groups of HERO objects from other groups the concept of realms exists in HEROS. +You can think of it as a namespace where objects in the same namespace can talk to each other while communication across realms/namespaces is +not easily possible. +Note that this is solely a management feature, not a security feature. +All realms share the same zenoh network and can thus talk to each other on this level. Objects ------- -An object that should be shared via HEROS must inherit from the class `LocalHero`. When python instantiates such -an object, it will parse the methods, class attributes, and events (see event decorator) and automatically generate -a list of capabilities that describes this HEROS object. The capabilities are announced and a liveliness token for the -object is created. HEROSOberserver in the network will thus be notified that our new object joined the realm. +An object that should be shared via HEROS must inherit from the class ``LocalHero``. +When python instantiates such an object, it will parse the methods, class attributes, and :doc:`events ` (see event decorator) and automatically generate a list of capabilities that describes this HEROS object. +The capabilities are announced and a liveliness token for the object is created. ``HEROSOberserver`` in the network will thus be notified that our new object joined the realm. -When the object is destroyed or the link gets lost, the liveliness token disappears and any remote object will notice -this. +When the object is destroyed or the link gets lost, the liveliness token disappears and any remote object will notice this. Capabilities ------------ @@ -33,12 +32,11 @@ A HEROS object is characterized by the capabilities it provides. There are curre Datasource ---------- -A frequent use case for HERO is that of making data (like sensor data or status data) available to interested peers. To cover this use -case, a special class of HERO exists, the DatasourceHERO. It provides a special event `new_data` that is always emitted when new data -is available. RemoteHEROs connect to the emitting HERO will get noticed directly and react accordingly. In addition also a DatasourceObsever class -exist, that efficiently monitors the events of many HEROs in the network without fully instanciating the RemoteHEROs. +A frequent use case for HERO is that of making data (like sensor data or status data) available to interested peers. +To cover this use case, a special class of HERO exists, the ``DatasourceHERO``. It provides a special event ``new_data`` that is always emitted when new data is available. RemoteHEROs connect to the emitting HERO will get noticed directly and react accordingly. +In addition also a ``DatasourceObsever`` class exist, that efficiently monitors the events of many HEROs in the network without fully instantiating the RemoteHEROs. Class Tree ---------- -This is not yet implemented. I will be a method to \ No newline at end of file +This is not yet implemented. diff --git a/docs/usage.rst b/docs/usage.rst index 2e36501..43a7d7e 100644 --- a/docs/usage.rst +++ b/docs/usage.rst @@ -1,19 +1,17 @@ Usage ##### -Building your first HERO is very simple. Just write your custom class and make it inherit from :code:`heros.LocalHero` +Building your first HERO is very simple. Just write your custom class and make it inherit from ``heros.LocalHero``. A simple example can be seen .. literalinclude:: ../examples/share_local_hero_simple.py :language: python -Due to the infinite loop, the script will not terminate and keep the object `obj` alive. Since it inherits from `heros.LocalHERO`, -the object was analyzed upon instantiation and provided as a HERO to the network. This means, we can now simply access the method -attributes of the object from a remote site. +Due to the infinite loop, the script will not terminate and keep the object ``obj`` alive. Since it inherits from ``heros.LocalHERO``, the object was analyzed upon instantiation and provided as a HERO to the network. +This means, we can now simply access the method attributes of the object from a remote site. -To get remote access we can run the following in a different process or on a different machine in the same network (UDP broadcast -needs to reach the other machine for discovery): +To get remote access we can run the following in a different process or on a different machine in the same network (UDP broadcast needs to reach the other machine for discovery): .. literalinclude:: ../examples/access_remote_hero_simple.py :language: python @@ -21,8 +19,10 @@ needs to reach the other machine for discovery): Nested HEROs ------------ -HEROS is able to serialize HEROs as references to a HERO. This allows to pass a HERO from between the local and the remote site. -When either of the sites receives such a reference, it creates a RemoteHERO to access the referenced HERO. This allows things like +HEROS is able to serialize HEROs as references to a HERO. +This allows to pass a HERO from between the local and the remote site. +When either of the sites receives such a reference, it creates a ``RemoteHERO`` to access the referenced HERO. +This allows things like 1. Deep access to HEROs nested inside of HEROs. 2. Passing HEROs as arguments into methods exposed by a HERO. @@ -32,12 +32,12 @@ When either of the sites receives such a reference, it creates a RemoteHERO to a Unserializable Objects ---------------------- -A HERO attribute or method might return an object that is not a HERO and can not be serialized. In that case, the returned object is -cached on the side of the LocalHERO and an identifier is sent to the remote side. The remote side can store the reference locally. -If the reference is sent back to the LocalHERO side, the corresponding object is taken from the cache and inserted into the request -instead of the reference. This allows to instruct the LocalHERO to do something with an object that cannot be transferred. This -allows, for example, to hand over an unserializable object retrieved earlier as argument to a function. +A HERO attribute or method might return an object that is not a HERO and can not be serialized. +In that case, the returned object is cached on the side of the ``LocalHERO`` and an identifier is sent to the remote side. The remote side can store the reference locally. +If the reference is sent back to the ``LocalHERO`` side, the corresponding object is taken from the cache and inserted into the request instead of the reference. +This allows to instruct the LocalHERO to do something with an object that cannot be transferred. +This allows, for example, to hand over an unserializable object retrieved earlier as argument to a function. .. note:: - The cache that keeps the object references uses only weak references to to avoid memory leaks. That means that an object can be garbage - collected if not any other instance keeps a reference on it. \ No newline at end of file + The cache that keeps the object references uses only weak references to to avoid memory leaks. + That means that an object can be garbage collected if not any other instance keeps a reference on it. -- GitLab From 18c5059deeffa595c603b15d66bdd18640b9062c Mon Sep 17 00:00:00 2001 From: Suthep Pomjaksilp Date: Wed, 26 Mar 2025 14:41:09 +0100 Subject: [PATCH 13/20] Change apiref to autoapi --- docs/apiref.rst | 25 ------------------------- docs/conf.py | 21 ++++++++++++++++++--- docs/index.rst | 1 - pyproject.toml | 8 ++++++++ 4 files changed, 26 insertions(+), 29 deletions(-) delete mode 100644 docs/apiref.rst diff --git a/docs/apiref.rst b/docs/apiref.rst deleted file mode 100644 index 0d0ae25..0000000 --- a/docs/apiref.rst +++ /dev/null @@ -1,25 +0,0 @@ -API Reference -############# - -This is reference for the API of HEROS. - -.. autoclass:: heros.HEROPeer - -.. autoclass:: heros.HERO - -.. autoclass:: heros.LocalHERO - - -.. autoclass:: heros.RemoteHERO - - -.. autoclass:: heros.HEROObserver - - -.. autoclass:: heros.LocalDatasourceHERO - - -.. autoclass:: heros.PolledLocalDatasourceHERO - - -.. autoclass:: heros.DatasourceObserver \ No newline at end of file diff --git a/docs/conf.py b/docs/conf.py index 82e9f35..0457eb5 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -13,10 +13,15 @@ author = 'Thomas Niederprüm' # -- General configuration --------------------------------------------------- # https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration +# -- General configuration --------------------------------------------------- extensions = [ - "sphinx.ext.autodoc", - "sphinx.ext.autosummary", - "sphinx.ext.napoleon", + # 'sphinx.ext.autodoc', # Include docstrings in the documentation + 'autoapi.extension', + 'sphinx.ext.autosummary', + 'sphinx.ext.napoleon', # Support for Google and NumPy style docstrings + 'sphinx.ext.viewcode', # Add links to source code + 'sphinx.ext.todo', # Enable todo lists + 'sphinx_autodoc_typehints', # Handle type hints in documentation ] templates_path = ['_templates'] @@ -34,3 +39,13 @@ html_theme_options = { "dark_logo": "heros_logo.svg", "sidebar_hide_name": False, } + +# Autodoc settings +autoclass_content = "both" +# -- AutoAPI configuration --------------------------------------------------- +autoapi_type = 'python' +autoapi_dirs = ['../src'] # Path to your source code +autoapi_add_toctree_entry = True # Avoid duplicate toctree entries +autoapi_keep_files = False # Keep intermediate reStructuredText files +# todo conf +todo_include_todos = True diff --git a/docs/index.rst b/docs/index.rst index 8bc1430..622fae0 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -31,5 +31,4 @@ The working principle is shown in the following. paradigms installation usage - apiref diff --git a/pyproject.toml b/pyproject.toml index 8311aa8..e9db6ac 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,6 +39,14 @@ classifiers = [ "Programming Language :: Python :: 3 :: Only" ] +[project.optional-dependencies] +dev = [ + "sphinx>=8", + "sphinx-autoapi>=3", + "sphinx-autodoc-typehints>=3", + "furo>=2024", +] + [project.urls] Homepage = "https://gitlab.com/atomiq-project/heros" Repository = "https://gitlab.com/atomiq-project/heros" -- GitLab From a71b509b067c58f2bdb6ad5536f19828cbfcec7f Mon Sep 17 00:00:00 2001 From: Suthep Pomjaksilp Date: Wed, 26 Mar 2025 15:05:14 +0100 Subject: [PATCH 14/20] Cleanup docstrings --- src/heros/capabilities.py | 17 ++++++------- src/heros/datasource/datasource.py | 25 ++++++++++--------- src/heros/datasource/observables.py | 10 ++++---- src/heros/datasource/types.py | 11 +++++---- src/heros/helper.py | 3 ++- src/heros/heros.py | 37 +++++++++++++++-------------- src/heros/serdes.py | 10 ++++---- src/heros/zenoh.py | 4 ++-- 8 files changed, 60 insertions(+), 57 deletions(-) diff --git a/src/heros/capabilities.py b/src/heros/capabilities.py index 679a386..ca95044 100644 --- a/src/heros/capabilities.py +++ b/src/heros/capabilities.py @@ -88,8 +88,8 @@ class Capability: @dataclass class AttributeCapability(Capability): """ - An attribute capability describes a single variable of the remote object that is exposed - under the name of the capability. + An attribute capability describes a single variable of the remote object. + It is exposed under the name of the capability. Args: name: name of the capability @@ -100,13 +100,13 @@ class AttributeCapability(Capability): type: str access: str = "rw" - def to_dict(self): + def to_dict(self) -> dict: d = Capability.to_dict(self) d.update({"type": self.type, "access": self.access}) return d @staticmethod - def from_dict(d: dict): + def from_dict(d: dict) -> "AttributeCapability": if "name" not in d: raise AttributeError("required field 'type' not in dict") return AttributeCapability(name=d["name"], type=d["type"], access=d["access"]) @@ -116,14 +116,11 @@ class AttributeCapability(Capability): class EventCapability(Capability): """ An event capability describes the ability of a remote object to notify upon a certain event. - - Args: - """ flavor: ClassVar[str] = "event" @staticmethod - def from_dict(d: dict): + def from_dict(d: dict) -> "EventCapability": return EventCapability(name=d["name"]) @@ -134,7 +131,7 @@ class MethodCapability(Capability): return_type: str = "None" @staticmethod - def from_method(m: Callable): + def from_method(m: Callable) -> "MethodCapability": sig = inspect.signature(m) cap = MethodCapability(name=m.__name__) @@ -161,7 +158,7 @@ class MethodCapability(Capability): return d @staticmethod - def from_dict(d: dict): + def from_dict(d: dict) -> "MethodCapability": """ Generate a method capabilities object from a defining dictionary. diff --git a/src/heros/datasource/datasource.py b/src/heros/datasource/datasource.py index e36b96b..bce8641 100644 --- a/src/heros/datasource/datasource.py +++ b/src/heros/datasource/datasource.py @@ -9,12 +9,13 @@ from .observables import ObservableProcessor class LocalDatasourceHERO(LocalHERO): """ - A datasource is a HERO that can provide information on a standardized interface. This interface is the event - `new_data`. Instances in the zenoh network interested in the data provided by data sources can simply subscribe - to the key expression `@objects/realm/*/new_data` or use the :class:RemoteDatasourceObserver(). + A datasource is a HERO that can provide information on a standardized interface. + This interface is the event `new_data`. Instances in the zenoh network interested in the data provided + by data sources can simply subscribe to the key expression `@objects/realm/*/new_data` or use + the :class:`DatasourceObserver`. To make your class a LocalDatasourceHERO make it inherit this class. - This class is ment for datasources that create asynchronous events on their own. When processing such an event + This class is meant for datasources that create asynchronous events on their own. When processing such an event call `new_data` to publish the data from this datasource. Args: @@ -36,11 +37,12 @@ class LocalDatasourceHERO(LocalHERO): class DatasourceObserver(HEROPeer): """ - A class that can observe and handle the data emitted by one or more datasource HEROs. In particular, this - class provides an efficient way to listen to the data emitted by all datasource HEROs in the realm. By - not instantiating the HEROs themselves but just subscribing to the topics for the datasource, this reduces the - pressure on the backing zenoh network. If, however, only the data of a few HEROs should be observed, it might - make more sense to just instantiate the according RemoteHEROs and connect a callback to their `new_data` signal. + A class that can observe and handle the data emitted by one or more datasource HEROs. + In particular, this class provides an efficient way to listen to the data emitted by all datasource HEROs in + the realm. By not instantiating the HEROs themselves but just subscribing to the topics for the datasource, this + reduces the pressure on the backing zenoh network. If, however, only the data of a few HEROs should be observed, + it might make more sense to just instantiate the according RemoteHEROs and connect a callback to their `new_data` + signal. Args: object_selector: selector to specify which objects to observe. This becomes part of a zenoh selector and thus @@ -87,8 +89,9 @@ class PolledLocalDatasourceHERO(LocalDatasourceHERO): To make your class a PolledLocalDatasourceHERO make it inherit this class an implement the method `_new_data`. The method will get called periodically and the return value will be published as an event. - Note: The periodic calling is realized via asyncio and will thus only work if the asyncio mainloop is - started. + Note: + The periodic calling is realized via asyncio and will thus only work if the asyncio mainloop is + started. Args: name: name/identifier under which the object is available. Make sure this name is unique in the realm. diff --git a/src/heros/datasource/observables.py b/src/heros/datasource/observables.py index 2cab505..d480022 100644 --- a/src/heros/datasource/observables.py +++ b/src/heros/datasource/observables.py @@ -4,8 +4,9 @@ from typing import Optional def lambdify(expression: str, globals_dict: dict = {}): """ - Turn a string into a lambda function with the given globals. By default it enables a set of numpy functions - in the expression. Further functions can be added by providing it in :param:`globals_dict`. + Turn a string into a lambda function with the given globals. + By default it enables a set of numpy functions in the expression. Further functions can be added by providing + it in ``globals_dict``. """ import numpy as np np_functions = ["sin", "cos", "tan", "arcsin", "arccos", "arctan", "hypot", "arctan2", "sinh", "cosh", "tanh", @@ -22,8 +23,7 @@ def lambdify(expression: str, globals_dict: dict = {}): class BoundaryChecker: """ Check whether the observed value is in it's boundaries. - Therefore provide an integer feedback with rising importance. - where boundary = 0 says that everything is fine. + Therefore provide an integer feedback with rising importance where boundary = 0 says that everything is fine. 1, 2, 3 correspond to higher warning / error or fault situations. """ @@ -80,7 +80,7 @@ class Observable: class ObservableProcessor: """ - An ObservableProcessor takes a configuration dict for multiple observables when it is instanciated. + An ObservableProcessor takes a configuration dict for multiple observables when it is instantiated. An object of this class can be called with a :class:DataSourceReturnSet as an argument. """ def __init__(self, obs_def: dict): diff --git a/src/heros/datasource/types.py b/src/heros/datasource/types.py index b689c28..fb43287 100644 --- a/src/heros/datasource/types.py +++ b/src/heros/datasource/types.py @@ -7,10 +7,11 @@ def ensure_string(x): class DatasourceReturnValue(dict): """ - A structure to store data returned from a single entity in a datasource. A datasource can return multiple - entities at once. In this case many DatasourceReturnValues are stored in a :class:DatasourceReturnSet. + A structure to store data returned from a single entity in a datasource. + A datasource can return multiple entities at once. In this case many DatasourceReturnValues are stored in + a :class:`DatasourceReturnSet`. - Default return values from datasource. They can be converted using a calibration. + Default return values from datasource. They can be converted using a calibration. :param raw_value: (float) :param raw_unit: (str[10]) :param time: (int) creation time of the rawValue. @@ -92,13 +93,13 @@ class DatasourceReturnValue(dict): class DatasourceReturnSet(tuple): """ - collection of multiple DatasourceReturnValues + Collection of multiple :class:`DatasourceReturnValue`. """ @staticmethod def from_data(data): """ - we try to build a DatasourceReturnSet by guessing the data format from the following options: + We try to build a DatasourceReturnSet by guessing the data format from the following options: * [FLOAT, FLOAT, ..] -> A list of raw_values * [(FLOAT, STR), (FLOAT, STR), ..] -> a list of (raw_value, raw_unit) tuples * {STR: FLOAT, STR: FLOAT, ..} -> a dict with id: raw_value diff --git a/src/heros/helper.py b/src/heros/helper.py index 46c1f7f..2a95bf9 100644 --- a/src/heros/helper.py +++ b/src/heros/helper.py @@ -10,7 +10,8 @@ def object_name_from_keyexpr(key_expr, ns_objects, realm, endpoint=".*"): def full_classname(o): - """ Return the fully qualified class name of an object + """ + Return the fully qualified class name of an object. Args: o: object diff --git a/src/heros/heros.py b/src/heros/heros.py index c4f208c..c85a830 100644 --- a/src/heros/heros.py +++ b/src/heros/heros.py @@ -11,10 +11,10 @@ from .helper import object_name_from_keyexpr, log class HEROPeer: """ - A HEROPeer provides the minimal interface to establish the HERO communication on top of the zenoh backend. To this - end, it provides methods to send cbor-serialized messages via the zenoh network. It establishes the `@object` - namespace and communicates in a defined realm. Methods to discover objects in the realm and to retrieve their - object information are provided. + A HEROPeer provides the minimal interface to establish the HERO communication on top of the zenoh backend. + To this end, it provides methods to send cbor-serialized messages via the zenoh network. It establishes + the `@object` namespace and communicates in a defined realm. Methods to discover objects in the realm and + to retrieve their object information are provided. Args: realm: Name of the realm that this HEROPeer belongs to. (default: heros) @@ -63,8 +63,8 @@ class HEROPeer: def _subscribe_selector(self, selector: str, callback: callable, *args, **kwargs): """ - Subscribe to a zenoh selector and a attach a callback that receives the deserialized payload of the messages - published. + Subscribe to a zenoh selector and a attach a callback. + The callback receives the deserialized payload of the messages published. Args: selector: zenoh selector for the subscription. See the zenoh documentation for valid descriptors. @@ -96,8 +96,8 @@ class HEROPeer: def _discover(self, timeout: float = 10.0) -> dict: """ - Send query to discovery endpoint of all HEROs in the current realm. All alive objects will respond and - send their remote object descriptor + Send query to discovery endpoint of all HEROs in the current realm. + All alive objects will respond and send their remote object descriptor. Args: timeout: timeout for the discover operation in seconds (default: 10) @@ -155,8 +155,8 @@ class HERO(HEROPeer): def _query_endpoint(self, endpoint: str, *args, **kwargs) -> list: """ - Send a query to an endpoint. This is a wrapper for _query_selector that enforces to talk to the endpoint - of the remote object. + Send a query to an endpoint. + This is a wrapper for _query_selector that enforces to talk to the endpoint of the remote object. Args: endpoint: endpoint within this HERO. If the given endpoint does not start with the endpoint base path, @@ -171,8 +171,8 @@ class HERO(HEROPeer): def _subscribe_endpoint(self, endpoint: str, callback: callable, *args, **kwargs): """ - Subscribe to an endpoint of this HERO. This is a wrapper for _subscribe_selector that enforces to talk to the - endpoint of the remote object. + Subscribe to an endpoint of this HERO. + This is a wrapper for _subscribe_selector that enforces to talk to the endpoint of the remote object. Args: endpoint: endpoint within this HERO. If the given endpoint does not start with the endpoint base path, @@ -188,8 +188,8 @@ class HERO(HEROPeer): class RemoteHERO(HERO): """ - Creates a local stub object from a remote HERO such that it seems like the remote object is a local - object. The remote HERO is identified by its name and has to be available at the given realm. + Creates a local stub object from a remote HERO such that it seems like the remote object is a local object. + The remote HERO is identified by its name and has to be available at the given realm. Attribute and method capabilities of the remote object are directly mapped to attributes and methods of the stub object, respectively. The signature of the methods is adapted accordingly. The remote attributes do not @@ -200,7 +200,8 @@ class RemoteHERO(HERO): To be able to attach attributes to this class, every instance of a `RemoteHERO` is created from a dynamically generated child class of `RemoteHERO` with the name `RemoteHERO__`. - Note: To discover which objects are available in a certain realm, see :class:HEROObserver. + Note: + To discover which objects are available in a certain realm, see :class:HEROObserver. Args: name: name/identifier of the remote object @@ -279,8 +280,8 @@ class RemoteHERO(HERO): class LocalHERO(HERO): """ - Base class for objects exposed through HEROS. Any object that should be able to be accessed remotely must be based - off this class. + Base class for objects exposed through HEROS. + Any object that should be able to be accessed remotely must be based off this class. Args: name: name/identifier under which the object is available. Make sure this name is unique in the realm. @@ -379,7 +380,7 @@ class LocalHERO(HERO): class HEROObserver(HEROPeer): """ A HEROObserver keeps track of the HEROs in a given realm by monitoring its zenoh liveliness tokens. - The member attribute `known_objects` always holds a list of all HEROs known to the observer. + The member attribute ``known_objects`` always holds a list of all HEROs known to the observer. Args: realm: Name of the realm that this HEROPeer belongs to. (default: heros) diff --git a/src/heros/serdes.py b/src/heros/serdes.py index 906aad7..cdb2af6 100644 --- a/src/heros/serdes.py +++ b/src/heros/serdes.py @@ -16,8 +16,7 @@ def is_builtin_class_instance(obj): class ObjectStore: """ - An object store that can be used to keep week references to objects identified by a serializable - identifier (int64) + An object store to be used to keep week references to objects identified by a serializable identifier (int64). """ _cache = {} @@ -37,8 +36,8 @@ class ObjectStore: def get(self, identifier: int) -> object: """ - Retrieve an object identified by :param:identifier from the store. If the object does not - exist any more, None is returned. + Retrieve an object identified by :param:identifier from the store. + If the object does not exist any more, None is returned. Args: identifier: the identifier obtained when storing the object @@ -75,7 +74,8 @@ class UnserializableRemoteObject: def cbor_default_encoder(encoder, value): - """Handle custom types in serialization + """ + Handle custom types in serialization. """ from heros import LocalHERO, RemoteHERO global obj_store diff --git a/src/heros/zenoh.py b/src/heros/zenoh.py index 7d0e90b..2e8934a 100644 --- a/src/heros/zenoh.py +++ b/src/heros/zenoh.py @@ -11,7 +11,7 @@ class ZenohSessionManager: def request_session(self, obj: object) -> zenoh.Session: """ - Request the global zenoh session + Request the global zenoh session. Args: obj: The object that requests the session @@ -31,7 +31,7 @@ class ZenohSessionManager: def release_session(self, obj: object): """ - Release from the global zenoh session + Release from the global zenoh session. Args: obj: The object that wants to release from the global zenoh session -- GitLab From 75019c1cdc0a148d44fc2fade36d706609ab00dc Mon Sep 17 00:00:00 2001 From: Suthep Pomjaksilp Date: Wed, 26 Mar 2025 15:11:39 +0100 Subject: [PATCH 15/20] Fix optional deps in pyproject.toml --- pyproject.toml | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index e9db6ac..49b6e46 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,14 +39,6 @@ classifiers = [ "Programming Language :: Python :: 3 :: Only" ] -[project.optional-dependencies] -dev = [ - "sphinx>=8", - "sphinx-autoapi>=3", - "sphinx-autodoc-typehints>=3", - "furo>=2024", -] - [project.urls] Homepage = "https://gitlab.com/atomiq-project/heros" Repository = "https://gitlab.com/atomiq-project/heros" @@ -56,5 +48,9 @@ Documentation = "https://gitlab.com/atomiq-project/heros/wiki" [project.optional-dependencies] dev = [ "pytest>=7.1", - "flake8>=6.0" + "flake8>=6.0", + "sphinx>=8", + "sphinx-autoapi>=3", + "sphinx-autodoc-typehints>=3", + "furo>=2024", ] -- GitLab From 333813f8f3b9bb3b27afde1b45b3525505b8056a Mon Sep 17 00:00:00 2001 From: Suthep Pomjaksilp Date: Wed, 26 Mar 2025 15:13:57 +0100 Subject: [PATCH 16/20] Fix principle drawing for dark mode enjoyers --- docs/_static/principle.svg | 347 +++++++++++++++++++------------------ 1 file changed, 177 insertions(+), 170 deletions(-) diff --git a/docs/_static/principle.svg b/docs/_static/principle.svg index c6df394..06184c6 100644 --- a/docs/_static/principle.svg +++ b/docs/_static/principle.svg @@ -2,9 +2,9 @@ LocalHEROLocalHERORemoteHERORemoteHEROcapabilitiescapabilitiesdiscoverdiscoverobjectobjectproxyobjectproxyobjectsignallingsignallingRPCRPCinspectinspectconstruct + y="-2.8168223" + id="text12-6" + transform="rotate(45)">construct -- GitLab From f3b1cf6d43c8dab4e8a5c2119b31e6d9741fb8fa Mon Sep 17 00:00:00 2001 From: Suthep Pomjaksilp Date: Wed, 26 Mar 2025 15:19:50 +0100 Subject: [PATCH 17/20] Add event.rst to docs index --- docs/index.rst | 5 ----- 1 file changed, 5 deletions(-) diff --git a/docs/index.rst b/docs/index.rst index 616423c..f949c51 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -31,9 +31,4 @@ The working principle is shown in the following. paradigms installation usage -<<<<<<< HEAD events - apiref -======= ->>>>>>> reformat_docs - -- GitLab From d5669cb573ce8f0dcc6af79f9f689a69be940d62 Mon Sep 17 00:00:00 2001 From: Suthep Pomjaksilp Date: Wed, 26 Mar 2025 15:21:31 +0100 Subject: [PATCH 18/20] Add SP as contributor and bump minor --- pyproject.toml | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 49b6e46..b5556bc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,16 +4,18 @@ build-backend = "hatchling.build" [project] name = "heros" -version = "0.3.0" +version = "0.4.0" description = "Make your objects available everywhere" readme = "README.md" license = {text = "LGPL-3.0-or-later"} keywords = ["remote object", "zenoh", "pub/sub", "rpc"] authors = [ - {name = "Thomas Niederprüm", email = "t.niederpruem@rptu.de"} + {name = "Thomas Niederprüm", email = "t.niederpruem@rptu.de"}, + {name = "Suthep Pomjaksilp", email = "s.pomjaksilp@rptu.de"}, ] maintainers = [ - {name = "Thomas Niederprüm", email = "t.niederpruem@rptu.de"} + {name = "Thomas Niederprüm", email = "t.niederpruem@rptu.de"}, + {name = "Suthep Pomjaksilp", email = "s.pomjaksilp@rptu.de"}, ] requires-python = ">=3.9" -- GitLab From 61ece9da9cfb9e3b36e8b59a0c6b54a5fd1a8985 Mon Sep 17 00:00:00 2001 From: Suthep Pomjaksilp Date: Wed, 26 Mar 2025 15:24:39 +0100 Subject: [PATCH 19/20] Make local hero callback helpers private --- src/heros/event.py | 6 +++--- src/heros/heros.py | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/heros/event.py b/src/heros/event.py index 4cd3bb3..6084932 100644 --- a/src/heros/event.py +++ b/src/heros/event.py @@ -282,7 +282,7 @@ class RemoteEventHandler(EventHandler): str: name of the callback. """ if is_hero_method(callback): - callback_name = self.instance.connect_local_hero_callback( + callback_name = self.instance._connect_local_hero_callback( event=self, remote_hero_method=callback, origin=self.instance._name @@ -305,7 +305,7 @@ class RemoteEventHandler(EventHandler): """ if is_hero_method(callback): log.debug(f"{self.__class__} disconnecting LocalEvent callback {self.__name__} -> {callback}") - return self.instance.disconnect_local_hero_callback(event=self, remote_hero_method=callback) + return self.instance._disconnect_local_hero_callback(event=self, remote_hero_method=callback) if callback in self.callbacks: log.debug(f"{self.__class__} disconnecting RemoteEvent callback {self.__name__} -> {callback}") return self.callbacks.remove(callback) @@ -330,7 +330,7 @@ class RemoteEventHandler(EventHandler): list: dictionary representation of all callbacks """ remote_event_cbs = [{**cb, "context": "RemoteHERO"} for cb in self.callbacks.get_callbacks()] - local_event_cbs = [{**cb, "context": "LocalHERO"} for cb in self.instance.get_local_hero_callbacks(event=self)] + local_event_cbs = [{**cb, "context": "LocalHERO"} for cb in self.instance._get_local_hero_callbacks(event=self)] return [*remote_event_cbs, *local_event_cbs] diff --git a/src/heros/heros.py b/src/heros/heros.py index a06036c..ec4243c 100644 --- a/src/heros/heros.py +++ b/src/heros/heros.py @@ -387,7 +387,7 @@ class LocalHERO(HERO): self.capabilities.append(cap) @force_publish - def connect_local_hero_callback(self, event: callable , remote_hero_method: callable, origin: str = None) -> str: + def _connect_local_hero_callback(self, event: callable , remote_hero_method: callable, origin: str = None) -> str: """ Connect a method of `RemoteHERO` as a callback to an event of the `LocalHERO`. This leads to a new, direct P2P connection between the `RemoteHERO` and the `LocalHERO` to call the method. @@ -403,7 +403,7 @@ class LocalHERO(HERO): return getattr(self, event.__name__).connect(remote_hero_method, origin) @force_publish - def disconnect_local_hero_callback(self, event: callable , remote_hero_method: callable) -> bool: + def _disconnect_local_hero_callback(self, event: callable , remote_hero_method: callable) -> bool: """ Disconnect a method of `RemoteHERO` from an event of the `LocalHERO`. @@ -417,7 +417,7 @@ class LocalHERO(HERO): return getattr(self, event.__name__).disconnect(remote_hero_method) @force_publish - def get_local_hero_callbacks(self, event: callable) -> list: + def _get_local_hero_callbacks(self, event: callable) -> list: """ Get a list of dictionary representations of the callbacks of an event of the `LocalHERO`. -- GitLab From ae6c8be6db932935579b0044c87c1fb02982fddc Mon Sep 17 00:00:00 2001 From: Suthep Pomjaksilp Date: Thu, 27 Mar 2025 10:06:59 +0100 Subject: [PATCH 20/20] Rename force_publish to force_remote --- src/heros/heros.py | 12 ++++++------ src/heros/inspect.py | 18 +++++++++--------- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/src/heros/heros.py b/src/heros/heros.py index ec4243c..a33859b 100644 --- a/src/heros/heros.py +++ b/src/heros/heros.py @@ -4,8 +4,8 @@ from functools import partial from .capabilities import Capability, MethodCapability, AttributeCapability, EventCapability from .event import RemoteEventDescriptor from .inspect import ( - is_hero_event, is_local_only, is_force_publish, is_hero_method, - local_only, force_publish, mark_hero_method + is_hero_event, is_local_only, is_force_remote, is_hero_method, + local_only, force_remote, mark_hero_method ) from heros import serdes from heros.zenoh import session_manager as default_session_manager @@ -336,7 +336,7 @@ class LocalHERO(HERO): member_name in dir(LocalHERO), is_local_only(getattr(self.__class__, member_name)) ]) - if exclude and (not is_force_publish(getattr(self.__class__, member_name))): + if exclude and (not is_force_remote(getattr(self.__class__, member_name))): continue if callable(getattr(self, member_name)): @@ -386,7 +386,7 @@ class LocalHERO(HERO): self._session.declare_queryable(f"{self._endpoint_base_path}/{member_name}", partial(wrapper, cap)) self.capabilities.append(cap) - @force_publish + @force_remote def _connect_local_hero_callback(self, event: callable , remote_hero_method: callable, origin: str = None) -> str: """ Connect a method of `RemoteHERO` as a callback to an event of the `LocalHERO`. @@ -402,7 +402,7 @@ class LocalHERO(HERO): """ return getattr(self, event.__name__).connect(remote_hero_method, origin) - @force_publish + @force_remote def _disconnect_local_hero_callback(self, event: callable , remote_hero_method: callable) -> bool: """ Disconnect a method of `RemoteHERO` from an event of the `LocalHERO`. @@ -416,7 +416,7 @@ class LocalHERO(HERO): """ return getattr(self, event.__name__).disconnect(remote_hero_method) - @force_publish + @force_remote def _get_local_hero_callbacks(self, event: callable) -> list: """ Get a list of dictionary representations of the callbacks of an event of the `LocalHERO`. diff --git a/src/heros/inspect.py b/src/heros/inspect.py index e0608d3..76e90be 100644 --- a/src/heros/inspect.py +++ b/src/heros/inspect.py @@ -1,7 +1,7 @@ HERO_EVENT_ATTRIBUTE = "hero_event" HERO_METHOD_ATTRIBUTE = "hero_method" LOCAL_ONLY_ATTRIBUTE = "local_only" -FORCE_PUBLISH_ATTRIBUTE = "force_publish" +FORCE_REMOTE_ATTRIBUTE = "force_remote" def _mark(func: callable, marker: str) -> callable: @@ -77,9 +77,9 @@ def mark_local_only(func: callable) -> callable: # rename mark_local_only to use as a decorator local_only = mark_local_only -def mark_force_publish(func: callable) -> callable: +def mark_force_remote(func: callable) -> callable: """ - Mark a callable as force publish. + Mark a callable as force remote. Args: func: callable to mark @@ -87,10 +87,10 @@ def mark_force_publish(func: callable) -> callable: Returns: The marked callable. """ - return _mark(func, FORCE_PUBLISH_ATTRIBUTE) + return _mark(func, FORCE_REMOTE_ATTRIBUTE) -# rename mark_force_publish to use as a decorator -force_publish = mark_force_publish +# rename mark_force_remote to use as a decorator +force_remote = mark_force_remote def is_hero_event(func: callable) -> bool: @@ -134,9 +134,9 @@ def is_local_only(func: callable) -> bool: """ return _is_marked(func, LOCAL_ONLY_ATTRIBUTE) -def is_force_publish(func: callable) -> bool: +def is_force_remote(func: callable) -> bool: """ - Check if a callable is a force publish. + Check if a callable is a force remote. Args: func: callable to check @@ -145,4 +145,4 @@ def is_force_publish(func: callable) -> bool: The value of the marker. `False` if the marker is not present. """ - return _is_marked(func, FORCE_PUBLISH_ATTRIBUTE) + return _is_marked(func, FORCE_REMOTE_ATTRIBUTE) -- GitLab