diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..344b6c59 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +*.ipynb export-ignore +*.ipynb linguist-vendored \ No newline at end of file diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml deleted file mode 100644 index 4ebf85e9..00000000 --- a/.gitlab-ci.yml +++ /dev/null @@ -1,9 +0,0 @@ -rules: - - if: '$CI_COMMIT_BRANCH == "main-next-release"' - -codestyle: - image: python:3.11 - stage: test - script: - - pip install uv - - uvx ruff check hololinked\core\thing.py diff --git a/CHANGELOG.md b/CHANGELOG.md index bd209417..0a2d8abf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,15 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ✓ means ready to try +## [v0.3.4] - 2025-10-02 + +- fixes a bug in content type in the forms of TD for HTTP protocol binding, when multiple serializers are used +- one can specify numpy array or arbitrary python objects in pydantic models for properties, actions and events + +## [v0.3.3] - 2025-09-25 + +- updates API reference largely to latest version + ## [v0.3.2] - 2025-09-21 - adds TD security definition for BCryptBasicSecurity and ArgsBasicSecurity diff --git a/README.md b/README.md index 93961303..29436587 100644 --- a/README.md +++ b/README.md @@ -313,7 +313,10 @@ if __name__ == '__main__': id='spectrometer', serial_number='S14155', ).run( - access_points=['HTTP', 'ZMQ-IPC'] + access_points=[ + ("ZMQ", "IPC"), + ("HTTP", 8080), + ] ) # HTTP & ZMQ Interprocess Communication ``` diff --git a/doc b/doc index 633d018d..afa0e647 160000 --- a/doc +++ b/doc @@ -1 +1 @@ -Subproject commit 633d018dabfa5c0557100bee9fcb1c1c3e364bdf +Subproject commit afa0e64706dedec5a15c9c46f465f2ac0f3258ec diff --git a/hololinked/client/http/consumed_interactions.py b/hololinked/client/http/consumed_interactions.py index 3e47c403..7933d8d7 100644 --- a/hololinked/client/http/consumed_interactions.py +++ b/hololinked/client/http/consumed_interactions.py @@ -49,7 +49,8 @@ def get_body_from_response( body = response.content if not body: return - serializer = Serializers.content_types.get(form.contentType or "application/json") + givenContentType = response.headers.get("Content-Type", None) + serializer = Serializers.content_types.get(givenContentType or form.contentType or "application/json") if serializer is None: raise ValueError(f"Unsupported content type: {form.contentType}") body = serializer.loads(body) diff --git a/hololinked/core/property.py b/hololinked/core/property.py index c1dbe129..fc906b15 100644 --- a/hololinked/core/property.py +++ b/hololinked/core/property.py @@ -329,7 +329,7 @@ def to_affordance(self, owner_inst=None): try: - from pydantic import BaseModel, RootModel, create_model + from pydantic import BaseModel, RootModel, create_model, ConfigDict def wrap_plain_types_in_rootmodel(model: type) -> type[BaseModel] | type[RootModel]: """ @@ -344,7 +344,12 @@ def wrap_plain_types_in_rootmodel(model: type) -> type[BaseModel] | type[RootMod return if issubklass(model, BaseModel): return model - return create_model(f"{model!r}", root=(model, ...), __base__=RootModel) + return create_model( + f"{model!r}", + root=(model, ...), + __base__=RootModel, + __config__=ConfigDict(arbitrary_types_allowed=True), + ) # type: ignore[call-overload] except ImportError: def wrap_plain_types_in_rootmodel(model: type) -> type: diff --git a/hololinked/core/thing.py b/hololinked/core/thing.py index 2b74813d..74ed55d3 100644 --- a/hololinked/core/thing.py +++ b/hololinked/core/thing.py @@ -349,9 +349,9 @@ def run( access_points = kwargs.get("access_points", None) # type: dict[str, dict | int | str | list[str]] servers = kwargs.get("servers", []) # type: typing.Optional[typing.List[BaseProtocolServer]] - if access_points is None and servers is None: + if access_points is None and len(servers) == 0: raise ValueError("At least one of access_points or servers must be provided.") - if access_points is not None and servers is not None: + if access_points is not None and len(servers) > 0: raise ValueError("Only one of access_points or servers can be provided.") if access_points is not None: diff --git a/hololinked/core/zmq/brokers.py b/hololinked/core/zmq/brokers.py index c266a0dc..6890e6cf 100644 --- a/hololinked/core/zmq/brokers.py +++ b/hololinked/core/zmq/brokers.py @@ -2228,7 +2228,7 @@ def interrupt_message(self) -> EventMessage: return EventMessage.craft_from_arguments( event_id=f"{self.id}/interrupting-server", sender_id=self.id, - payload=SerializableData("INTERRUPT"), + payload=SerializableData("INTERRUPT", content_type="application/json"), ) def exit(self): diff --git a/hololinked/core/zmq/rpc_server.py b/hololinked/core/zmq/rpc_server.py index 1757eb32..2fd4d36e 100644 --- a/hololinked/core/zmq/rpc_server.py +++ b/hololinked/core/zmq/rpc_server.py @@ -20,7 +20,7 @@ set_global_event_loop_policy, ) from ...config import global_config -from ...serializers import Serializers +from ...serializers import Serializers, BaseSerializer from .message import ( EMPTY_BYTE, ERROR, @@ -33,7 +33,7 @@ from ..thing import Thing from ..property import Property from ..properties import TypedDict -from ..actions import BoundAction, action as remote_method +from ..actions import BoundAction from ..logger import LogHistoryHandler @@ -377,67 +377,38 @@ async def run_thing_instance(self, instance: Thing, scheduler: typing.Optional[" return_value = await self.execute_operation(instance, objekt, operation, payload, preserialized_payload) # handle return value - if ( - isinstance(return_value, tuple) - and len(return_value) == 2 - and (isinstance(return_value[1], bytes) or isinstance(return_value[1], PreserializedData)) - ): - if fetch_execution_logs: - return_value[0] = { - "return_value": return_value[0], - "execution_logs": list_handler.log_list, - } - payload = SerializableData( - return_value[0], - Serializers.for_object(thing_id, instance.__class__.__name__, objekt), - ) - if isinstance(return_value[1], bytes): - preserialized_payload = PreserializedData(return_value[1]) - # elif isinstance(return_value, PreserializedData): - # if fetch_execution_logs: - # return_value = { - # "return_value" : return_value.value, - # "execution_logs" : list_handler.log_list - # } - # payload = SerializableData(return_value.value, content_type='application/json') - # preserialized_payload = return_value - - elif isinstance(return_value, bytes): - payload = SerializableData(None, content_type="application/json") - preserialized_payload = PreserializedData(return_value) - else: - # complete thing execution context - if fetch_execution_logs: - return_value = { - "return_value": return_value, - "execution_logs": list_handler.log_list, - } - payload = SerializableData( - return_value, - Serializers.for_object(thing_id, instance.__class__.__name__, objekt), - ) - preserialized_payload = PreserializedData(EMPTY_BYTE, content_type="text/plain") + serializer = Serializers.for_object(thing_id, instance.__class__.__name__, objekt) + rpayload, rpreserialized_payload = self.format_return_value(return_value, serializer=serializer) + + # complete thing execution context + if fetch_execution_logs: + rpayload.value = dict(return_value=rpayload.value, execution_logs=list_handler.log_list) + + # raise any payload errors now + rpayload.require_serialized() + # set reply - scheduler.last_operation_reply = (payload, preserialized_payload, REPLY) + scheduler.last_operation_reply = (rpayload, rpreserialized_payload, REPLY) + except BreakInnerLoop: # exit the loop and stop the thing instance.logger.info( - "Thing {} with instance name {} exiting event loop.".format( - instance.__class__.__name__, instance.id - ) + "Thing {} with id {} exiting event loop.".format(instance.__class__.__name__, instance.id) ) - return_value = None + + # send a reply with None return value + rpayload, rpreserialized_payload = self.format_return_value(None, Serializers.json) + + # complete thing execution context if fetch_execution_logs: - return_value = { - "return_value": None, - "execution_logs": list_handler.log_list, - } - scheduler.last_operation_reply = ( - SerializableData(return_value, content_type="application/json"), - PreserializedData(EMPTY_BYTE, content_type="text/plain"), - None, - ) - return + rpayload.value = dict(return_value=rpayload.value, execution_logs=list_handler.log_list) + + # set reply, let the message broker decide + scheduler.last_operation_reply = (rpayload, rpreserialized_payload, None) + + # quit the loop + break + except Exception as ex: # error occurred while executing the operation instance.logger.error( @@ -445,20 +416,25 @@ async def run_thing_instance(self, instance: Thing, scheduler: typing.Optional[" instance.__class__.__name__, instance.id, type(ex), ex ) ) - return_value = dict(exception=format_exception_as_json(ex)) - if fetch_execution_logs: - return_value["execution_logs"] = list_handler.log_list - scheduler.last_operation_reply = ( - SerializableData(return_value, content_type="application/json"), - PreserializedData(EMPTY_BYTE, content_type="text/plain"), - ERROR, + + # send a reply with error + rpayload, rpreserialized_payload = self.format_return_value( + dict(exception=format_exception_as_json(ex)), Serializers.json ) + + # complete thing execution context + if fetch_execution_logs: + rpayload.value["execution_logs"] = list_handler.log_list + + # set error reply + scheduler.last_operation_reply = (rpayload, rpreserialized_payload, ERROR) + finally: # cleanup if fetch_execution_logs: instance.logger.removeHandler(list_handler) instance.logger.debug( - "thing {} with instance name {} completed execution of operation {} on {}".format( + "thing {} with id {} completed execution of operation {} on {}".format( instance.__class__.__name__, instance.id, operation, objekt ) ) @@ -501,6 +477,12 @@ async def execute_operation( elif operation == Operations.deleteproperty: prop = instance.properties[objekt] # type: Property del prop # raises NotImplementedError when deletion is not implemented which is mostly the case + elif operation == Operations.invokeaction and objekt == "get_thing_description": + # special case + if payload is None: + payload = dict() + args = payload.pop("__args__", tuple()) + return self.get_thing_description(instance, *args, **payload) elif operation == Operations.invokeaction: if payload is None: payload = dict() @@ -508,10 +490,6 @@ async def execute_operation( # payload then become kwargs if preserialized_payload != EMPTY_BYTE: args = (preserialized_payload,) + args - # special case - if objekt == "get_thing_description": - return self.get_thing_description(instance, *args, **payload) - # normal Thing action action = instance.actions[objekt] # type: BoundAction if action.execution_info.iscoroutine: # the actual scheduling as a purely async task is done by the scheduler, not here, @@ -528,6 +506,30 @@ async def execute_operation( "Unimplemented execution path for Thing {} for operation {}".format(instance.id, operation) ) + def format_return_value( + self, + return_value: typing.Any, + serializer: BaseSerializer, + ) -> tuple[SerializableData, PreserializedData]: + if ( + isinstance(return_value, tuple) + and len(return_value) == 2 + and (isinstance(return_value[1], bytes) or isinstance(return_value[1], PreserializedData)) + ): + payload = SerializableData(return_value[0], serializer=serializer, content_type=serializer.content_type) + if isinstance(return_value[1], bytes): + preserialized_payload = PreserializedData(return_value[1]) + elif isinstance(return_value, bytes): + payload = SerializableData(None, content_type="application/json") + preserialized_payload = PreserializedData(return_value) + elif isinstance(return_value, PreserializedData): + payload = SerializableData(None, content_type="application/json") + preserialized_payload = return_value + else: + payload = SerializableData(return_value, serializer=serializer, content_type=serializer.content_type) + preserialized_payload = PreserializedData(EMPTY_BYTE, content_type="text/plain") + return payload, preserialized_payload + async def _process_timeouts( self, request_message: RequestMessage, diff --git a/hololinked/schema_validators/json_schema.py b/hololinked/schema_validators/json_schema.py index ee6ba58b..0a5c68ac 100644 --- a/hololinked/schema_validators/json_schema.py +++ b/hololinked/schema_validators/json_schema.py @@ -59,7 +59,7 @@ def register_type_replacement( self, type: typing.Any, json_schema_base_type: str, schema: typing.Optional[JSON] = None ) -> None: """ - Specify a python type to map to a specific JSON type. Schema only supported for array and objects. + Specify a python type to map to a specific JSON type. For example: - `JSONSchema.register_type_replacement(MyCustomObject, 'object', schema=MyCustomObject.schema())` @@ -87,7 +87,7 @@ def register_type_replacement( JSONSchema._schemas[type] = schema else: raise TypeError( - f"json schema replacement type must be one of allowed type - 'string', 'object', 'array', 'string', " + "json schema replacement type must be one of allowed type - 'string', 'object', 'array', 'string', " + f"'number', 'integer', 'boolean', 'null'. Given value {json_schema_base_type}" ) diff --git a/hololinked/serializers/payloads.py b/hololinked/serializers/payloads.py index 428a3774..ca98851e 100644 --- a/hololinked/serializers/payloads.py +++ b/hololinked/serializers/payloads.py @@ -15,9 +15,12 @@ class SerializableData: value: typing.Any serializer: BaseSerializer | None = None content_type: str = "application/json" + _serialized: bytes | None = None def serialize(self): """serialize the value""" + if self._serialized is not None: + return self._serialized if isinstance(self.value, byte_types): return self.value if self.serializer is not None: @@ -38,6 +41,10 @@ def deserialize(self): return serializer.loads(self.value) raise ValueError(f"content type {self.content_type} not supported for deserialization") + def require_serialized(self) -> None: + """ensure the value is serialized""" + self._serialized = self.serialize() + @dataclass class PreserializedData: @@ -47,4 +54,4 @@ class PreserializedData: """ value: bytes - content_type: str = "unknown" + content_type: str = "application/octet-stream" diff --git a/hololinked/serializers/serializers.py b/hololinked/serializers/serializers.py index 8aa46ed0..0c95c89c 100644 --- a/hololinked/serializers/serializers.py +++ b/hololinked/serializers/serializers.py @@ -27,6 +27,7 @@ import inspect import array import datetime +import io import uuid import decimal import typing @@ -220,11 +221,30 @@ def __init__(self) -> None: super().__init__() self.type = msgpack + codes = dict(NDARRAY_EXT=1) + def dumps(self, value) -> bytes: - return msgpack.encode(value) + return msgpack.encode(value, enc_hook=self.default_encode) def loads(self, value) -> typing.Any: - return msgpack.decode(self.convert_to_bytes(value)) + return msgpack.decode(self.convert_to_bytes(value), ext_hook=self.ext_decode) + + @classmethod + def default_encode(cls, obj) -> typing.Any: + if "numpy" in globals() and isinstance(obj, numpy.ndarray): + buf = io.BytesIO() + numpy.save(buf, obj, allow_pickle=False) # use .npy. which stores dtype, shape, order, endianness + return msgpack.Ext(MsgpackSerializer.codes["NDARRAY_EXT"], buf.getvalue()) + raise TypeError("Given type cannot be converted to MessagePack : {}".format(type(obj))) + + @classmethod + def ext_decode(cls, code: int, obj: memoryview) -> typing.Any: + if code == MsgpackSerializer.codes["NDARRAY_EXT"]: + if "numpy" in globals(): + return numpy.load(io.BytesIO(obj), allow_pickle=False) + else: + raise ValueError("numpy is required to decode numpy array from MessagePack") + return obj @property def content_type(self) -> str: @@ -379,7 +399,7 @@ class Serializers(metaclass=MappableSingleton): def register(cls, serializer: BaseSerializer, name: str | None = None, override: bool = False) -> None: """ Register a new serializer. It is recommended to implement a content type property/attribute for the serializer - to facilitate automatic deserialization on client side, otherwise deserialization is not gauranteed by this package. + to facilitate automatic deserialization on client side, otherwise deserialization is not gauranteed by this implementation. Parameters ---------- @@ -438,6 +458,39 @@ def for_object(cls, thing_id: str, thing_cls: str, objekt: str) -> BaseSerialize return cls.content_types[cls.object_content_type_map[thing].get(thing, cls.default_content_type)] return cls.default # JSON is default serializer + # @validate_call + @classmethod + def register_for_object(cls, objekt: typing.Any, serializer: BaseSerializer) -> None: + """ + Register (an existing) serializer for a property, action or event. Other option is to register a content type, + the effects are similar. + + Parameters + ---------- + objekt: str | Property | Action | Event + the property, action or event + serializer: BaseSerializer + the serializer to be used + """ + if not isinstance(serializer, BaseSerializer): + raise ValueError("serializer must be an instance of BaseSerializer, given : {}".format(type(serializer))) + from ..core import Property, Action, Event, Thing + + if not isinstance(objekt, (Property, Action, Event)) and not issubklass(objekt, Thing): + raise ValueError("object must be a Property, Action or Event, or Thing, got : {}".format(type(objekt))) + if issubklass(objekt, Thing): + owner = objekt.__name__ + elif not objekt.owner: + raise ValueError("object owner cannot be determined : {}".format(objekt)) + else: + owner = objekt.owner.__name__ + if owner not in cls.object_serializer_map: + cls.object_serializer_map[owner] = dict() + if issubklass(objekt, Thing): + cls.object_serializer_map[owner][objekt.__name__] = serializer + else: + cls.object_serializer_map[owner][objekt.name] = serializer + # @validate_call @classmethod def register_content_type_for_object(cls, objekt: typing.Any, content_type: str) -> None: @@ -523,39 +576,6 @@ def register_content_type_for_thing_instance(cls, thing_id: str, content_type: s cls.object_content_type_map[thing_id][thing_id] = content_type # remember, its a redundant key, TODO - # @validate_call - @classmethod - def register_for_object(cls, objekt: typing.Any, serializer: BaseSerializer) -> None: - """ - Register (an existing) serializer for a property, action or event. Other option is to register a content type, - the effects are similar. - - Parameters - ---------- - objekt: str | Property | Action | Event - the property, action or event - serializer: BaseSerializer - the serializer to be used - """ - if not isinstance(serializer, BaseSerializer): - raise ValueError("serializer must be an instance of BaseSerializer, given : {}".format(type(serializer))) - from ..core import Property, Action, Event, Thing - - if not isinstance(objekt, (Property, Action, Event)) and not issubklass(objekt, Thing): - raise ValueError("object must be a Property, Action or Event, or Thing, got : {}".format(type(objekt))) - if issubklass(objekt, Thing): - owner = objekt.__name__ - elif not objekt.owner: - raise ValueError("object owner cannot be determined : {}".format(objekt)) - else: - owner = objekt.owner.__name__ - if owner not in cls.object_serializer_map: - cls.object_serializer_map[owner] = dict() - if issubklass(objekt, Thing): - cls.object_serializer_map[owner][objekt.__name__] = serializer - else: - cls.object_serializer_map[owner][objekt.name] = serializer - @classmethod def register_for_object_per_thing_instance(cls, thing_id: str, objekt: str, serializer: BaseSerializer) -> None: """ diff --git a/hololinked/server/http/__init__.py b/hololinked/server/http/__init__.py index 1e397e95..1c236f64 100644 --- a/hololinked/server/http/__init__.py +++ b/hololinked/server/http/__init__.py @@ -4,6 +4,7 @@ import socket import ssl import typing +from copy import deepcopy from tornado import ioloop from tornado.web import Application from tornado.httpserver import HTTPServer as TornadoHTTP1Server @@ -73,6 +74,7 @@ class HTTPServer(Parameterized): ) # type: int address = IPAddress(default="0.0.0.0", doc="IP address") # type: str + # protocol_version = Selector(objects=[1, 1.1, 2], default=2, # doc="for HTTP 2, SSL is mandatory. HTTP2 is recommended. \ # When no SSL configurations are provided, defaults to 1.1" ) # type: float @@ -103,8 +105,8 @@ class HTTPServer(Parameterized): allowed_clients = TypedList( item_type=str, doc="""Serves request and sets CORS only from these clients, other clients are rejected with 403. - Unlike pure CORS, the server resource is not even executed if the client is not - an allowed client. if None any client is served.""", + Unlike pure CORS, the server resource is not even executed if the client is not + an allowed client. if None any client is served.""", ) host = String( @@ -112,6 +114,7 @@ class HTTPServer(Parameterized): allow_None=True, doc="Host Server to subscribe to coordinate starting sequence of remote objects & web GUI", ) # type: str + # network_interface = String(default='Ethernet', # doc="Currently there is no logic to detect the IP addresss (as externally visible) correctly, \ # therefore please send the network interface name to retrieve the IP. If a DNS server is present, \ @@ -121,21 +124,21 @@ class HTTPServer(Parameterized): default=PropertyHandler, class_=(PropertyHandler, RPCHandler), isinstance=False, - doc="custom web request handler of your choice for property read-write & action execution", + doc="custom web request handler for property read-write", ) # type: typing.Union[RPCHandler, PropertyHandler] action_handler = ClassSelector( default=ActionHandler, class_=(ActionHandler, RPCHandler), isinstance=False, - doc="custom web request handler of your choice for property read-write & action execution", + doc="custom web request handler for actions", ) # type: typing.Union[RPCHandler, ActionHandler] event_handler = ClassSelector( default=EventHandler, class_=(EventHandler, RPCHandler), isinstance=False, - doc="custom event handler of your choice for handling events", + doc="custom event handler for sending HTTP SSE", ) # type: typing.Union[RPCHandler, EventHandler] schema_validator = ClassSelector( @@ -157,7 +160,7 @@ class HTTPServer(Parameterized): default=None, allow_None=True, doc="""Set CORS headers for the HTTP server. If set to False, CORS headers are not set. - This is useful when the server is used in a controlled environment where CORS is not needed.""", + This is useful when the server is used in a controlled environment where CORS is not needed.""", ) # type: bool def __init__( @@ -696,6 +699,7 @@ def add_interaction_affordances( for property in properties: if property in self: continue + self.server.logger.debug(f"adding property {property.name} for thing id {property.thing_id}") if property.thing_id is not None: path = f"/{property.thing_id}/{pep8_to_dashed_name(property.name)}" else: @@ -720,6 +724,7 @@ def add_interaction_affordances( for action in actions: if action in self: continue + self.server.logger.debug(f"adding action {action.name} for thing id {action.thing_id}") name = get_alternate_name(action.name) if action.thing_id is not None: path = f"/{action.thing_id}/{pep8_to_dashed_name(name)}" @@ -729,17 +734,26 @@ def add_interaction_affordances( for event in events: if event in self: continue + self.server.logger.debug(f"adding event {event.name} for thing id {event.thing_id}") if event.thing_id is not None: path = f"/{event.thing_id}/{pep8_to_dashed_name(event.name)}" else: path = f"/{pep8_to_dashed_name(event.name)}" self.server.add_event(URL_path=path, event=event, handler=self.server.event_handler) + + get_thing_model_action = next((action for action in actions if action.name == "get_thing_model"), None) + get_thing_description_action = deepcopy(get_thing_model_action) + get_thing_description_action.override_defaults(name="get_thing_description") self.server.add_action( URL_path=f"/{thing_id}/resources/wot-td" if thing_id else "/resources/wot-td", - action=next((action for action in actions if action.name == "get_thing_model"), None), + action=get_thing_description_action, http_method=("GET",), handler=ThingDescriptionHandler, ) + self.server.logger.debug( + f"added thing description action for thing id {thing_id if thing_id else 'unknown'} at path " + + f"{f'/{thing_id}/resources/wot-td' if thing_id else '/resources/wot-td'}" + ) def add_thing_instance(self, thing: Thing) -> None: """ @@ -904,7 +918,7 @@ def print_rules(self) -> None: def get_alternate_name(interaction_affordance_name: str) -> str: if interaction_affordance_name == "get_thing_model": - return "resources/thing-model" + return "resources/wot-tm" return interaction_affordance_name diff --git a/hololinked/server/http/handlers.py b/hololinked/server/http/handlers.py index ebfd925f..e8658403 100644 --- a/hololinked/server/http/handlers.py +++ b/hololinked/server/http/handlers.py @@ -26,6 +26,7 @@ ActionAffordance, EventAffordance, ) +from ...td.forms import Form try: from ..security import BcryptBasicSecurity @@ -691,7 +692,13 @@ async def get(self): operation=Operations.invokeaction, payload=SerializableData( value=dict( - ignore_errors=body.get("ignore_errors", False), skip_names=body.get("skip_names", []) + ignore_errors=body.get("ignore_errors", False), + skip_names=body.get("skip_names", []), + protocol=self.zmq_client_pool[ + self.zmq_client_pool.get_client_id_from_thing_id(self.resource.thing_id) + ] + .socket_address.split("://")[0] + .upper(), ), ), ) @@ -713,85 +720,105 @@ async def get(self): def generate_td( self, TM: dict[str, JSONSerializable], authority: str = None, use_localhost: bool = False ) -> dict[str, JSONSerializable]: - from ...td.forms import Form - TD = copy.deepcopy(TM) # sanitize some things TD["id"] = f"{self.server.router.get_basepath(authority=authority, use_localhost=use_localhost)}/{TD['id']}" - # add forms + + self.add_properties(TD, TM, authority=authority, use_localhost=use_localhost) + self.add_actions(TD, TM, authority=authority, use_localhost=use_localhost) + self.add_events(TD, TM, authority=authority, use_localhost=use_localhost) + + self.add_security_definitions(TD) + return TD + + def add_properties( + self, TD: dict[str, JSONSerializable], TM: dict[str, JSONSerializable], authority: str, use_localhost: bool + ) -> dict[str, JSONSerializable]: for name in TM.get("properties", []): affordance = PropertyAffordance.from_TD(name, TM) href = self.server.router.get_href_for_affordance( affordance, authority=authority, use_localhost=use_localhost ) - if not TD["properties"][name].get("forms", None): - TD["properties"][name]["forms"] = [] + TD["properties"][name]["forms"] = [] http_methods = ( self.server.router.get_target_kwargs_for_affordance(affordance) .get("metadata", {}) .get("http_methods", []) ) for http_method in http_methods: - if affordance.readOnly and http_method.upper() != "GET": - break if http_method.upper() == "DELETE": # currently not in spec although we support it continue - form = Form() + if affordance.readOnly and http_method.upper() != "GET": + break + op = Operations.readproperty if http_method.upper() == "GET" else Operations.writeproperty + form = affordance.retrieve_form(op) + if not form: + form = Form() + form.op = op + form.contentType = Serializers.for_object(TD["id"], TD["title"], affordance.name).content_type form.href = href form.htv_methodName = http_method - form.op = Operations.readproperty if http_method.upper() == "GET" else Operations.writeproperty - form.contentType = "application/json" TD["properties"][name]["forms"].append(form.json()) if affordance.observable: - form = Form() + form = affordance.retrieve_form(Operations.observeproperty) + if not form: + form = Form() + form.contentType = Serializers.for_object(TD["id"], TD["title"], affordance.name).content_type + form.op = Operations.observeproperty form.href = f"{href}/change-event" form.htv_methodName = "GET" - form.contentType = "application/json" - form.op = Operations.observeproperty form.subprotocol = "sse" TD["properties"][name]["forms"].append(form.json()) + + def add_actions( + self, TD: dict[str, JSONSerializable], TM: dict[str, JSONSerializable], authority: str, use_localhost: bool + ) -> dict[str, JSONSerializable]: for name in TM.get("actions", []): affordance = ActionAffordance.from_TD(name, TM) href = self.server.router.get_href_for_affordance( affordance, authority=authority, use_localhost=use_localhost ) - if not TD["actions"][name].get("forms", None): - TD["actions"][name]["forms"] = [] + TD["actions"][name]["forms"] = [] http_methods = ( self.server.router.get_target_kwargs_for_affordance(affordance) .get("metadata", {}) .get("http_methods", []) ) for http_method in http_methods: - form = Form() + form = affordance.retrieve_form(Operations.invokeaction) + if not form: + form = Form() + form.op = Operations.invokeaction + form.contentType = Serializers.for_object(TD["id"], TD["title"], affordance.name).content_type form.href = href form.htv_methodName = http_method - form.op = Operations.invokeaction - form.contentType = "application/json" TD["actions"][name]["forms"].append(form.json()) + + def add_events( + self, TD: dict[str, JSONSerializable], TM: dict[str, JSONSerializable], authority: str, use_localhost: bool + ) -> dict[str, JSONSerializable]: for name in TM.get("events", []): affordance = EventAffordance.from_TD(name, TM) href = self.server.router.get_href_for_affordance( affordance, authority=authority, use_localhost=use_localhost ) - if not TD["events"][name].get("forms", None): - TD["events"][name]["forms"] = [] + TD["events"][name]["forms"] = [] http_methods = ( self.server.router.get_target_kwargs_for_affordance(affordance) .get("metadata", dict(http_methods=["GET"])) .get("http_methods", ["GET"]) ) for http_method in http_methods: - form = Form() + form = affordance.retrieve_form(Operations.subscribeevent) + if not form: + form = Form() + form.op = Operations.subscribeevent + form.contentType = Serializers.for_object(TD["id"], TD["title"], affordance.name).content_type form.href = href form.htv_methodName = http_method - form.op = Operations.subscribeevent - form.contentType = "application/json" form.subprotocol = "sse" TD["events"][name]["forms"].append(form.json()) - self.add_security_definitions(TD) - return TD def add_security_definitions(self, TD: dict[str, JSONSerializable]) -> None: from ...td.security_definitions import SecurityScheme diff --git a/hololinked/td/interaction_affordance.py b/hololinked/td/interaction_affordance.py index 5e474259..d7521474 100644 --- a/hololinked/td/interaction_affordance.py +++ b/hololinked/td/interaction_affordance.py @@ -1,4 +1,5 @@ import typing +import copy from enum import Enum from typing import ClassVar, Optional from pydantic import ConfigDict @@ -137,6 +138,30 @@ def retrieve_form(self, op: str, default: typing.Any = None) -> Form: return form return default + def pop_form(self, op: str, default: typing.Any = None) -> Form: + """ + retrieve and remove form for a certain operation, return default if not found + + Parameters + ---------- + op: str + operation for which the form is to be retrieved + default: typing.Any, optional + default value to return if form is not found, by default None. + One can make use of a sensible default value for one's logic. + + Returns + ------- + Dict[str, typing.Any] + JSON representation of the form + """ + if self.forms is None: + return default + for i, form in enumerate(self.forms): + if form.op == op: + return self.forms.pop(i) + return default + @classmethod def generate( cls, interaction: Property | Action | Event, owner: Thing @@ -215,6 +240,29 @@ def register_descriptor( ) InteractionAffordance._custom_schema_generators[descriptor] = schema_generator + def build_non_compliant_metadata(self) -> None: + """ + If by chance, there is additional non standard metadata to be added (i.e. also that is outside LD, or + may not even be within the TD), they can be added here. + """ + pass + + def override_defaults(self, **kwargs): + """ + Override default values with provided keyword arguments, especially thing_id, owner name, object name etc. + """ + for key, value in kwargs.items(): + if key == "name": + self._name = value + elif key == "thing_id": + self._thing_id = value + elif key == "owner": + self._owner = value + elif key == "thing_cls": + self._thing_cls = value + elif hasattr(self, key) or key in self.model_fields: + setattr(self, key, value) + def __hash__(self): return hash(self.thing_id + "" if not self.thing_cls else self.thing_cls.__name__ + self.name) @@ -238,28 +286,29 @@ def __eq__(self, value): return False return self.thing_id == value.thing_id and self.name == value.name - def build_non_compliant_metadata(self) -> None: - """ - If by chance, there is additional non standard metadata to be added (i.e. also that is outside LD, or - may not even be within the TD), they can be added here. - """ - pass - - def override_defaults(self, **kwargs): - """ - Override default values with provided keyword arguments, especially thing_id, owner name, object name etc. - """ - for key, value in kwargs.items(): - if key == "name": - self._name = value - elif key == "thing_id": - self._thing_id = value - elif key == "owner": - self._owner = value - elif key == "thing_cls": - self._thing_cls = value - elif hasattr(self, key) or key in self.model_fields: - setattr(self, key, value) + def __deepcopy__(self, memo): + if self.__class__ == PropertyAffordance: + result = PropertyAffordance() + elif self.__class__ == ActionAffordance: + result = ActionAffordance() + elif self.__class__ == EventAffordance: + result = EventAffordance() + memo[id(self)] = result + for k, v in self.__dict__.items(): + if k not in ("_owner", "_thing_cls", "_objekt"): + setattr(result, k, copy.deepcopy(v, memo)) + return result + + def __getstate__(self): + state = self.__dict__.copy() + # Remove possibly unpicklable entries + if "_owner" in state: + del state["_owner"] + if "_thing_cls" in state: + del state["_thing_cls"] + if "_objekt" in state: + del state["_objekt"] + return state class PropertyAffordance(DataSchema, InteractionAffordance): diff --git a/hololinked/utils.py b/hololinked/utils.py index 3dde9f04..b0efc349 100644 --- a/hololinked/utils.py +++ b/hololinked/utils.py @@ -446,7 +446,7 @@ def get_input_model_from_signature( model = create_model( # type: ignore[call-overload] f"{func.__name__}_input", **fields, - __config__=ConfigDict(extra="forbid", strict=True), + __config__=ConfigDict(extra="forbid", strict=True, arbitrary_types_allowed=True), ) return model @@ -598,7 +598,10 @@ def get_all_sub_things_recusively(thing) -> typing.List: def forkable(func): - """Decorator to make a function forkable. This is useful for functions that need to be run in a separate thread.""" + """ + Decorator to make a function forkable. + This is useful for functions that need to be run in a separate thread. + """ @wraps(func) def wrapper(*args, **kwargs): diff --git a/tests/helper-scripts/design_scripty.ipynb b/tests/helper-scripts/client.ipynb similarity index 88% rename from tests/helper-scripts/design_scripty.ipynb rename to tests/helper-scripts/client.ipynb index b5624172..486ab129 100644 --- a/tests/helper-scripts/design_scripty.ipynb +++ b/tests/helper-scripts/client.ipynb @@ -8,6 +8,11 @@ "outputs": [], "source": [ "import importlib\n", + "import os\n", + "import sys\n", + "\n", + "sys.path.insert(0, os.path.abspath(os.path.join(os.getcwd(), \"../\")))\n", + "\n", "import hololinked\n", "import things \n", "\n", @@ -31,15 +36,15 @@ "name": "stdout", "output_type": "stream", "text": [ - "INFO - 2025-09-21T09:28:11:539 - example-test - initialialised Thing class TestThing with id example-test\n", - "INFO - 2025-09-21T09:28:11:543 - example-test - created socket type: ROUTER with address: inproc://example-test & identity: example-test and bound\n", - "INFO - 2025-09-21T09:28:11:547 - example-test - created socket type: PUB with address: inproc://example-test/event-publisher & identity: example-test/event-publisher and bound\n" + "INFO - 2025-10-04T16:54:24:934 - example-test - initialialised Thing class TestThing with id example-test\n", + "INFO - 2025-10-04T16:54:24:938 - example-test - created socket type: ROUTER with address: inproc://example-test & identity: example-test and bound\n", + "INFO - 2025-10-04T16:54:24:942 - example-test - created socket type: PUB with address: inproc://example-test/event-publisher & identity: example-test/event-publisher and bound\n" ] }, { "data": { "text/plain": [ - "" + "" ] }, "execution_count": 2, @@ -50,201 +55,29 @@ "name": "stdout", "output_type": "stream", "text": [ - "INFO - 2025-09-21T09:28:11:550 - example-test - created socket type: ROUTER with address: tcp://*:5558 & identity: example-test and bound\n", - "INFO - 2025-09-21T09:28:11:562 - example-test - created socket type: PUB with address: tcp://0.0.0.0:60000 & identity: example-test/event-publisher and bound\n", - "INFO - 2025-09-21T09:28:11:565 - example-test - created socket type: ROUTER with address: ipc://C:\\Users\\vvign\\AppData\\Local\\Temp\\hololinked\\example-test.ipc & identity: example-test and bound\n", - "INFO - 2025-09-21T09:28:11:568 - example-test - created socket type: PUB with address: ipc://C:\\Users\\vvign\\AppData\\Local\\Temp\\hololinked\\example-test\\event-publisher.ipc & identity: example-test/event-publisher and bound\n" + "INFO - 2025-10-04T16:54:24:944 - example-test - created socket type: ROUTER with address: tcp://*:5558 & identity: example-test and bound\n", + "INFO - 2025-10-04T16:54:24:946 - example-test - created socket type: PUB with address: tcp://*:5559 & identity: example-test/event-publisher and bound\n", + "INFO - 2025-10-04T16:54:24:950 - example-test - created socket type: ROUTER with address: ipc://C:\\Users\\vvign\\AppData\\Local\\Temp\\hololinked\\example-test.ipc & identity: example-test and bound\n" ] }, { "name": "stdout", "output_type": "stream", "text": [ - "INFO - 2025-09-21T09:28:11:570 - example-test - created socket type: SUB with address: inproc://example-test/event-publisher & identity: example-test/event-proxy and connected\n", - "INFO - 2025-09-21T09:28:11:627 - HTTPServer|0.0.0.0:8080 - created socket type: ROUTER with address: inproc://example-test & identity: 0.0.0.0:8080 and connected\n", - "INFO - 2025-09-21T09:28:11:630 - HTTPServer|0.0.0.0:8080 - sent Handshake to server 'example-test'\n", - "INFO - 2025-09-21T09:28:12:131 - HTTPServer|0.0.0.0:8080 - got no response for handshake\n", - "INFO - 2025-09-21T09:28:12:132 - HTTPServer|0.0.0.0:8080 - sent Handshake to server 'example-test'\n", - "INFO - 2025-09-21T09:28:12:646 - HTTPServer|0.0.0.0:8080 - got no response for handshake\n", - "INFO - 2025-09-21T09:28:12:647 - HTTPServer|0.0.0.0:8080 - sent Handshake to server 'example-test'\n", - "INFO - 2025-09-21T09:28:13:161 - HTTPServer|0.0.0.0:8080 - got no response for handshake\n", - "INFO - 2025-09-21T09:28:13:162 - HTTPServer|0.0.0.0:8080 - sent Handshake to server 'example-test'\n", - "INFO - 2025-09-21T09:28:13:664 - HTTPServer|0.0.0.0:8080 - got no response for handshake\n", - "INFO - 2025-09-21T09:28:13:664 - HTTPServer|0.0.0.0:8080 - sent Handshake to server 'example-test'\n", - "INFO - 2025-09-21T09:28:14:176 - HTTPServer|0.0.0.0:8080 - got no response for handshake\n", - "INFO - 2025-09-21T09:28:14:177 - HTTPServer|0.0.0.0:8080 - sent Handshake to server 'example-test'\n", - "INFO - 2025-09-21T09:28:14:681 - HTTPServer|0.0.0.0:8080 - got no response for handshake\n", - "INFO - 2025-09-21T09:28:14:682 - HTTPServer|0.0.0.0:8080 - sent Handshake to server 'example-test'\n", - "INFO - 2025-09-21T09:28:15:193 - HTTPServer|0.0.0.0:8080 - got no response for handshake\n", - "INFO - 2025-09-21T09:28:15:194 - HTTPServer|0.0.0.0:8080 - sent Handshake to server 'example-test'\n", - "INFO - 2025-09-21T09:28:15:697 - HTTPServer|0.0.0.0:8080 - got no response for handshake\n", - "INFO - 2025-09-21T09:28:15:698 - HTTPServer|0.0.0.0:8080 - sent Handshake to server 'example-test'\n", - "INFO - 2025-09-21T09:28:16:211 - HTTPServer|0.0.0.0:8080 - got no response for handshake\n", - "INFO - 2025-09-21T09:28:16:212 - HTTPServer|0.0.0.0:8080 - sent Handshake to server 'example-test'\n", - "INFO - 2025-09-21T09:28:16:717 - HTTPServer|0.0.0.0:8080 - got no response for handshake\n", - "INFO - 2025-09-21T09:28:16:717 - HTTPServer|0.0.0.0:8080 - sent Handshake to server 'example-test'\n", - "INFO - 2025-09-21T09:28:17:227 - HTTPServer|0.0.0.0:8080 - got no response for handshake\n", - "INFO - 2025-09-21T09:28:17:228 - HTTPServer|0.0.0.0:8080 - sent Handshake to server 'example-test'\n", - "INFO - 2025-09-21T09:28:17:734 - HTTPServer|0.0.0.0:8080 - got no response for handshake\n", - "INFO - 2025-09-21T09:28:17:734 - HTTPServer|0.0.0.0:8080 - sent Handshake to server 'example-test'\n", - "INFO - 2025-09-21T09:28:18:245 - HTTPServer|0.0.0.0:8080 - got no response for handshake\n", - "INFO - 2025-09-21T09:28:18:246 - HTTPServer|0.0.0.0:8080 - sent Handshake to server 'example-test'\n", - "INFO - 2025-09-21T09:28:18:751 - HTTPServer|0.0.0.0:8080 - got no response for handshake\n", - "INFO - 2025-09-21T09:28:18:752 - HTTPServer|0.0.0.0:8080 - sent Handshake to server 'example-test'\n", - "INFO - 2025-09-21T09:28:19:260 - HTTPServer|0.0.0.0:8080 - got no response for handshake\n", - "INFO - 2025-09-21T09:28:19:261 - HTTPServer|0.0.0.0:8080 - sent Handshake to server 'example-test'\n", - "INFO - 2025-09-21T09:28:19:767 - HTTPServer|0.0.0.0:8080 - got no response for handshake\n", - "INFO - 2025-09-21T09:28:19:768 - HTTPServer|0.0.0.0:8080 - sent Handshake to server 'example-test'\n", - "INFO - 2025-09-21T09:28:20:269 - HTTPServer|0.0.0.0:8080 - got no response for handshake\n", - "INFO - 2025-09-21T09:28:20:270 - HTTPServer|0.0.0.0:8080 - sent Handshake to server 'example-test'\n", - "INFO - 2025-09-21T09:28:20:779 - HTTPServer|0.0.0.0:8080 - got no response for handshake\n", - "INFO - 2025-09-21T09:28:20:780 - HTTPServer|0.0.0.0:8080 - sent Handshake to server 'example-test'\n", - "INFO - 2025-09-21T09:28:21:285 - HTTPServer|0.0.0.0:8080 - got no response for handshake\n", - "INFO - 2025-09-21T09:28:21:285 - HTTPServer|0.0.0.0:8080 - sent Handshake to server 'example-test'\n", - "INFO - 2025-09-21T09:28:21:798 - HTTPServer|0.0.0.0:8080 - got no response for handshake\n", - "INFO - 2025-09-21T09:28:21:799 - HTTPServer|0.0.0.0:8080 - sent Handshake to server 'example-test'\n", - "INFO - 2025-09-21T09:28:22:304 - HTTPServer|0.0.0.0:8080 - got no response for handshake\n", - "INFO - 2025-09-21T09:28:22:304 - HTTPServer|0.0.0.0:8080 - sent Handshake to server 'example-test'\n", - "INFO - 2025-09-21T09:28:22:817 - HTTPServer|0.0.0.0:8080 - got no response for handshake\n", - "INFO - 2025-09-21T09:28:22:818 - HTTPServer|0.0.0.0:8080 - sent Handshake to server 'example-test'\n", - "INFO - 2025-09-21T09:28:23:322 - HTTPServer|0.0.0.0:8080 - got no response for handshake\n", - "INFO - 2025-09-21T09:28:23:322 - HTTPServer|0.0.0.0:8080 - sent Handshake to server 'example-test'\n", - "INFO - 2025-09-21T09:28:23:835 - HTTPServer|0.0.0.0:8080 - got no response for handshake\n", - "INFO - 2025-09-21T09:28:23:836 - HTTPServer|0.0.0.0:8080 - sent Handshake to server 'example-test'\n", - "INFO - 2025-09-21T09:28:24:339 - HTTPServer|0.0.0.0:8080 - got no response for handshake\n", - "INFO - 2025-09-21T09:28:24:340 - HTTPServer|0.0.0.0:8080 - sent Handshake to server 'example-test'\n", - "INFO - 2025-09-21T09:28:24:851 - HTTPServer|0.0.0.0:8080 - got no response for handshake\n", - "INFO - 2025-09-21T09:28:24:852 - HTTPServer|0.0.0.0:8080 - sent Handshake to server 'example-test'\n", - "INFO - 2025-09-21T09:28:25:356 - HTTPServer|0.0.0.0:8080 - got no response for handshake\n", - "INFO - 2025-09-21T09:28:25:357 - HTTPServer|0.0.0.0:8080 - sent Handshake to server 'example-test'\n", - "INFO - 2025-09-21T09:28:25:867 - HTTPServer|0.0.0.0:8080 - got no response for handshake\n", - "INFO - 2025-09-21T09:28:25:867 - HTTPServer|0.0.0.0:8080 - sent Handshake to server 'example-test'\n", - "INFO - 2025-09-21T09:28:26:373 - HTTPServer|0.0.0.0:8080 - got no response for handshake\n", - "INFO - 2025-09-21T09:28:26:374 - HTTPServer|0.0.0.0:8080 - sent Handshake to server 'example-test'\n", - "INFO - 2025-09-21T09:28:26:885 - HTTPServer|0.0.0.0:8080 - got no response for handshake\n", - "INFO - 2025-09-21T09:28:26:886 - HTTPServer|0.0.0.0:8080 - sent Handshake to server 'example-test'\n", - "INFO - 2025-09-21T09:28:27:391 - HTTPServer|0.0.0.0:8080 - got no response for handshake\n", - "INFO - 2025-09-21T09:28:27:392 - HTTPServer|0.0.0.0:8080 - sent Handshake to server 'example-test'\n", - "INFO - 2025-09-21T09:28:27:905 - HTTPServer|0.0.0.0:8080 - got no response for handshake\n", - "INFO - 2025-09-21T09:28:27:906 - HTTPServer|0.0.0.0:8080 - sent Handshake to server 'example-test'\n", - "INFO - 2025-09-21T09:28:28:409 - HTTPServer|0.0.0.0:8080 - got no response for handshake\n", - "INFO - 2025-09-21T09:28:28:410 - HTTPServer|0.0.0.0:8080 - sent Handshake to server 'example-test'\n", - "INFO - 2025-09-21T09:28:28:920 - HTTPServer|0.0.0.0:8080 - got no response for handshake\n", - "INFO - 2025-09-21T09:28:28:920 - HTTPServer|0.0.0.0:8080 - sent Handshake to server 'example-test'\n", - "INFO - 2025-09-21T09:28:29:427 - HTTPServer|0.0.0.0:8080 - got no response for handshake\n", - "INFO - 2025-09-21T09:28:29:428 - HTTPServer|0.0.0.0:8080 - sent Handshake to server 'example-test'\n", - "INFO - 2025-09-21T09:28:29:940 - HTTPServer|0.0.0.0:8080 - got no response for handshake\n", - "INFO - 2025-09-21T09:28:29:940 - HTTPServer|0.0.0.0:8080 - sent Handshake to server 'example-test'\n", - "INFO - 2025-09-21T09:28:30:444 - HTTPServer|0.0.0.0:8080 - got no response for handshake\n", - "INFO - 2025-09-21T09:28:30:445 - HTTPServer|0.0.0.0:8080 - sent Handshake to server 'example-test'\n", - "INFO - 2025-09-21T09:28:30:956 - HTTPServer|0.0.0.0:8080 - got no response for handshake\n", - "INFO - 2025-09-21T09:28:30:956 - HTTPServer|0.0.0.0:8080 - sent Handshake to server 'example-test'\n", - "INFO - 2025-09-21T09:28:31:461 - HTTPServer|0.0.0.0:8080 - got no response for handshake\n", - "INFO - 2025-09-21T09:28:31:462 - HTTPServer|0.0.0.0:8080 - sent Handshake to server 'example-test'\n", - "INFO - 2025-09-21T09:28:31:974 - HTTPServer|0.0.0.0:8080 - got no response for handshake\n", - "INFO - 2025-09-21T09:28:31:975 - HTTPServer|0.0.0.0:8080 - sent Handshake to server 'example-test'\n", - "INFO - 2025-09-21T09:28:32:478 - HTTPServer|0.0.0.0:8080 - got no response for handshake\n", - "INFO - 2025-09-21T09:28:32:479 - HTTPServer|0.0.0.0:8080 - sent Handshake to server 'example-test'\n", - "INFO - 2025-09-21T09:28:32:991 - HTTPServer|0.0.0.0:8080 - got no response for handshake\n", - "INFO - 2025-09-21T09:28:32:992 - HTTPServer|0.0.0.0:8080 - sent Handshake to server 'example-test'\n", - "INFO - 2025-09-21T09:28:33:495 - HTTPServer|0.0.0.0:8080 - got no response for handshake\n", - "INFO - 2025-09-21T09:28:33:496 - HTTPServer|0.0.0.0:8080 - sent Handshake to server 'example-test'\n", - "INFO - 2025-09-21T09:28:34:009 - HTTPServer|0.0.0.0:8080 - got no response for handshake\n", - "INFO - 2025-09-21T09:28:34:010 - HTTPServer|0.0.0.0:8080 - sent Handshake to server 'example-test'\n", - "INFO - 2025-09-21T09:28:34:514 - HTTPServer|0.0.0.0:8080 - got no response for handshake\n", - "INFO - 2025-09-21T09:28:34:515 - HTTPServer|0.0.0.0:8080 - sent Handshake to server 'example-test'\n", - "INFO - 2025-09-21T09:28:35:025 - HTTPServer|0.0.0.0:8080 - got no response for handshake\n", - "INFO - 2025-09-21T09:28:35:025 - HTTPServer|0.0.0.0:8080 - sent Handshake to server 'example-test'\n", - "INFO - 2025-09-21T09:28:35:531 - HTTPServer|0.0.0.0:8080 - got no response for handshake\n", - "INFO - 2025-09-21T09:28:35:532 - HTTPServer|0.0.0.0:8080 - sent Handshake to server 'example-test'\n", - "INFO - 2025-09-21T09:28:36:048 - HTTPServer|0.0.0.0:8080 - got no response for handshake\n", - "INFO - 2025-09-21T09:28:36:049 - HTTPServer|0.0.0.0:8080 - sent Handshake to server 'example-test'\n", - "INFO - 2025-09-21T09:28:36:564 - HTTPServer|0.0.0.0:8080 - got no response for handshake\n", - "INFO - 2025-09-21T09:28:36:565 - HTTPServer|0.0.0.0:8080 - sent Handshake to server 'example-test'\n", - "INFO - 2025-09-21T09:28:37:066 - HTTPServer|0.0.0.0:8080 - got no response for handshake\n", - "INFO - 2025-09-21T09:28:37:067 - HTTPServer|0.0.0.0:8080 - sent Handshake to server 'example-test'\n", - "INFO - 2025-09-21T09:28:37:580 - HTTPServer|0.0.0.0:8080 - got no response for handshake\n", - "INFO - 2025-09-21T09:28:37:581 - HTTPServer|0.0.0.0:8080 - sent Handshake to server 'example-test'\n", - "INFO - 2025-09-21T09:28:38:083 - HTTPServer|0.0.0.0:8080 - got no response for handshake\n", - "INFO - 2025-09-21T09:28:38:084 - HTTPServer|0.0.0.0:8080 - sent Handshake to server 'example-test'\n", - "INFO - 2025-09-21T09:28:38:596 - HTTPServer|0.0.0.0:8080 - got no response for handshake\n", - "INFO - 2025-09-21T09:28:38:598 - HTTPServer|0.0.0.0:8080 - sent Handshake to server 'example-test'\n", - "INFO - 2025-09-21T09:28:39:101 - HTTPServer|0.0.0.0:8080 - got no response for handshake\n", - "INFO - 2025-09-21T09:28:39:102 - HTTPServer|0.0.0.0:8080 - sent Handshake to server 'example-test'\n", - "INFO - 2025-09-21T09:28:39:612 - HTTPServer|0.0.0.0:8080 - got no response for handshake\n", - "INFO - 2025-09-21T09:28:39:614 - HTTPServer|0.0.0.0:8080 - sent Handshake to server 'example-test'\n", - "INFO - 2025-09-21T09:28:40:119 - HTTPServer|0.0.0.0:8080 - got no response for handshake\n", - "INFO - 2025-09-21T09:28:40:120 - HTTPServer|0.0.0.0:8080 - sent Handshake to server 'example-test'\n", - "INFO - 2025-09-21T09:28:40:631 - HTTPServer|0.0.0.0:8080 - got no response for handshake\n", - "INFO - 2025-09-21T09:28:40:632 - HTTPServer|0.0.0.0:8080 - sent Handshake to server 'example-test'\n", - "INFO - 2025-09-21T09:28:41:135 - HTTPServer|0.0.0.0:8080 - got no response for handshake\n", - "INFO - 2025-09-21T09:28:41:136 - HTTPServer|0.0.0.0:8080 - sent Handshake to server 'example-test'\n", - "ERROR - 2025-09-21T09:28:41:635 - HTTPServer|0.0.0.0:8080 - could not connect to example-test using on server example-test with access_point INPROC. error: \n", - "INFO - 2025-09-21T09:28:41:636 - example-test - starting RPC server example-test\n", - "INFO - 2025-09-21T09:28:41:638 - example-test - starting thing executor loop in thread 21816 for ['example-test']\n", - "INFO - 2025-09-21T09:28:41:642 - example-test - starting to run operations on thing example-test of class TestThing\n", - "INFO - 2025-09-21T09:28:41:642 - example-test - starting external message listener thread\n", - "INFO - 2025-09-21T09:28:41:647 - HTTPServer|0.0.0.0:8080 - started webserver at 0.0.0.0:8080, ready to receive requests.\n", - "INFO - 2025-09-21T09:28:41:650 - HTTPServer|0.0.0.0:8080 - client polling started for sockets for []\n", - "INFO - 2025-09-21T09:28:41:654 - example-test - sent handshake to client '0.0.0.0:8080'\n", - "INFO - 2025-09-21T09:28:41:656 - example-test - sent handshake to client '0.0.0.0:8080'\n", - "INFO - 2025-09-21T09:28:41:657 - example-test - sent handshake to client '0.0.0.0:8080'\n", - "INFO - 2025-09-21T09:28:41:658 - example-test - sent handshake to client '0.0.0.0:8080'\n", - "INFO - 2025-09-21T09:28:41:659 - example-test - sent handshake to client '0.0.0.0:8080'\n", - "INFO - 2025-09-21T09:28:41:661 - example-test - sent handshake to client '0.0.0.0:8080'\n", - "INFO - 2025-09-21T09:28:41:662 - example-test - sent handshake to client '0.0.0.0:8080'\n", - "INFO - 2025-09-21T09:28:41:663 - example-test - sent handshake to client '0.0.0.0:8080'\n", - "INFO - 2025-09-21T09:28:41:664 - example-test - sent handshake to client '0.0.0.0:8080'\n", - "INFO - 2025-09-21T09:28:41:665 - example-test - sent handshake to client '0.0.0.0:8080'\n", - "INFO - 2025-09-21T09:28:41:666 - example-test - sent handshake to client '0.0.0.0:8080'\n", - "INFO - 2025-09-21T09:28:41:667 - example-test - sent handshake to client '0.0.0.0:8080'\n", - "INFO - 2025-09-21T09:28:41:668 - example-test - sent handshake to client '0.0.0.0:8080'\n", - "INFO - 2025-09-21T09:28:41:670 - example-test - sent handshake to client '0.0.0.0:8080'\n", - "INFO - 2025-09-21T09:28:41:671 - example-test - sent handshake to client '0.0.0.0:8080'\n", - "INFO - 2025-09-21T09:28:41:672 - example-test - sent handshake to client '0.0.0.0:8080'\n", - "INFO - 2025-09-21T09:28:41:674 - example-test - sent handshake to client '0.0.0.0:8080'\n", - "INFO - 2025-09-21T09:28:41:675 - example-test - sent handshake to client '0.0.0.0:8080'\n", - "INFO - 2025-09-21T09:28:41:676 - example-test - sent handshake to client '0.0.0.0:8080'\n", - "INFO - 2025-09-21T09:28:41:677 - example-test - sent handshake to client '0.0.0.0:8080'\n", - "INFO - 2025-09-21T09:28:41:678 - example-test - sent handshake to client '0.0.0.0:8080'\n", - "INFO - 2025-09-21T09:28:41:680 - example-test - sent handshake to client '0.0.0.0:8080'\n", - "INFO - 2025-09-21T09:28:41:680 - example-test - sent handshake to client '0.0.0.0:8080'\n", - "INFO - 2025-09-21T09:28:41:681 - example-test - sent handshake to client '0.0.0.0:8080'\n", - "INFO - 2025-09-21T09:28:41:682 - example-test - sent handshake to client '0.0.0.0:8080'\n", - "INFO - 2025-09-21T09:28:41:683 - example-test - sent handshake to client '0.0.0.0:8080'\n", - "INFO - 2025-09-21T09:28:41:684 - example-test - sent handshake to client '0.0.0.0:8080'\n", - "INFO - 2025-09-21T09:28:41:685 - example-test - sent handshake to client '0.0.0.0:8080'\n", - "INFO - 2025-09-21T09:28:41:686 - example-test - sent handshake to client '0.0.0.0:8080'\n", - "INFO - 2025-09-21T09:28:41:686 - example-test - sent handshake to client '0.0.0.0:8080'\n", - "INFO - 2025-09-21T09:28:41:687 - example-test - sent handshake to client '0.0.0.0:8080'\n", - "INFO - 2025-09-21T09:28:41:688 - example-test - sent handshake to client '0.0.0.0:8080'\n", - "INFO - 2025-09-21T09:28:41:690 - example-test - sent handshake to client '0.0.0.0:8080'\n", - "INFO - 2025-09-21T09:28:41:690 - example-test - sent handshake to client '0.0.0.0:8080'\n", - "INFO - 2025-09-21T09:28:41:691 - example-test - sent handshake to client '0.0.0.0:8080'\n", - "INFO - 2025-09-21T09:28:41:692 - example-test - sent handshake to client '0.0.0.0:8080'\n", - "INFO - 2025-09-21T09:28:41:692 - example-test - sent handshake to client '0.0.0.0:8080'\n", - "INFO - 2025-09-21T09:28:41:693 - example-test - sent handshake to client '0.0.0.0:8080'\n", - "INFO - 2025-09-21T09:28:41:693 - example-test - sent handshake to client '0.0.0.0:8080'\n", - "INFO - 2025-09-21T09:28:41:694 - example-test - sent handshake to client '0.0.0.0:8080'\n", - "INFO - 2025-09-21T09:28:41:695 - example-test - sent handshake to client '0.0.0.0:8080'\n", - "INFO - 2025-09-21T09:28:41:695 - example-test - sent handshake to client '0.0.0.0:8080'\n", - "INFO - 2025-09-21T09:28:41:696 - example-test - sent handshake to client '0.0.0.0:8080'\n", - "INFO - 2025-09-21T09:28:41:696 - example-test - sent handshake to client '0.0.0.0:8080'\n", - "INFO - 2025-09-21T09:28:41:697 - example-test - sent handshake to client '0.0.0.0:8080'\n", - "INFO - 2025-09-21T09:28:41:697 - example-test - sent handshake to client '0.0.0.0:8080'\n", - "INFO - 2025-09-21T09:28:41:698 - example-test - sent handshake to client '0.0.0.0:8080'\n", - "INFO - 2025-09-21T09:28:41:698 - example-test - sent handshake to client '0.0.0.0:8080'\n", - "INFO - 2025-09-21T09:28:41:700 - example-test - sent handshake to client '0.0.0.0:8080'\n", - "INFO - 2025-09-21T09:28:41:701 - example-test - sent handshake to client '0.0.0.0:8080'\n", - "INFO - 2025-09-21T09:28:41:702 - example-test - sent handshake to client '0.0.0.0:8080'\n", - "INFO - 2025-09-21T09:28:41:703 - example-test - sent handshake to client '0.0.0.0:8080'\n", - "INFO - 2025-09-21T09:28:41:704 - example-test - sent handshake to client '0.0.0.0:8080'\n", - "INFO - 2025-09-21T09:28:41:704 - example-test - sent handshake to client '0.0.0.0:8080'\n", - "INFO - 2025-09-21T09:28:41:705 - example-test - sent handshake to client '0.0.0.0:8080'\n", - "INFO - 2025-09-21T09:28:41:706 - example-test - sent handshake to client '0.0.0.0:8080'\n", - "INFO - 2025-09-21T09:28:41:707 - example-test - sent handshake to client '0.0.0.0:8080'\n", - "INFO - 2025-09-21T09:28:41:708 - example-test - sent handshake to client '0.0.0.0:8080'\n", - "INFO - 2025-09-21T09:28:41:709 - example-test - sent handshake to client '0.0.0.0:8080'\n" + "INFO - 2025-10-04T16:54:24:955 - example-test - created socket type: PUB with address: ipc://C:\\Users\\vvign\\AppData\\Local\\Temp\\hololinked\\example-test\\event-publisher.ipc & identity: example-test/event-publisher and bound\n", + "INFO - 2025-10-04T16:54:24:957 - example-test - created socket type: SUB with address: inproc://example-test/event-publisher & identity: example-test/event-proxy and connected\n", + "INFO - 2025-10-04T16:54:24:960 - example-test - starting RPC server example-test\n", + "INFO - 2025-10-04T16:54:24:962 - example-test - starting thing executor loop in thread 5956 for ['example-test']\n", + "INFO - 2025-10-04T16:54:24:965 - example-test - starting to run operations on thing example-test of class TestThing\n", + "INFO - 2025-10-04T16:54:24:965 - example-test - starting external message listener thread\n", + "INFO - 2025-10-04T16:54:25:132 - HTTPServer|0.0.0.0:8080 - created socket type: ROUTER with address: inproc://example-test & identity: 0.0.0.0:8080 and connected\n", + "INFO - 2025-10-04T16:54:25:136 - HTTPServer|0.0.0.0:8080 - sent Handshake to server 'example-test'\n", + "INFO - 2025-10-04T16:54:25:138 - example-test - sent handshake to client '0.0.0.0:8080'\n", + "INFO - 2025-10-04T16:54:25:138 - HTTPServer|0.0.0.0:8080 - client '0.0.0.0:8080' handshook with server 'example-test'\n", + "payload before deserialize SerializableData(value=b'{\"ignore_errors\":true,\"protocol\":\"INPROC\"}', serializer=None, content_type='application/json', _serialized=None)\n", + "ERROR - 2025-10-04T16:54:25:143 - example-test - Error while generating schema for base_property - WoT schema generator for this descriptor/property is not implemented. name base_property & type \n", + "INFO - 2025-10-04T16:54:25:160 - HTTPServer|0.0.0.0:8080 - started webserver at 0.0.0.0:8080, ready to receive requests.\n", + "INFO - 2025-10-04T16:54:25:160 - HTTPServer|0.0.0.0:8080 - client polling started for sockets for ['0.0.0.0:8080']\n" ] } ], @@ -2169,7 +2002,33 @@ }, { "cell_type": "code", - "execution_count": 3, + "execution_count": 2, + "id": "e51e2fb7", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "{'input': {'properties': {'array': {'items': {'type': 'number'},\n", + " 'type': 'array'}},\n", + " 'required': ['array'],\n", + " 'type': 'object'},\n", + " 'output': {'items': {'type': 'number'}, 'type': 'array'},\n", + " 'synchronous': True}" + ] + }, + "execution_count": 2, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "TestThing.numpy_action.to_affordance().json()" + ] + }, + { + "cell_type": "code", + "execution_count": 2, "id": "b67beb49", "metadata": {}, "outputs": [ @@ -2177,56 +2036,63 @@ "name": "stdout", "output_type": "stream", "text": [ - "INFO - 2025-09-21T09:39:11:186 - example-test|example-test|tcp://localhost:5558|2f1ecdef-432d-42af-bfe6-03496ab70046 - created socket type: ROUTER with address: tcp://localhost:5558 & identity: example-test|example-test|tcp://localhost:5558|2f1ecdef-432d-42af-bfe6-03496ab70046|sync and connected\n", - "INFO - 2025-09-21T09:39:11:188 - example-test|example-test|tcp://localhost:5558|2f1ecdef-432d-42af-bfe6-03496ab70046 - sent Handshake to server 'example-test'\n", - "INFO - 2025-09-21T09:39:11:703 - example-test|example-test|tcp://localhost:5558|2f1ecdef-432d-42af-bfe6-03496ab70046 - got no response for handshake\n", - "INFO - 2025-09-21T09:39:11:704 - example-test|example-test|tcp://localhost:5558|2f1ecdef-432d-42af-bfe6-03496ab70046 - sent Handshake to server 'example-test'\n", - "INFO - 2025-09-21T09:39:11:705 - example-test|example-test|tcp://localhost:5558|2f1ecdef-432d-42af-bfe6-03496ab70046 - client 'example-test|example-test|tcp://localhost:5558|2f1ecdef-432d-42af-bfe6-03496ab70046|sync' handshook with server 'example-test'\n", - "INFO - 2025-09-21T09:39:11:710 - example-test|example-test|tcp://localhost:5558|2f1ecdef-432d-42af-bfe6-03496ab70046 - created socket type: ROUTER with address: tcp://localhost:5558 & identity: example-test|example-test|tcp://localhost:5558|2f1ecdef-432d-42af-bfe6-03496ab70046|async and connected\n" + "INFO - 2025-10-04T17:01:27:554 - example-test-server|example-test|IPC|d0adc808-b7ed-4a9e-a97f-8bb3f2c04a0b - created socket type: ROUTER with address: ipc://C:\\Users\\vvign\\AppData\\Local\\Temp\\hololinked\\example-test-server.ipc & identity: example-test-server|example-test|IPC|d0adc808-b7ed-4a9e-a97f-8bb3f2c04a0b|sync and connected\n", + "INFO - 2025-10-04T17:01:27:555 - example-test-server|example-test|IPC|d0adc808-b7ed-4a9e-a97f-8bb3f2c04a0b - sent Handshake to server 'example-test-server'\n", + "INFO - 2025-10-04T17:01:27:556 - example-test-server|example-test|IPC|d0adc808-b7ed-4a9e-a97f-8bb3f2c04a0b - client 'example-test-server|example-test|IPC|d0adc808-b7ed-4a9e-a97f-8bb3f2c04a0b|sync' handshook with server 'example-test-server'\n", + "INFO - 2025-10-04T17:01:27:560 - example-test-server|example-test|IPC|d0adc808-b7ed-4a9e-a97f-8bb3f2c04a0b - created socket type: ROUTER with address: ipc://C:\\Users\\vvign\\AppData\\Local\\Temp\\hololinked\\example-test-server.ipc & identity: example-test-server|example-test|IPC|d0adc808-b7ed-4a9e-a97f-8bb3f2c04a0b|async and connected\n" ] }, { "data": { "text/plain": [ "{'context': ['https://www.w3.org/2022/wot/td/v1.1'],\n", - " 'id': 'example-test',\n", + " 'id': 'http://127.0.0.1:9000/example-test',\n", " 'title': 'TestThing',\n", + " 'description': 'A test thing with various API options for properties, actions and events that were collected from examples from real world implementations, testing, features offered etc. Add your own use case/snippets used in tests here as needed.',\n", " 'properties': {'db_commit_number_prop': {'description': 'A fully editable number property to check commits to db on write operations',\n", " 'default': 0,\n", " 'type': 'number',\n", - " 'forms': [{'href': 'tcp://LAPTOP-TUBBGPB8:5558',\n", + " 'forms': [{'href': 'http://127.0.0.1:9000/example-test/db-commit-number-prop',\n", " 'op': 'readproperty',\n", + " 'htv:methodName': 'GET',\n", " 'contentType': 'application/json'},\n", - " {'href': 'tcp://LAPTOP-TUBBGPB8:5558',\n", + " {'href': 'http://127.0.0.1:9000/example-test/db-commit-number-prop',\n", " 'op': 'writeproperty',\n", + " 'htv:methodName': 'PUT',\n", " 'contentType': 'application/json'}]},\n", " 'db_init_int_prop': {'description': 'An integer property to check initialization from db',\n", " 'default': 1,\n", " 'type': 'integer',\n", - " 'forms': [{'href': 'tcp://LAPTOP-TUBBGPB8:5558',\n", + " 'forms': [{'href': 'http://127.0.0.1:9000/example-test/db-init-int-prop',\n", " 'op': 'readproperty',\n", - " 'contentType': 'application/json'},\n", - " {'href': 'tcp://LAPTOP-TUBBGPB8:5558',\n", + " 'htv:methodName': 'GET',\n", + " 'contentType': 'application/x-pickle'},\n", + " {'href': 'http://127.0.0.1:9000/example-test/db-init-int-prop',\n", " 'op': 'writeproperty',\n", - " 'contentType': 'application/json'}]},\n", + " 'htv:methodName': 'PUT',\n", + " 'contentType': 'application/x-pickle'}]},\n", " 'db_persist_selector_prop': {'description': 'A selector property to check persistence to db on write operations',\n", " 'default': 'a',\n", " 'oneOf': [{'type': 'string'}, {'type': 'integer'}],\n", " 'enum': ['a', 'b', 'c', 1],\n", - " 'forms': [{'href': 'tcp://LAPTOP-TUBBGPB8:5558',\n", + " 'forms': [{'href': 'http://127.0.0.1:9000/example-test/db-persist-selector-prop',\n", " 'op': 'readproperty',\n", + " 'htv:methodName': 'GET',\n", " 'contentType': 'application/json'},\n", - " {'href': 'tcp://LAPTOP-TUBBGPB8:5558',\n", + " {'href': 'http://127.0.0.1:9000/example-test/db-persist-selector-prop',\n", " 'op': 'writeproperty',\n", + " 'htv:methodName': 'PUT',\n", " 'contentType': 'application/json'}]},\n", " 'deletable_class_prop': {'description': 'deletable class property with custom deleter',\n", " 'default': 100,\n", " 'type': 'number',\n", - " 'forms': [{'href': 'tcp://LAPTOP-TUBBGPB8:5558',\n", + " 'forms': [{'href': 'http://127.0.0.1:9000/example-test/deletable-class-prop',\n", " 'op': 'readproperty',\n", + " 'htv:methodName': 'GET',\n", " 'contentType': 'application/json'},\n", - " {'href': 'tcp://LAPTOP-TUBBGPB8:5558',\n", + " {'href': 'http://127.0.0.1:9000/example-test/deletable-class-prop',\n", " 'op': 'writeproperty',\n", + " 'htv:methodName': 'PUT',\n", " 'contentType': 'application/json'}]},\n", " 'int_prop': {'description': 'An integer property with step and bounds constraints to check RW',\n", " 'default': 5,\n", @@ -2234,11 +2100,13 @@ " 'minimum': 0,\n", " 'maximum': 100,\n", " 'multipleOf': 2,\n", - " 'forms': [{'href': 'tcp://LAPTOP-TUBBGPB8:5558',\n", + " 'forms': [{'href': 'http://127.0.0.1:9000/example-test/int-prop',\n", " 'op': 'readproperty',\n", + " 'htv:methodName': 'GET',\n", " 'contentType': 'application/json'},\n", - " {'href': 'tcp://LAPTOP-TUBBGPB8:5558',\n", + " {'href': 'http://127.0.0.1:9000/example-test/int-prop',\n", " 'op': 'writeproperty',\n", + " 'htv:methodName': 'PUT',\n", " 'contentType': 'application/json'}]},\n", " 'json_schema_prop': {'description': 'A property with a json schema to check RW',\n", " 'oneOf': [{'type': 'null'},\n", @@ -2246,62 +2114,87 @@ " 'minLength': 1,\n", " 'maxLength': 10,\n", " 'pattern': '^[a-z]+$'}],\n", - " 'forms': [{'href': 'tcp://LAPTOP-TUBBGPB8:5558',\n", + " 'forms': [{'href': 'http://127.0.0.1:9000/example-test/json-schema-prop',\n", " 'op': 'readproperty',\n", + " 'htv:methodName': 'GET',\n", " 'contentType': 'application/json'},\n", - " {'href': 'tcp://LAPTOP-TUBBGPB8:5558',\n", + " {'href': 'http://127.0.0.1:9000/example-test/json-schema-prop',\n", " 'op': 'writeproperty',\n", + " 'htv:methodName': 'PUT',\n", " 'contentType': 'application/json'}]},\n", " 'managed_class_prop': {'description': '(managed) class property with custom getter/setter',\n", " 'default': 0.0,\n", " 'type': 'number',\n", - " 'forms': [{'href': 'tcp://LAPTOP-TUBBGPB8:5558',\n", + " 'forms': [{'href': 'http://127.0.0.1:9000/example-test/managed-class-prop',\n", " 'op': 'readproperty',\n", + " 'htv:methodName': 'GET',\n", " 'contentType': 'application/json'},\n", - " {'href': 'tcp://LAPTOP-TUBBGPB8:5558',\n", + " {'href': 'http://127.0.0.1:9000/example-test/managed-class-prop',\n", " 'op': 'writeproperty',\n", + " 'htv:methodName': 'PUT',\n", " 'contentType': 'application/json'}]},\n", " 'not_a_class_prop': {'description': 'test property with class_member=False',\n", " 'default': 43,\n", " 'type': 'number',\n", - " 'forms': [{'href': 'tcp://LAPTOP-TUBBGPB8:5558',\n", + " 'forms': [{'href': 'http://127.0.0.1:9000/example-test/not-a-class-prop',\n", " 'op': 'readproperty',\n", + " 'htv:methodName': 'GET',\n", " 'contentType': 'application/json'},\n", - " {'href': 'tcp://LAPTOP-TUBBGPB8:5558',\n", + " {'href': 'http://127.0.0.1:9000/example-test/not-a-class-prop',\n", " 'op': 'writeproperty',\n", + " 'htv:methodName': 'PUT',\n", " 'contentType': 'application/json'}]},\n", " 'number_prop': {'description': 'A fully editable number property',\n", " 'default': 1,\n", " 'type': 'number',\n", - " 'forms': [{'href': 'tcp://LAPTOP-TUBBGPB8:5558',\n", + " 'forms': [{'href': 'http://127.0.0.1:9000/example-test/number-prop',\n", " 'op': 'readproperty',\n", + " 'htv:methodName': 'GET',\n", " 'contentType': 'application/json'},\n", - " {'href': 'tcp://LAPTOP-TUBBGPB8:5558',\n", + " {'href': 'http://127.0.0.1:9000/example-test/number-prop',\n", " 'op': 'writeproperty',\n", + " 'htv:methodName': 'PUT',\n", " 'contentType': 'application/json'}]},\n", + " 'numpy_array_prop': {'description': 'A property with a numpy array as value',\n", + " 'oneOf': [{'type': 'array'}, {'type': 'null'}],\n", + " 'forms': [{'href': 'http://127.0.0.1:9000/example-test/numpy-array-prop',\n", + " 'op': 'readproperty',\n", + " 'htv:methodName': 'GET',\n", + " 'contentType': 'application/msgpack'},\n", + " {'href': 'http://127.0.0.1:9000/example-test/numpy-array-prop',\n", + " 'op': 'writeproperty',\n", + " 'htv:methodName': 'PUT',\n", + " 'contentType': 'application/msgpack'}]},\n", " 'observable_list_prop': {'description': 'An observable list property to check observable events on write operations',\n", " 'oneOf': [{'type': 'array'}, {'type': 'null'}],\n", " 'observable': True,\n", - " 'forms': [{'href': 'tcp://LAPTOP-TUBBGPB8:5558',\n", + " 'forms': [{'href': 'http://127.0.0.1:9000/example-test/observable-list-prop',\n", " 'op': 'readproperty',\n", + " 'htv:methodName': 'GET',\n", " 'contentType': 'application/json'},\n", - " {'href': 'tcp://LAPTOP-TUBBGPB8:5558',\n", + " {'href': 'http://127.0.0.1:9000/example-test/observable-list-prop',\n", " 'op': 'writeproperty',\n", + " 'htv:methodName': 'PUT',\n", " 'contentType': 'application/json'},\n", - " {'href': 'tcp://LAPTOP-TUBBGPB8:60000',\n", + " {'href': 'http://127.0.0.1:9000/example-test/observable-list-prop/change-event',\n", " 'op': 'observeproperty',\n", - " 'contentType': 'application/json'}]},\n", + " 'htv:methodName': 'GET',\n", + " 'contentType': 'application/json',\n", + " 'subprotocol': 'sse'}]},\n", " 'observable_readonly_prop': {'description': 'An observable readonly property to check observable events on read operations',\n", " 'default': 0,\n", " 'readOnly': True,\n", " 'type': 'number',\n", " 'observable': True,\n", - " 'forms': [{'href': 'tcp://LAPTOP-TUBBGPB8:5558',\n", + " 'forms': [{'href': 'http://127.0.0.1:9000/example-test/observable-readonly-prop',\n", " 'op': 'readproperty',\n", + " 'htv:methodName': 'GET',\n", " 'contentType': 'application/json'},\n", - " {'href': 'tcp://LAPTOP-TUBBGPB8:60000',\n", + " {'href': 'http://127.0.0.1:9000/example-test/observable-readonly-prop/change-event',\n", " 'op': 'observeproperty',\n", - " 'contentType': 'application/json'}]},\n", + " 'htv:methodName': 'GET',\n", + " 'contentType': 'application/json',\n", + " 'subprotocol': 'sse'}]},\n", " 'pydantic_prop': {'description': 'A property with a pydantic model to check RW',\n", " 'oneOf': [{'type': 'null'},\n", " {'properties': {'foo': {'type': 'string'},\n", @@ -2309,80 +2202,97 @@ " 'foo_bar': {'type': 'number'}},\n", " 'required': ['foo', 'bar', 'foo_bar'],\n", " 'type': 'object'}],\n", - " 'forms': [{'href': 'tcp://LAPTOP-TUBBGPB8:5558',\n", + " 'forms': [{'href': 'http://127.0.0.1:9000/example-test/pydantic-prop',\n", " 'op': 'readproperty',\n", + " 'htv:methodName': 'GET',\n", " 'contentType': 'application/json'},\n", - " {'href': 'tcp://LAPTOP-TUBBGPB8:5558',\n", + " {'href': 'http://127.0.0.1:9000/example-test/pydantic-prop',\n", " 'op': 'writeproperty',\n", + " 'htv:methodName': 'PUT',\n", " 'contentType': 'application/json'}]},\n", " 'pydantic_simple_prop': {'description': 'A property with a simple pydantic model to check RW',\n", " 'oneOf': [{'type': 'null'}, {'type': 'integer'}],\n", - " 'forms': [{'href': 'tcp://LAPTOP-TUBBGPB8:5558',\n", + " 'forms': [{'href': 'http://127.0.0.1:9000/example-test/pydantic-simple-prop',\n", " 'op': 'readproperty',\n", + " 'htv:methodName': 'GET',\n", " 'contentType': 'application/json'},\n", - " {'href': 'tcp://LAPTOP-TUBBGPB8:5558',\n", + " {'href': 'http://127.0.0.1:9000/example-test/pydantic-simple-prop',\n", " 'op': 'writeproperty',\n", + " 'htv:methodName': 'PUT',\n", " 'contentType': 'application/json'}]},\n", " 'readonly_class_prop': {'description': 'read-only class property',\n", " 'default': '',\n", " 'readOnly': True,\n", " 'type': 'string',\n", - " 'forms': [{'href': 'tcp://LAPTOP-TUBBGPB8:5558',\n", + " 'forms': [{'href': 'http://127.0.0.1:9000/example-test/readonly-class-prop',\n", " 'op': 'readproperty',\n", + " 'htv:methodName': 'GET',\n", " 'contentType': 'application/json'}]},\n", " 'selector_prop': {'description': 'A selector property to check RW',\n", " 'default': 'a',\n", " 'oneOf': [{'type': 'string'}, {'type': 'integer'}],\n", " 'enum': ['a', 'b', 'c', 1],\n", - " 'forms': [{'href': 'tcp://LAPTOP-TUBBGPB8:5558',\n", + " 'forms': [{'href': 'http://127.0.0.1:9000/example-test/selector-prop',\n", " 'op': 'readproperty',\n", + " 'htv:methodName': 'GET',\n", " 'contentType': 'application/json'},\n", - " {'href': 'tcp://LAPTOP-TUBBGPB8:5558',\n", + " {'href': 'http://127.0.0.1:9000/example-test/selector-prop',\n", " 'op': 'writeproperty',\n", + " 'htv:methodName': 'PUT',\n", " 'contentType': 'application/json'}]},\n", " 'simple_class_prop': {'description': 'simple class property with default value',\n", " 'default': 42,\n", " 'type': 'number',\n", - " 'forms': [{'href': 'tcp://LAPTOP-TUBBGPB8:5558',\n", + " 'forms': [{'href': 'http://127.0.0.1:9000/example-test/simple-class-prop',\n", " 'op': 'readproperty',\n", + " 'htv:methodName': 'GET',\n", " 'contentType': 'application/json'},\n", - " {'href': 'tcp://LAPTOP-TUBBGPB8:5558',\n", + " {'href': 'http://127.0.0.1:9000/example-test/simple-class-prop',\n", " 'op': 'writeproperty',\n", + " 'htv:methodName': 'PUT',\n", " 'contentType': 'application/json'}]},\n", " 'sleeping_prop': {'description': 'A property that sleeps for 10 seconds on read operations',\n", " 'default': 0,\n", " 'readOnly': True,\n", " 'type': 'number',\n", " 'observable': True,\n", - " 'forms': [{'href': 'tcp://LAPTOP-TUBBGPB8:5558',\n", + " 'forms': [{'href': 'http://127.0.0.1:9000/example-test/sleeping-prop',\n", " 'op': 'readproperty',\n", + " 'htv:methodName': 'GET',\n", " 'contentType': 'application/json'},\n", - " {'href': 'tcp://LAPTOP-TUBBGPB8:60000',\n", + " {'href': 'http://127.0.0.1:9000/example-test/sleeping-prop/change-event',\n", " 'op': 'observeproperty',\n", - " 'contentType': 'application/json'}]},\n", + " 'htv:methodName': 'GET',\n", + " 'contentType': 'application/json',\n", + " 'subprotocol': 'sse'}]},\n", " 'string_prop': {'description': 'A string property with a regex constraint to check value errors',\n", " 'default': 'hello',\n", " 'type': 'string',\n", " 'pattern': '^[a-z]+',\n", - " 'forms': [{'href': 'tcp://LAPTOP-TUBBGPB8:5558',\n", + " 'forms': [{'href': 'http://127.0.0.1:9000/example-test/string-prop',\n", " 'op': 'readproperty',\n", + " 'htv:methodName': 'GET',\n", " 'contentType': 'application/json'},\n", - " {'href': 'tcp://LAPTOP-TUBBGPB8:5558',\n", + " {'href': 'http://127.0.0.1:9000/example-test/string-prop',\n", " 'op': 'writeproperty',\n", + " 'htv:methodName': 'PUT',\n", " 'contentType': 'application/json'}]},\n", " 'total_number_of_events': {'description': 'Total number of events pushed',\n", " 'default': 100,\n", " 'type': 'number',\n", " 'minimum': 1,\n", - " 'forms': [{'href': 'tcp://LAPTOP-TUBBGPB8:5558',\n", + " 'forms': [{'href': 'http://127.0.0.1:9000/example-test/total-number-of-events',\n", " 'op': 'readproperty',\n", + " 'htv:methodName': 'GET',\n", " 'contentType': 'application/json'},\n", - " {'href': 'tcp://LAPTOP-TUBBGPB8:5558',\n", + " {'href': 'http://127.0.0.1:9000/example-test/total-number-of-events',\n", " 'op': 'writeproperty',\n", + " 'htv:methodName': 'PUT',\n", " 'contentType': 'application/json'}]}},\n", " 'actions': {'action_echo': {'synchronous': True,\n", - " 'forms': [{'href': 'tcp://LAPTOP-TUBBGPB8:5558',\n", + " 'forms': [{'href': 'http://127.0.0.1:9000/example-test/action-echo',\n", " 'op': 'invokeaction',\n", + " 'htv:methodName': 'POST',\n", " 'contentType': 'application/json'}]},\n", " 'execute_instruction': {'description': \"executes instruction given by the ASCII string parameter 'command'. If return data size is greater than 0, it reads the response and returns the response. Return Data Size - in bytes - 1 ASCII character = 1 Byte.\",\n", " 'input': {'properties': {'command': {'type': 'string'},\n", @@ -2391,13 +2301,15 @@ " 'type': 'object'},\n", " 'output': {'type': 'string'},\n", " 'synchronous': True,\n", - " 'forms': [{'href': 'tcp://LAPTOP-TUBBGPB8:5558',\n", + " 'forms': [{'href': 'http://127.0.0.1:9000/example-test/execute-instruction',\n", " 'op': 'invokeaction',\n", + " 'htv:methodName': 'POST',\n", " 'contentType': 'application/json'}]},\n", - " 'exit': {'description': 'Stop serving the object. This method can only be called remotely',\n", + " 'exit': {'description': 'Stop serving the object. This method usually needs to be called remotely',\n", " 'synchronous': True,\n", - " 'forms': [{'href': 'tcp://LAPTOP-TUBBGPB8:5558',\n", + " 'forms': [{'href': 'http://127.0.0.1:9000/example-test/exit',\n", " 'op': 'invokeaction',\n", + " 'htv:methodName': 'POST',\n", " 'contentType': 'application/json'}]},\n", " 'get_analogue_offset': {'description': 'analogue offset for a voltage range and coupling',\n", " 'input': {'type': 'object',\n", @@ -2421,20 +2333,24 @@ " 'maxItems': 2,\n", " 'items': {'type': 'number'}},\n", " 'synchronous': True,\n", - " 'forms': [{'href': 'tcp://LAPTOP-TUBBGPB8:5558',\n", + " 'forms': [{'href': 'http://127.0.0.1:9000/example-test/get-analogue-offset',\n", " 'op': 'invokeaction',\n", + " 'htv:methodName': 'POST',\n", " 'contentType': 'application/json'}]},\n", " 'get_mixed_content_data': {'synchronous': True,\n", - " 'forms': [{'href': 'tcp://LAPTOP-TUBBGPB8:5558',\n", + " 'forms': [{'href': 'http://127.0.0.1:9000/example-test/get-mixed-content-data',\n", " 'op': 'invokeaction',\n", + " 'htv:methodName': 'POST',\n", " 'contentType': 'application/json'}]},\n", " 'get_non_remote_number_prop': {'synchronous': True,\n", - " 'forms': [{'href': 'tcp://LAPTOP-TUBBGPB8:5558',\n", + " 'forms': [{'href': 'http://127.0.0.1:9000/example-test/get-non-remote-number-prop',\n", " 'op': 'invokeaction',\n", - " 'contentType': 'application/json'}]},\n", + " 'htv:methodName': 'POST',\n", + " 'contentType': 'application/msgpack'}]},\n", " 'get_serialized_data': {'synchronous': True,\n", - " 'forms': [{'href': 'tcp://LAPTOP-TUBBGPB8:5558',\n", + " 'forms': [{'href': 'http://127.0.0.1:9000/example-test/get-serialized-data',\n", " 'op': 'invokeaction',\n", + " 'htv:methodName': 'POST',\n", " 'contentType': 'application/json'}]},\n", " 'get_thing_model': {'description': \"generate the [Thing Model](https://www.w3.org/TR/wot-thing-description11/#introduction-tm) of the object. The model is a JSON that describes the object's properties, actions, events and their metadata, without the protocol information. The model can be used by a client to understand the object's capabilities.\",\n", " 'input': {'properties': {'ignore_errors': {'default': False,\n", @@ -2444,29 +2360,44 @@ " 'type': 'array'}},\n", " 'type': 'object'},\n", " 'synchronous': True,\n", - " 'forms': [{'href': 'tcp://LAPTOP-TUBBGPB8:5558',\n", + " 'forms': [{'href': 'http://127.0.0.1:9000/example-test/resources/wot-tm',\n", " 'op': 'invokeaction',\n", + " 'htv:methodName': 'POST',\n", " 'contentType': 'application/json'}]},\n", " 'get_transports': {'synchronous': True,\n", - " 'forms': [{'href': 'tcp://LAPTOP-TUBBGPB8:5558',\n", + " 'forms': [{'href': 'http://127.0.0.1:9000/example-test/get-transports',\n", " 'op': 'invokeaction',\n", + " 'htv:methodName': 'POST',\n", " 'contentType': 'application/json'}]},\n", - " 'ping': {'description': 'ping the `Thing` to see if it is alive. Ping successful when action succeeds with no return value and no timeout or exception raised on the client side.',\n", + " 'numpy_action': {'input': {'properties': {'array': {'items': {'type': 'number'},\n", + " 'type': 'array'}},\n", + " 'required': ['array'],\n", + " 'type': 'object'},\n", + " 'output': {'items': {'type': 'number'}, 'type': 'array'},\n", + " 'synchronous': True,\n", + " 'forms': [{'href': 'http://127.0.0.1:9000/example-test/numpy-action',\n", + " 'op': 'invokeaction',\n", + " 'htv:methodName': 'POST',\n", + " 'contentType': 'application/msgpack'}]},\n", + " 'ping': {'description': 'ping to see if it is alive. Successful when action succeeds with no return value and no timeout or exception raised on the client side.',\n", " 'synchronous': True,\n", - " 'forms': [{'href': 'tcp://LAPTOP-TUBBGPB8:5558',\n", + " 'forms': [{'href': 'http://127.0.0.1:9000/example-test/ping',\n", " 'op': 'invokeaction',\n", + " 'htv:methodName': 'POST',\n", " 'contentType': 'application/json'}]},\n", " 'print_props': {'synchronous': True,\n", - " 'forms': [{'href': 'tcp://LAPTOP-TUBBGPB8:5558',\n", + " 'forms': [{'href': 'http://127.0.0.1:9000/example-test/print-props',\n", " 'op': 'invokeaction',\n", + " 'htv:methodName': 'POST',\n", " 'contentType': 'application/json'}]},\n", " 'push_events': {'input': {'properties': {'event_name': {'default': 'test_event',\n", " 'type': 'string'},\n", " 'total_number_of_events': {'default': 100, 'type': 'integer'}},\n", " 'type': 'object'},\n", " 'synchronous': True,\n", - " 'forms': [{'href': 'tcp://LAPTOP-TUBBGPB8:5558',\n", + " 'forms': [{'href': 'http://127.0.0.1:9000/example-test/push-events',\n", " 'op': 'invokeaction',\n", + " 'htv:methodName': 'POST',\n", " 'contentType': 'application/json'}]},\n", " 'set_channel': {'description': 'Set the parameter for a channel. https://www.picotech.com/download/manuals/picoscope-6000-series-a-api-programmers-guide.pdf',\n", " 'input': {'type': 'object',\n", @@ -2490,8 +2421,9 @@ " 'coupling': {'type': 'string', 'enum': ['AC', 'DC']},\n", " 'bw_limiter': {'type': 'string', 'enum': ['full', '20MHz']}}},\n", " 'synchronous': True,\n", - " 'forms': [{'href': 'tcp://LAPTOP-TUBBGPB8:5558',\n", + " 'forms': [{'href': 'http://127.0.0.1:9000/example-test/set-channel',\n", " 'op': 'invokeaction',\n", + " 'htv:methodName': 'POST',\n", " 'contentType': 'application/json'}]},\n", " 'set_channel_pydantic': {'description': 'Set the parameter for a channel. https://www.picotech.com/download/manuals/picoscope-6000-series-a-api-programmers-guide.pdf',\n", " 'input': {'properties': {'channel': {'enum': ['A', 'B', 'C', 'D'],\n", @@ -2520,18 +2452,21 @@ " 'required': ['channel'],\n", " 'type': 'object'},\n", " 'synchronous': True,\n", - " 'forms': [{'href': 'tcp://LAPTOP-TUBBGPB8:5558',\n", + " 'forms': [{'href': 'http://127.0.0.1:9000/example-test/set-channel-pydantic',\n", " 'op': 'invokeaction',\n", + " 'htv:methodName': 'POST',\n", " 'contentType': 'application/json'}]},\n", " 'set_non_remote_number_prop': {'synchronous': True,\n", - " 'forms': [{'href': 'tcp://LAPTOP-TUBBGPB8:5558',\n", + " 'forms': [{'href': 'http://127.0.0.1:9000/example-test/set-non-remote-number-prop',\n", " 'op': 'invokeaction',\n", - " 'contentType': 'application/json'}]},\n", + " 'htv:methodName': 'POST',\n", + " 'contentType': 'application/msgpack'}]},\n", " 'set_sensor_model': {'description': 'Set the attached sensor to the meter under control. Sensor should be defined as a class and added to the AllowedSensors dict.',\n", " 'input': {'type': 'string', 'enum': ['QE25LP-S-MB', 'QE12LP-S-MB-QED-D0']},\n", " 'synchronous': True,\n", - " 'forms': [{'href': 'tcp://LAPTOP-TUBBGPB8:5558',\n", + " 'forms': [{'href': 'http://127.0.0.1:9000/example-test/set-sensor-model',\n", " 'op': 'invokeaction',\n", + " 'htv:methodName': 'POST',\n", " 'contentType': 'application/json'}]},\n", " 'set_sensor_model_pydantic': {'description': 'Set the attached sensor to the meter under control. Sensor should be defined as a class and added to the AllowedSensors dict.',\n", " 'input': {'properties': {'value': {'enum': ['QE25LP-S-MB',\n", @@ -2540,12 +2475,14 @@ " 'required': ['value'],\n", " 'type': 'object'},\n", " 'synchronous': True,\n", - " 'forms': [{'href': 'tcp://LAPTOP-TUBBGPB8:5558',\n", + " 'forms': [{'href': 'http://127.0.0.1:9000/example-test/set-sensor-model-pydantic',\n", " 'op': 'invokeaction',\n", + " 'htv:methodName': 'POST',\n", " 'contentType': 'application/json'}]},\n", " 'sleep': {'synchronous': True,\n", - " 'forms': [{'href': 'tcp://LAPTOP-TUBBGPB8:5558',\n", + " 'forms': [{'href': 'http://127.0.0.1:9000/example-test/sleep',\n", " 'op': 'invokeaction',\n", + " 'htv:methodName': 'POST',\n", " 'contentType': 'application/json'}]},\n", " 'start_acquisition': {'description': 'Start acquisition of energy measurements.',\n", " 'input': {'properties': {'max_count': {'exclusiveMinimum': 0,\n", @@ -2553,40 +2490,56 @@ " 'required': ['max_count'],\n", " 'type': 'object'},\n", " 'synchronous': True,\n", - " 'forms': [{'href': 'tcp://LAPTOP-TUBBGPB8:5558',\n", + " 'forms': [{'href': 'http://127.0.0.1:9000/example-test/start-acquisition',\n", " 'op': 'invokeaction',\n", + " 'htv:methodName': 'POST',\n", " 'contentType': 'application/json'}]}},\n", " 'events': {'data_point_event': {'description': 'Event raised when a new data point is available',\n", " 'data': {'type': 'object',\n", " 'properties': {'timestamp': {'type': 'string'},\n", " 'energy': {'type': 'number'}},\n", " 'required': ['timestamp', 'energy']},\n", - " 'forms': [{'href': 'tcp://LAPTOP-TUBBGPB8:60000',\n", + " 'forms': [{'href': 'http://127.0.0.1:9000/example-test/data-point-event',\n", " 'op': 'subscribeevent',\n", - " 'contentType': 'application/json'}]},\n", + " 'htv:methodName': 'GET',\n", + " 'contentType': 'application/json',\n", + " 'subprotocol': 'sse'}]},\n", " 'test_binary_payload_event': {'description': 'test event with binary payload',\n", - " 'forms': [{'href': 'tcp://LAPTOP-TUBBGPB8:60000',\n", + " 'forms': [{'href': 'http://127.0.0.1:9000/example-test/test-binary-payload-event',\n", " 'op': 'subscribeevent',\n", - " 'contentType': 'application/json'}]},\n", + " 'htv:methodName': 'GET',\n", + " 'contentType': 'application/json',\n", + " 'subprotocol': 'sse'}]},\n", " 'test_event': {'description': 'test event with arbitrary payload',\n", - " 'forms': [{'href': 'tcp://LAPTOP-TUBBGPB8:60000',\n", + " 'forms': [{'href': 'http://127.0.0.1:9000/example-test/test-event',\n", " 'op': 'subscribeevent',\n", - " 'contentType': 'application/json'}]},\n", + " 'htv:methodName': 'GET',\n", + " 'contentType': 'application/json',\n", + " 'subprotocol': 'sse'}]},\n", " 'test_event_with_json_schema': {'description': 'test event with schema validation',\n", - " 'forms': [{'href': 'tcp://LAPTOP-TUBBGPB8:60000',\n", + " 'forms': [{'href': 'http://127.0.0.1:9000/example-test/test-event-with-json-schema',\n", " 'op': 'subscribeevent',\n", - " 'contentType': 'application/json'}]},\n", + " 'htv:methodName': 'GET',\n", + " 'contentType': 'application/json',\n", + " 'subprotocol': 'sse'}]},\n", " 'test_event_with_pydantic_schema': {'description': 'test event with pydantic schema validation',\n", - " 'forms': [{'href': 'tcp://LAPTOP-TUBBGPB8:60000',\n", + " 'forms': [{'href': 'http://127.0.0.1:9000/example-test/test-event-with-pydantic-schema',\n", " 'op': 'subscribeevent',\n", - " 'contentType': 'application/json'}]},\n", + " 'htv:methodName': 'GET',\n", + " 'contentType': 'application/json',\n", + " 'subprotocol': 'sse'}]},\n", " 'test_mixed_content_payload_event': {'description': 'test event with mixed content payload',\n", - " 'forms': [{'href': 'tcp://LAPTOP-TUBBGPB8:60000',\n", + " 'forms': [{'href': 'http://127.0.0.1:9000/example-test/test-mixed-content-payload-event',\n", " 'op': 'subscribeevent',\n", - " 'contentType': 'application/json'}]}}}" + " 'htv:methodName': 'GET',\n", + " 'contentType': 'application/json',\n", + " 'subprotocol': 'sse'}]}},\n", + " 'securityDefinitions': {'nosec': {'scheme': 'nosec',\n", + " 'description': 'currently no security scheme supported - use cookie auth directly on hololinked.server.HTTPServer object'}},\n", + " 'security': ['nosec']}" ] }, - "execution_count": 3, + "execution_count": 2, "metadata": {}, "output_type": "execute_result" }, @@ -2594,16 +2547,16 @@ "name": "stdout", "output_type": "stream", "text": [ - "INFO - 2025-09-21T09:39:11:745 - example-test|example-test|tcp://localhost:5558|2f1ecdef-432d-42af-bfe6-03496ab70046 - sent Handshake to server 'example-test'\n", - "INFO - 2025-09-21T09:39:11:746 - example-test|example-test|tcp://localhost:5558|2f1ecdef-432d-42af-bfe6-03496ab70046 - client 'example-test|example-test|tcp://localhost:5558|2f1ecdef-432d-42af-bfe6-03496ab70046|async' handshook with server 'example-test'\n" + "INFO - 2025-10-04T17:01:27:612 - example-test-server|example-test|IPC|d0adc808-b7ed-4a9e-a97f-8bb3f2c04a0b - sent Handshake to server 'example-test-server'\n", + "INFO - 2025-10-04T17:01:27:614 - example-test-server|example-test|IPC|d0adc808-b7ed-4a9e-a97f-8bb3f2c04a0b - client 'example-test-server|example-test|IPC|d0adc808-b7ed-4a9e-a97f-8bb3f2c04a0b|async' handshook with server 'example-test-server'\n" ] } ], "source": [ "from hololinked.client import ClientFactory\n", "\n", - "# object_proxy = ClientFactory.http(\"http://localhost:8080/example-test/resources/wot-td\", ignore_TD_errors=True)\n", - "object_proxy = ClientFactory.zmq(server_id=\"example-test\", thing_id=\"example-test\", access_point=\"tcp://localhost:5558\", ignore_TD_errors=True)\n", + "object_proxy = ClientFactory.http(\"http://localhost:9000/example-test/resources/wot-td\", ignore_TD_errors=True)\n", + "object_proxy_zmq = ClientFactory.zmq(server_id=\"example-test-server\", thing_id=\"example-test\", access_point=\"IPC\", ignore_TD_errors=True)\n", "object_proxy.td" ] }, @@ -2874,7 +2827,7 @@ }, { "cell_type": "code", - "execution_count": 10, + "execution_count": 3, "id": "3285efde", "metadata": {}, "outputs": [ @@ -2897,9 +2850,136 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 4, "id": "169fd06e", "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "1" + ] + }, + "execution_count": 4, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "object_proxy.read_property(\"db_init_int_prop\")" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "41595859", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "20" + ] + }, + "execution_count": 5, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "object_proxy_zmq.set_non_remote_number_prop(20)\n", + "object_proxy_zmq.get_non_remote_number_prop()" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "113441d1", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "['INPROC', 'IPC']" + ] + }, + "execution_count": 6, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "object_proxy.get_transports()" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "ea4ed954", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "b'foobar'" + ] + }, + "execution_count": 7, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "# object_proxy.get_serialized_data()\n", + "object_proxy_zmq.get_serialized_data()" + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "id": "0077604d", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "foobar\n", + "b'foobar'\n" + ] + } + ], + "source": [ + "val, binary_val = object_proxy_zmq.get_mixed_content_data()\n", + "print(val)\n", + "print(binary_val)" + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "id": "7a9a4f24", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "[2 4 6]\n" + ] + } + ], + "source": [ + "# print(object_proxy.numpy_array_prop)\n", + "# print(type(object_proxy.numpy_array_prop))\n", + "print(object_proxy.numpy_action(object_proxy.numpy_array_prop))" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "92c360e6", + "metadata": {}, "outputs": [], "source": [] } diff --git a/tests/helper-scripts/design_script.py b/tests/helper-scripts/design_script.py deleted file mode 100644 index cf522d57..00000000 --- a/tests/helper-scripts/design_script.py +++ /dev/null @@ -1,18 +0,0 @@ -from hololinked.core import Thing -from hololinked.constants import Operations -from hololinked.server.http import HTTPServer -from hololinked.server.zmq import ZMQServer -from things import TestThing - -thing = TestThing(id="example-test") -# thing.run( -# access_points=[ -# ("ZMQ", "IPC"), -# ("HTTP", 8080), -# ] -# ) - - -http_server = HTTPServer(port=9000) -zmq_server = ZMQServer(id="example-test-server", things=[thing], access_points="IPC") -thing.run(servers=[http_server, zmq_server]) diff --git a/tests/helper-scripts/run_test_thing.py b/tests/helper-scripts/run_test_thing.py new file mode 100644 index 00000000..5fa58294 --- /dev/null +++ b/tests/helper-scripts/run_test_thing.py @@ -0,0 +1,31 @@ +import os +import sys + +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../"))) + +from hololinked.server.http import HTTPServer +from hololinked.server.zmq import ZMQServer +from hololinked.serializers import Serializers +from hololinked.config import global_config +from things import TestThing + +global_config.DEBUG = True + +thing = TestThing(id="example-test") + +Serializers.register_for_object(TestThing.db_init_int_prop, Serializers.pickle) +Serializers.register_for_object(TestThing.set_non_remote_number_prop, Serializers.msgpack) +Serializers.register_for_object(TestThing.get_non_remote_number_prop, Serializers.msgpack) +Serializers.register_for_object(TestThing.numpy_array_prop, Serializers.msgpack) +Serializers.register_for_object(TestThing.numpy_action, Serializers.msgpack) + +# thing.run( +# access_points=[ +# ("ZMQ", "IPC"), +# ("HTTP", 8080), +# ] +# ) + +http_server = HTTPServer(port=9000) +zmq_server = ZMQServer(id="example-test-server", things=[thing], access_points="IPC") +thing.run(servers=[http_server, zmq_server]) diff --git a/tests/test_13_protocols_http.py b/tests/test_13_protocols_http.py index b2bb4f65..7a9987aa 100644 --- a/tests/test_13_protocols_http.py +++ b/tests/test_13_protocols_http.py @@ -648,6 +648,7 @@ def wait_until_server_ready(cls, port, tries: int = 10): try: response = session.get(f"http://127.0.0.1:{port}/liveness") if response.status_code in [200, 201, 202, 204]: + time.sleep(2) return except Exception: pass diff --git a/tests/things/spectrometer.py b/tests/things/spectrometer.py index 580f80d8..735ab001 100644 --- a/tests/things/spectrometer.py +++ b/tests/things/spectrometer.py @@ -8,44 +8,38 @@ from hololinked.core import Thing, Property, action, Event -from hololinked.core.properties import (String, Integer, Number, List, Boolean, - Selector, ClassSelector, TypedList) +from hololinked.core.properties import String, Integer, Number, List, Boolean, Selector, ClassSelector, TypedList from hololinked.core.state_machine import StateMachine from hololinked.serializers import JSONSerializer from hololinked.schema_validators import JSONSchema from hololinked.server.http import HTTPServer -@dataclass +@dataclass class Intensity: - value : numpy.ndarray - timestamp : str + value: numpy.ndarray + timestamp: str schema = { - "type" : "object", - "properties" : { - "value" : { - "type" : "array", - "items" : { - "type" : "number" - }, + "type": "object", + "properties": { + "value": { + "type": "array", + "items": {"type": "number"}, }, - "timestamp" : { - "type" : "string" - } - } + "timestamp": {"type": "string"}, + }, } @property def not_completely_black(self): - if any(self.value[i] > 0 for i in range(len(self.value))): - return True + if any(self.value[i] > 0 for i in range(len(self.value))): + return True return False - -JSONSerializer.register_type_replacement(numpy.ndarray, lambda obj : obj.tolist()) -JSONSchema.register_type_replacement(Intensity, 'object', Intensity.schema) +JSONSerializer.register_type_replacement(numpy.ndarray, lambda obj: obj.tolist()) +JSONSchema.register_type_replacement(Intensity, "object", Intensity.schema) connect_args = { @@ -53,13 +47,12 @@ def not_completely_black(self): "properties": { "serial_number": {"type": "string"}, "trigger_mode": {"type": "integer"}, - "integration_time": {"type": "number"} + "integration_time": {"type": "number"}, }, - "additionalProperties": False + "additionalProperties": False, } - class States(StrEnum): DISCONNECTED = "DISCONNECTED" ON = "ON" @@ -75,45 +68,47 @@ class OceanOpticsSpectrometer(Thing): states = States - status = String(readonly=True, fget=lambda self: self._status, - doc="descriptive status of current operation") # type: str + status = String(readonly=True, fget=lambda self: self._status, doc="descriptive status of current operation") # type: str + + serial_number = String( + default=None, allow_None=True, doc="serial number of the spectrometer to connect/or connected" + ) # type: str - serial_number = String(default=None, allow_None=True, - doc="serial number of the spectrometer to connect/or connected")# type: str + last_intensity = ClassSelector( + default=None, allow_None=True, class_=Intensity, doc="last measurement intensity (in arbitrary units)" + ) # type: Intensity - last_intensity = ClassSelector(default=None, allow_None=True, class_=Intensity, - doc="last measurement intensity (in arbitrary units)") # type: Intensity - intensity_measurement_event = Event( - doc="event generated on measurement of intensity, max 30 per second even if measurement is faster.", - schema=Intensity.schema) - - reference_intensity = ClassSelector(default=None, allow_None=True, class_=Intensity, - doc="reference intensity to overlap in background") # type: Intensity - - + doc="event generated on measurement of intensity, max 30 per second even if measurement is faster.", + schema=Intensity.schema, + ) + + reference_intensity = ClassSelector( + default=None, allow_None=True, class_=Intensity, doc="reference intensity to overlap in background" + ) # type: Intensity + def __init__(self, id: str, serial_number: typing.Optional[str] = None, **kwargs) -> None: super().__init__(id=id, serial_number=serial_number, **kwargs) self.set_status("disconnected") if serial_number is not None: self.connect() - self._acquisition_thread = None + self._acquisition_thread = None self._running = False - + def set_status(self, *args) -> None: if len(args) == 1: self._status = args[0] else: - self._status = ' '.join(args) - + self._status = " ".join(args) + @action(input_schema=connect_args) - def connect(self, serial_number : str = None, trigger_mode : int = None, integration_time : float = None) -> None: + def connect(self, serial_number: str = None, trigger_mode: int = None, integration_time: float = None) -> None: if serial_number is not None: self.serial_number = serial_number self.state_machine.current_state = self.states.ON self._pixel_count = 50 self._wavelengths = [i for i in range(self._pixel_count)] - self._model = 'STS' + self._model = "STS" self._max_intensity = 16384 if trigger_mode is not None: self.trigger_mode = trigger_mode @@ -128,144 +123,164 @@ def connect(self, serial_number : str = None, trigger_mode : int = None, integra self.logger.debug(f"opened device with serial number {self.serial_number} with model {self.model}") self.set_status("ready to start acquisition") - model = String(default=None, allow_None=True, readonly=True, - doc="model of the connected spectrometer", - fget=lambda self: self._model if self.state_machine.current_state != self.states.DISCONNECTED else None - ) # type: str - - wavelengths = List(default=[], item_type=(float, int), readonly=True, allow_None=False, - # this is only for testing, be careful - doc="wavelength bins of measurement", - fget=lambda self: self._wavelengths if self.state_machine.current_state != self.states.DISCONNECTED else None, - ) # type: typing.List[typing.Union[float, int]] - - pixel_count = Integer(default=None, allow_None=True, readonly=True, - doc="number of points in wavelength", - fget=lambda self: self._pixel_count if self.state_machine.current_state != self.states.DISCONNECTED else None - ) # type: int - - max_intensity = Number(readonly=True, - doc="""the maximum intensity that can be returned by the spectrometer in (a.u.). + model = String( + default=None, + allow_None=True, + readonly=True, + doc="model of the connected spectrometer", + fget=lambda self: self._model if self.state_machine.current_state != self.states.DISCONNECTED else None, + ) # type: str + + wavelengths = List( + default=[], + item_type=(float, int), + readonly=True, + allow_None=False, + # this is only for testing, be careful + doc="wavelength bins of measurement", + fget=lambda self: self._wavelengths if self.state_machine.current_state != self.states.DISCONNECTED else None, + ) # type: typing.List[typing.Union[float, int]] + + pixel_count = Integer( + default=None, + allow_None=True, + readonly=True, + doc="number of points in wavelength", + fget=lambda self: self._pixel_count if self.state_machine.current_state != self.states.DISCONNECTED else None, + ) # type: int + + max_intensity = Number( + readonly=True, + doc="""the maximum intensity that can be returned by the spectrometer in (a.u.). It's possible that the spectrometer saturates already at lower values.""", - fget=lambda self: self._max_intensity if self.state_machine.current_state != self.states.DISCONNECTED else None - ) # type: float - + fget=lambda self: self._max_intensity if self.state_machine.current_state != self.states.DISCONNECTED else None, + ) # type: float + @action() def disconnect(self): self.state_machine.current_state = self.states.DISCONNECTED - trigger_mode = Selector(objects=[0, 1, 2, 3, 4], default=0, observable=True, - doc="""0 = normal/free running, 1 = Software trigger, 2 = Ext. Trigger Level, - 3 = Ext. Trigger Synchro/ Shutter mode, 4 = Ext. Trigger Edge""") # type: int - - @trigger_mode.setter - def apply_trigger_mode(self, value : int): - self._trigger_mode = value - - @trigger_mode.getter + trigger_mode = Selector( + objects=[0, 1, 2, 3, 4], + default=0, + observable=True, + doc="""0 = normal/free running, 1 = Software trigger, 2 = Ext. Trigger Level, + 3 = Ext. Trigger Synchro/ Shutter mode, 4 = Ext. Trigger Edge""", + ) # type: int + + @trigger_mode.setter + def apply_trigger_mode(self, value: int): + self._trigger_mode = value + + @trigger_mode.getter def get_trigger_mode(self): try: return self._trigger_mode except: - return OceanOpticsSpectrometer.properties["trigger_mode"].default - - - integration_time = Number(default=1000, bounds=(0.001, None), crop_to_bounds=True, observable=True, - doc="integration time of measurement in milliseconds") # type: float - - @integration_time.setter - def apply_integration_time(self, value : float): - self._integration_time = int(value) - - @integration_time.getter + return OceanOpticsSpectrometer.properties["trigger_mode"].default + + integration_time = Number( + default=1000, + bounds=(0.001, None), + crop_to_bounds=True, + observable=True, + doc="integration time of measurement in milliseconds", + ) # type: float + + @integration_time.setter + def apply_integration_time(self, value: float): + self._integration_time = int(value) + + @integration_time.getter def get_integration_time(self) -> float: try: return self._integration_time except: - return OceanOpticsSpectrometer.properties["integration_time"].default - - background_correction = Selector(objects=['AUTO', 'CUSTOM', None], default=None, allow_None=True, - doc="set True for Seabreeze internal black level correction") # type: typing.Optional[str] - - custom_background_intensity = TypedList(item_type=(float, int)) # type: typing.List[typing.Union[float, int]] - - nonlinearity_correction = Boolean(default=False, - doc="automatic correction of non linearity in detector CCD") # type: bool + return OceanOpticsSpectrometer.properties["integration_time"].default + + background_correction = Selector( + objects=["AUTO", "CUSTOM", None], + default=None, + allow_None=True, + doc="set True for Seabreeze internal black level correction", + ) # type: typing.Optional[str] + + custom_background_intensity = TypedList(item_type=(float, int)) # type: typing.List[typing.Union[float, int]] + + nonlinearity_correction = Boolean(default=False, doc="automatic correction of non linearity in detector CCD") # type: bool @action() def start_acquisition(self) -> None: - self.stop_acquisition() # Just a shield - self._acquisition_thread = threading.Thread(target=self.measure) + self.stop_acquisition() # Just a shield + self._acquisition_thread = threading.Thread(target=self.measure) self._acquisition_thread.start() @action() def stop_acquisition(self) -> None: if self._acquisition_thread is not None: self.logger.debug(f"stopping acquisition thread with thread-ID {self._acquisition_thread.ident}") - self._running = False # break infinite loop + self._running = False # break infinite loop # Reduce the measurement that will proceed in new trigger mode to 1ms self._acquisition_thread.join() - self._acquisition_thread = None + self._acquisition_thread = None # re-apply old values self.trigger_mode = self.trigger_mode - self.integration_time = self.integration_time - + self.integration_time = self.integration_time - def measure(self, max_count = None): + def measure(self, max_count=None): try: self._running = True self.state_machine.current_state = self.states.MEASURING self.set_status("measuring") - self.logger.info(f'starting continuous acquisition loop with trigger mode {self.trigger_mode} & integration time {self.integration_time} in thread with ID {threading.get_ident()}') + self.logger.info( + f"starting continuous acquisition loop with trigger mode {self.trigger_mode} & integration time {self.integration_time} in thread with ID {threading.get_ident()}" + ) loop = 0 while self._running: if max_count is not None and loop > max_count: - break - loop += 1 + break + loop += 1 time.sleep(self.integration_time / 1000.0) # simulate integration time # Following is a blocking command - self.spec.intensities - self.logger.debug(f'starting measurement count {loop}') + self.logger.debug(f"starting measurement count {loop}") _current_intensity = [numpy.random.randint(0, self.max_intensity) for i in range(self._pixel_count)] - if self.background_correction == 'CUSTOM': + if self.background_correction == "CUSTOM": if self.custom_background_intensity is None: - self.logger.warning('no background correction possible') + self.logger.warning("no background correction possible") self.state_machine.set_state(self.states.ALARM) else: _current_intensity = _current_intensity - self.custom_background_intensity curtime = datetime.datetime.now() - timestamp = curtime.strftime('%d.%m.%Y %H:%M:%S.') + '{:03d}'.format(int(curtime.microsecond /1000)) - self.logger.debug(f'measurement taken at {timestamp} - measurement count {loop}') + timestamp = curtime.strftime("%d.%m.%Y %H:%M:%S.") + "{:03d}".format(int(curtime.microsecond / 1000)) + self.logger.debug(f"measurement taken at {timestamp} - measurement count {loop}") if self._running: - # To stop the acquisition in hardware trigger mode, we set running to False in stop_acquisition() - # and then change the trigger mode for self.spec.intensities to unblock. This exits this - # infintie loop. Therefore, to know, whether self.spec.intensities finished, whether due to trigger - # mode or due to actual completion of measurement, we check again if self._running is True. - self.last_intensity = Intensity( - value=_current_intensity, - timestamp=timestamp - ) - if self.last_intensity.not_completely_black: + # To stop the acquisition in hardware trigger mode, we set running to False in stop_acquisition() + # and then change the trigger mode for self.spec.intensities to unblock. This exits this + # infintie loop. Therefore, to know, whether self.spec.intensities finished, whether due to trigger + # mode or due to actual completion of measurement, we check again if self._running is True. + self.last_intensity = Intensity(value=_current_intensity, timestamp=timestamp) + if self.last_intensity.not_completely_black: self.intensity_measurement_event.push(self.last_intensity) self.state_machine.current_state = self.states.MEASURING else: - self.logger.warning('trigger delayed or no trigger or erroneous data - completely black') + self.logger.warning("trigger delayed or no trigger or erroneous data - completely black") self.state_machine.current_state = self.states.ALARM - if self.state_machine.current_state not in [self.states.FAULT, self.states.ALARM]: + if self.state_machine.current_state not in [self.states.FAULT, self.states.ALARM]: self.state_machine.current_state = self.states.ON self.set_status("ready to start acquisition") - self.logger.info("ending continuous acquisition") - self._running = False + self.logger.info("ending continuous acquisition") + self._running = False except Exception as ex: self.logger.error(f"error during acquisition - {str(ex)}, {type(ex)}") - self.set_status(f'error during acquisition - {str(ex)}, {type(ex)}') + self.set_status(f"error during acquisition - {str(ex)}, {type(ex)}") self.state_machine.current_state = self.states.FAULT @action() def start_acquisition_single(self): - self.stop_acquisition() # Just a shield - self._acquisition_thread = threading.Thread(target=self.measure, args=(1,)) + self.stop_acquisition() # Just a shield + self._acquisition_thread = threading.Thread(target=self.measure, args=(1,)) self._acquisition_thread.start() self.logger.info("data event will be pushed once acquisition is complete.") @@ -282,26 +297,34 @@ def test_echo(self, value): initial_state=states.DISCONNECTED, push_state_change_event=True, DISCONNECTED=[connect, serial_number], - ON=[start_acquisition, start_acquisition_single, disconnect, - integration_time, trigger_mode, background_correction, nonlinearity_correction], + ON=[ + start_acquisition, + start_acquisition_single, + disconnect, + integration_time, + trigger_mode, + background_correction, + nonlinearity_correction, + ], MEASURING=[stop_acquisition], - FAULT=[stop_acquisition, reset_fault] + FAULT=[stop_acquisition, reset_fault], ) logger_remote_access = True + def run_zmq_server(): - thing = OceanOpticsSpectrometer(id='test_spectrometer') + thing = OceanOpticsSpectrometer(id="test_spectrometer") thing.run_with_zmq_server() def run_http_server(): - thing = OceanOpticsSpectrometer(id='test_spectrometer') + thing = OceanOpticsSpectrometer(id="test_spectrometer") server = HTTPServer() server.add_things(thing) server.listen() -if __name__ == '__main__': +if __name__ == "__main__": run_zmq_server() # run_http_server() diff --git a/tests/things/starter.py b/tests/things/starter.py index 88c681c5..e0bd3b3b 100644 --- a/tests/things/starter.py +++ b/tests/things/starter.py @@ -102,9 +102,7 @@ def run_thing_with_zmq_server_forked( return T -def run_zmq_server( - server: AsyncZMQServer, owner, done_queue: multiprocessing.Queue -) -> None: +def run_zmq_server(server: AsyncZMQServer, owner, done_queue: multiprocessing.Queue) -> None: event_loop = get_current_async_loop() async def run(): diff --git a/tests/things/test_thing.py b/tests/things/test_thing.py index 98d4470e..c853c1bb 100644 --- a/tests/things/test_thing.py +++ b/tests/things/test_thing.py @@ -1,6 +1,9 @@ -import asyncio, threading, time, logging, unittest, os +import asyncio +import threading +import time import typing -from pydantic import BaseModel, Field +import numpy as np +from pydantic import BaseModel, Field, WithJsonSchema from hololinked.core import Thing, action, Property, Event from hololinked.core.properties import ( @@ -11,35 +14,29 @@ Integer, ClassSelector, ) -from hololinked.core.actions import ( - Action, - BoundAction, - BoundSyncAction, - BoundAsyncAction, -) +from hololinked.core.actions import Action, BoundAction from hololinked.param import ParameterizedFunction -from hololinked.core.dataklasses import ActionInfoValidator -from hololinked.utils import isclassmethod +from hololinked.schema_validators import JSONSchema class TestThing(Thing): + """ + A test thing with various API options for properties, actions and events that were collected from examples from + real world implementations, testing, features offered etc. + + Add your own use case/snippets used in tests here as needed. + """ + + # ----------- Actions -------------- + @action() def get_transports(self): transports = [] - if ( - self.rpc_server.req_rep_server is not None - and self.rpc_server.req_rep_server.socket_address.startswith("inproc://") - ): + if self.rpc_server.req_rep_server and self.rpc_server.req_rep_server.socket_address.startswith("inproc://"): transports.append("INPROC") - if ( - self.rpc_server.ipc_server is not None - and self.rpc_server.ipc_server.socket_address.startswith("ipc://") - ): + if self.rpc_server.ipc_server and self.rpc_server.ipc_server.socket_address.startswith("ipc://"): transports.append("IPC") - if ( - self.rpc_server.tcp_server is not None - and self.rpc_server.tcp_server.socket_address.startswith("tcp://") - ): + if self.rpc_server.tcp_server and self.rpc_server.tcp_server.socket_address.startswith("tcp://"): transports.append("TCP") return transports @@ -70,9 +67,7 @@ class parameterized_action(ParameterizedFunction): doc="arg1 description", ) arg2 = String(default="hello", doc="arg2 description", regex="[a-z]+") - arg3 = ClassSelector( - class_=(int, float, str), default=5, doc="arg3 description" - ) + arg3 = ClassSelector(class_=(int, float, str), default=5, doc="arg3 description") def __call__(self, instance, arg1, arg2, arg3): return instance.id, arg1, arg2, arg3 @@ -86,9 +81,7 @@ class parameterized_action_without_call(ParameterizedFunction): doc="arg1 description", ) arg2 = String(default="hello", doc="arg2 description", regex="[a-z]+") - arg3 = ClassSelector( - class_=(int, float, str), default=5, doc="arg3 description" - ) + arg3 = ClassSelector(class_=(int, float, str), default=5, doc="arg3 description") class parameterized_action_async(ParameterizedFunction): arg1 = Number( @@ -99,9 +92,7 @@ class parameterized_action_async(ParameterizedFunction): doc="arg1 description", ) arg2 = String(default="hello", doc="arg2 description", regex="[a-z]+") - arg3 = ClassSelector( - class_=(int, float, str), default=5, doc="arg3 description" - ) + arg3 = ClassSelector(class_=(int, float, str), default=5, doc="arg3 description") async def __call__(self, instance, arg1, arg2, arg3): await asyncio.sleep(0.1) @@ -120,9 +111,7 @@ async def not_an_async_action(self, value): await asyncio.sleep(0.1) return value - def json_schema_validated_action( - self, val1: int, val2: str, val3: dict, val4: list - ): + def json_schema_validated_action(self, val1: int, val2: str, val3: dict, val4: list): return {"val1": val1, "val3": val3} def pydantic_validated_action( @@ -145,54 +134,63 @@ def sleep(self): # ----------- Properties -------------- base_property = Property(default=None, allow_None=True, doc="a base Property class") + number_prop = Number(doc="A fully editable number property", default=1) + string_prop = String( default="hello", regex="^[a-z]+", doc="A string property with a regex constraint to check value errors", ) + int_prop = Integer( default=5, step=2, bounds=(0, 100), doc="An integer property with step and bounds constraints to check RW", ) - selector_prop = Selector( - objects=["a", "b", "c", 1], default="a", doc="A selector property to check RW" - ) + + selector_prop = Selector(objects=["a", "b", "c", 1], default="a", doc="A selector property to check RW") + observable_list_prop = List( default=None, allow_None=True, observable=True, doc="An observable list property to check observable events on write operations", ) + observable_readonly_prop = Number( default=0, readonly=True, observable=True, doc="An observable readonly property to check observable events on read operations", ) + db_commit_number_prop = Number( default=0, db_commit=True, doc="A fully editable number property to check commits to db on write operations", ) + db_init_int_prop = Integer( default=1, db_init=True, doc="An integer property to check initialization from db", ) + db_persist_selector_prop = Selector( objects=["a", "b", "c", 1], default="a", db_persist=True, doc="A selector property to check persistence to db on write operations", ) + non_remote_number_prop = Number( default=5, remote=False, doc="A non remote number property to check non-availability on client", ) + sleeping_prop = Number( default=0, observable=True, @@ -262,13 +260,9 @@ def get_observable_readonly_prop(self): # ----------- Class properties -------------- - simple_class_prop = Number( - class_member=True, default=42, doc="simple class property with default value" - ) + simple_class_prop = Number(class_member=True, default=42, doc="simple class property with default value") - managed_class_prop = Number( - class_member=True, doc="(managed) class property with custom getter/setter" - ) + managed_class_prop = Number(class_member=True, doc="(managed) class property with custom getter/setter") @managed_class_prop.getter def get_managed_class_prop(cls): @@ -280,9 +274,7 @@ def set_managed_class_prop(cls, value): raise ValueError("Value must be non-negative") cls._managed_value = value - readonly_class_prop = String( - class_member=True, readonly=True, doc="read-only class property" - ) + readonly_class_prop = String(class_member=True, readonly=True, doc="read-only class property") @readonly_class_prop.getter def get_readonly_class_prop(cls): @@ -307,9 +299,7 @@ def del_deletable_class_prop(cls): if hasattr(cls, "_deletable_value"): del cls._deletable_value - not_a_class_prop = Number( - class_member=False, default=43, doc="test property with class_member=False" - ) + not_a_class_prop = Number(class_member=False, default=43, doc="test property with class_member=False") @not_a_class_prop.getter def get_not_a_class_prop(self): @@ -337,27 +327,55 @@ def print_props(self): print(f"db_persist_selctor_prop: {self.db_persist_selector_prop}") print(f"non_remote_number_prop: {self.non_remote_number_prop}") + # ----------- Pythonic objects as properties -------------- + + numpy_array_prop = ClassSelector( + default=None, + allow_None=True, + class_=(np.ndarray,), + doc="A property with a numpy array as value", + ) + + @numpy_array_prop.setter + def set_numpy_array_prop(self, value): + self._numpy_array_prop = value + + @numpy_array_prop.getter + def get_numpy_array_prop(self): + try: + return self._numpy_array_prop + except AttributeError: + return np.array([1, 2, 3]) + + JSONSchema.register_type_replacement(np.ndarray, "array") + + NDArray = typing.Annotated[ + np.ndarray, + WithJsonSchema( + { + "type": "array", + "items": {"type": "number"}, + } + ), + ] + + @action() + def numpy_action(self, array: NDArray) -> NDArray: + return array * 2 + # ----------- Events -------------- test_event = Event(doc="test event with arbitrary payload") - total_number_of_events = Number( - default=100, bounds=(1, None), doc="Total number of events pushed" - ) + total_number_of_events = Number(default=100, bounds=(1, None), doc="Total number of events pushed") @action() - def push_events( - self, event_name: str = "test_event", total_number_of_events: int = 100 - ): + def push_events(self, event_name: str = "test_event", total_number_of_events: int = 100): if event_name not in self.events: raise ValueError(f"Event {event_name} is not a valid event") - threading.Thread( - target=self._push_worker, args=(event_name, total_number_of_events) - ).start() + threading.Thread(target=self._push_worker, args=(event_name, total_number_of_events)).start() - def _push_worker( - self, event_name: str = "test_event", total_number_of_events: int = 100 - ): + def _push_worker(self, event_name: str = "test_event", total_number_of_events: int = 100): for i in range(total_number_of_events): event_descriptor = self.events.descriptors[event_name] if event_descriptor == self.__class__.test_event: @@ -392,15 +410,11 @@ def _push_worker( test_binary_payload_event = Event(doc="test event with binary payload") - test_mixed_content_payload_event = Event( - doc="test event with mixed content payload" - ) + test_mixed_content_payload_event = Event(doc="test event with mixed content payload") test_event_with_json_schema = Event(doc="test event with schema validation") - test_event_with_pydantic_schema = Event( - doc="test event with pydantic schema validation" - ) + test_event_with_pydantic_schema = Event(doc="test event with pydantic schema validation") # --- Examples from existing device implementations @@ -444,13 +458,9 @@ def _push_worker( input_schema=analog_offset_input_schema, output_schema=analog_offset_output_schema, ) - def get_analogue_offset( - self, voltage_range: str, coupling: str - ) -> typing.Tuple[float, float]: + def get_analogue_offset(self, voltage_range: str, coupling: str) -> typing.Tuple[float, float]: """analogue offset for a voltage range and coupling""" - print( - f"get_analogue_offset called with voltage_range={voltage_range}, coupling={coupling}" - ) + print(f"get_analogue_offset called with voltage_range={voltage_range}, coupling={coupling}") return 0.0, 0.0 set_channel_schema = { @@ -536,9 +546,7 @@ def set_channel_pydantic( # ---- Gentec Optical Energy Meter - @action( - input_schema={"type": "string", "enum": ["QE25LP-S-MB", "QE12LP-S-MB-QED-D0"]} - ) + @action(input_schema={"type": "string", "enum": ["QE25LP-S-MB", "QE12LP-S-MB-QED-D0"]}) def set_sensor_model(self, value: str): """ Set the attached sensor to the meter under control. @@ -547,9 +555,7 @@ def set_sensor_model(self, value: str): print(f"set_sensor_model called with value={value}") @action() - def set_sensor_model_pydantic( - self, value: typing.Literal["QE25LP-S-MB", "QE12LP-S-MB-QED-D0"] - ): + def set_sensor_model_pydantic(self, value: typing.Literal["QE25LP-S-MB", "QE12LP-S-MB-QED-D0"]): """ Set the attached sensor to the meter under control. Sensor should be defined as a class and added to the AllowedSensors dict. @@ -582,17 +588,13 @@ def start_acquisition(self, max_count: typing.Annotated[int, Field(gt=0)]): # ----- Serial Utility @action() - def execute_instruction( - self, command: str, return_data_size: typing.Annotated[int, Field(ge=0)] = 0 - ) -> str: + def execute_instruction(self, command: str, return_data_size: typing.Annotated[int, Field(ge=0)] = 0) -> str: """ executes instruction given by the ASCII string parameter 'command'. If return data size is greater than 0, it reads the response and returns the response. Return Data Size - in bytes - 1 ASCII character = 1 Byte. """ - print( - f"execute_instruction called with command={command}, return_data_size={return_data_size}" - ) + print(f"execute_instruction called with command={command}, return_data_size={return_data_size}") return b"" @@ -605,9 +607,7 @@ def replace_methods_with_actions(thing_cls: typing.Type[TestThing]) -> None: if not isinstance(thing_cls.action_echo_with_classmethod, (Action, BoundAction)): # classmethod can be decorated with action - thing_cls.action_echo_with_classmethod = action()( - thing_cls.action_echo_with_classmethod - ) + thing_cls.action_echo_with_classmethod = action()(thing_cls.action_echo_with_classmethod) # BoundAction already, cannot call __set_name__ on it, at least at the time of writing exposed_actions.append("action_echo_with_classmethod") @@ -617,42 +617,28 @@ def replace_methods_with_actions(thing_cls: typing.Type[TestThing]) -> None: thing_cls.action_echo_async.__set_name__(thing_cls, "action_echo_async") exposed_actions.append("action_echo_async") - if not isinstance( - thing_cls.action_echo_async_with_classmethod, (Action, BoundAction) - ): + if not isinstance(thing_cls.action_echo_async_with_classmethod, (Action, BoundAction)): # async classmethods can be decorated with action - thing_cls.action_echo_async_with_classmethod = action()( - thing_cls.action_echo_async_with_classmethod - ) + thing_cls.action_echo_async_with_classmethod = action()(thing_cls.action_echo_async_with_classmethod) # BoundAction already, cannot call __set_name__ on it, at least at the time of writing exposed_actions.append("action_echo_async_with_classmethod") if not isinstance(thing_cls.parameterized_action, (Action, BoundAction)): # parameterized function can be decorated with action - thing_cls.parameterized_action = action(safe=True)( - thing_cls.parameterized_action - ) + thing_cls.parameterized_action = action(safe=True)(thing_cls.parameterized_action) thing_cls.parameterized_action.__set_name__(thing_cls, "parameterized_action") exposed_actions.append("parameterized_action") - if not isinstance( - thing_cls.parameterized_action_without_call, (Action, BoundAction) - ): + if not isinstance(thing_cls.parameterized_action_without_call, (Action, BoundAction)): thing_cls.parameterized_action_without_call = action(idempotent=True)( thing_cls.parameterized_action_without_call ) - thing_cls.parameterized_action_without_call.__set_name__( - thing_cls, "parameterized_action_without_call" - ) + thing_cls.parameterized_action_without_call.__set_name__(thing_cls, "parameterized_action_without_call") exposed_actions.append("parameterized_action_without_call") if not isinstance(thing_cls.parameterized_action_async, (Action, BoundAction)): - thing_cls.parameterized_action_async = action(synchronous=True)( - thing_cls.parameterized_action_async - ) - thing_cls.parameterized_action_async.__set_name__( - thing_cls, "parameterized_action_async" - ) + thing_cls.parameterized_action_async = action(synchronous=True)(thing_cls.parameterized_action_async) + thing_cls.parameterized_action_async.__set_name__(thing_cls, "parameterized_action_async") exposed_actions.append("parameterized_action_async") if not isinstance(thing_cls.json_schema_validated_action, (Action, BoundAction)): @@ -672,18 +658,12 @@ def replace_methods_with_actions(thing_cls: typing.Type[TestThing]) -> None: "properties": {"val1": {"type": "integer"}, "val3": {"type": "object"}}, }, )(thing_cls.json_schema_validated_action) - thing_cls.json_schema_validated_action.__set_name__( - thing_cls, "json_schema_validated_action" - ) + thing_cls.json_schema_validated_action.__set_name__(thing_cls, "json_schema_validated_action") exposed_actions.append("json_schema_validated_action") if not isinstance(thing_cls.pydantic_validated_action, (Action, BoundAction)): - thing_cls.pydantic_validated_action = action()( - thing_cls.pydantic_validated_action - ) - thing_cls.pydantic_validated_action.__set_name__( - thing_cls, "pydantic_validated_action" - ) + thing_cls.pydantic_validated_action = action()(thing_cls.pydantic_validated_action) + thing_cls.pydantic_validated_action.__set_name__(thing_cls, "pydantic_validated_action") exposed_actions.append("pydantic_validated_action") replace_methods_with_actions._exposed_actions = exposed_actions