Skip to content

Commit a81cce3

Browse files
authored
V0.3 content type bug fixes (#122)
* update doc to latest commit & changelog to latest status * add logic to overwrite form and carry over content type from the ZMQ layer * do basic test of content types * update changelog and readme * implement __deepcopy__ and __getstate__ * codestyle in helper scripts * code style & general updates * rearrange operation flow in RPC broker * support numpy in message pack * some more bug fix * finalize msgpack numpy with pydantic * commit ipynb * add a gitattributes * update changelog
1 parent e005d97 commit a81cce3

24 files changed

+967
-739
lines changed

.gitattributes

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
*.ipynb export-ignore
2+
*.ipynb linguist-vendored

.gitlab-ci.yml

Lines changed: 0 additions & 9 deletions
This file was deleted.

CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,15 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
88

99
✓ means ready to try
1010

11+
## [v0.3.4] - 2025-10-02
12+
13+
- fixes a bug in content type in the forms of TD for HTTP protocol binding, when multiple serializers are used
14+
- one can specify numpy array or arbitrary python objects in pydantic models for properties, actions and events
15+
16+
## [v0.3.3] - 2025-09-25
17+
18+
- updates API reference largely to latest version
19+
1120
## [v0.3.2] - 2025-09-21
1221

1322
- adds TD security definition for BCryptBasicSecurity and ArgsBasicSecurity

README.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -313,7 +313,10 @@ if __name__ == '__main__':
313313
id='spectrometer',
314314
serial_number='S14155',
315315
).run(
316-
access_points=['HTTP', 'ZMQ-IPC']
316+
access_points=[
317+
("ZMQ", "IPC"),
318+
("HTTP", 8080),
319+
]
317320
)
318321
# HTTP & ZMQ Interprocess Communication
319322
```

doc

Submodule doc updated from 633d018 to afa0e64

hololinked/client/http/consumed_interactions.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,8 @@ def get_body_from_response(
4949
body = response.content
5050
if not body:
5151
return
52-
serializer = Serializers.content_types.get(form.contentType or "application/json")
52+
givenContentType = response.headers.get("Content-Type", None)
53+
serializer = Serializers.content_types.get(givenContentType or form.contentType or "application/json")
5354
if serializer is None:
5455
raise ValueError(f"Unsupported content type: {form.contentType}")
5556
body = serializer.loads(body)

hololinked/core/property.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -329,7 +329,7 @@ def to_affordance(self, owner_inst=None):
329329

330330

331331
try:
332-
from pydantic import BaseModel, RootModel, create_model
332+
from pydantic import BaseModel, RootModel, create_model, ConfigDict
333333

334334
def wrap_plain_types_in_rootmodel(model: type) -> type[BaseModel] | type[RootModel]:
335335
"""
@@ -344,7 +344,12 @@ def wrap_plain_types_in_rootmodel(model: type) -> type[BaseModel] | type[RootMod
344344
return
345345
if issubklass(model, BaseModel):
346346
return model
347-
return create_model(f"{model!r}", root=(model, ...), __base__=RootModel)
347+
return create_model(
348+
f"{model!r}",
349+
root=(model, ...),
350+
__base__=RootModel,
351+
__config__=ConfigDict(arbitrary_types_allowed=True),
352+
) # type: ignore[call-overload]
348353
except ImportError:
349354

350355
def wrap_plain_types_in_rootmodel(model: type) -> type:

hololinked/core/thing.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -349,9 +349,9 @@ def run(
349349
access_points = kwargs.get("access_points", None) # type: dict[str, dict | int | str | list[str]]
350350
servers = kwargs.get("servers", []) # type: typing.Optional[typing.List[BaseProtocolServer]]
351351

352-
if access_points is None and servers is None:
352+
if access_points is None and len(servers) == 0:
353353
raise ValueError("At least one of access_points or servers must be provided.")
354-
if access_points is not None and servers is not None:
354+
if access_points is not None and len(servers) > 0:
355355
raise ValueError("Only one of access_points or servers can be provided.")
356356

357357
if access_points is not None:

hololinked/core/zmq/brokers.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2228,7 +2228,7 @@ def interrupt_message(self) -> EventMessage:
22282228
return EventMessage.craft_from_arguments(
22292229
event_id=f"{self.id}/interrupting-server",
22302230
sender_id=self.id,
2231-
payload=SerializableData("INTERRUPT"),
2231+
payload=SerializableData("INTERRUPT", content_type="application/json"),
22322232
)
22332233

22342234
def exit(self):

hololinked/core/zmq/rpc_server.py

Lines changed: 71 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
set_global_event_loop_policy,
2121
)
2222
from ...config import global_config
23-
from ...serializers import Serializers
23+
from ...serializers import Serializers, BaseSerializer
2424
from .message import (
2525
EMPTY_BYTE,
2626
ERROR,
@@ -33,7 +33,7 @@
3333
from ..thing import Thing
3434
from ..property import Property
3535
from ..properties import TypedDict
36-
from ..actions import BoundAction, action as remote_method
36+
from ..actions import BoundAction
3737
from ..logger import LogHistoryHandler
3838

3939

@@ -377,88 +377,64 @@ async def run_thing_instance(self, instance: Thing, scheduler: typing.Optional["
377377
return_value = await self.execute_operation(instance, objekt, operation, payload, preserialized_payload)
378378

379379
# handle return value
380-
if (
381-
isinstance(return_value, tuple)
382-
and len(return_value) == 2
383-
and (isinstance(return_value[1], bytes) or isinstance(return_value[1], PreserializedData))
384-
):
385-
if fetch_execution_logs:
386-
return_value[0] = {
387-
"return_value": return_value[0],
388-
"execution_logs": list_handler.log_list,
389-
}
390-
payload = SerializableData(
391-
return_value[0],
392-
Serializers.for_object(thing_id, instance.__class__.__name__, objekt),
393-
)
394-
if isinstance(return_value[1], bytes):
395-
preserialized_payload = PreserializedData(return_value[1])
396-
# elif isinstance(return_value, PreserializedData):
397-
# if fetch_execution_logs:
398-
# return_value = {
399-
# "return_value" : return_value.value,
400-
# "execution_logs" : list_handler.log_list
401-
# }
402-
# payload = SerializableData(return_value.value, content_type='application/json')
403-
# preserialized_payload = return_value
404-
405-
elif isinstance(return_value, bytes):
406-
payload = SerializableData(None, content_type="application/json")
407-
preserialized_payload = PreserializedData(return_value)
408-
else:
409-
# complete thing execution context
410-
if fetch_execution_logs:
411-
return_value = {
412-
"return_value": return_value,
413-
"execution_logs": list_handler.log_list,
414-
}
415-
payload = SerializableData(
416-
return_value,
417-
Serializers.for_object(thing_id, instance.__class__.__name__, objekt),
418-
)
419-
preserialized_payload = PreserializedData(EMPTY_BYTE, content_type="text/plain")
380+
serializer = Serializers.for_object(thing_id, instance.__class__.__name__, objekt)
381+
rpayload, rpreserialized_payload = self.format_return_value(return_value, serializer=serializer)
382+
383+
# complete thing execution context
384+
if fetch_execution_logs:
385+
rpayload.value = dict(return_value=rpayload.value, execution_logs=list_handler.log_list)
386+
387+
# raise any payload errors now
388+
rpayload.require_serialized()
389+
420390
# set reply
421-
scheduler.last_operation_reply = (payload, preserialized_payload, REPLY)
391+
scheduler.last_operation_reply = (rpayload, rpreserialized_payload, REPLY)
392+
422393
except BreakInnerLoop:
423394
# exit the loop and stop the thing
424395
instance.logger.info(
425-
"Thing {} with instance name {} exiting event loop.".format(
426-
instance.__class__.__name__, instance.id
427-
)
396+
"Thing {} with id {} exiting event loop.".format(instance.__class__.__name__, instance.id)
428397
)
429-
return_value = None
398+
399+
# send a reply with None return value
400+
rpayload, rpreserialized_payload = self.format_return_value(None, Serializers.json)
401+
402+
# complete thing execution context
430403
if fetch_execution_logs:
431-
return_value = {
432-
"return_value": None,
433-
"execution_logs": list_handler.log_list,
434-
}
435-
scheduler.last_operation_reply = (
436-
SerializableData(return_value, content_type="application/json"),
437-
PreserializedData(EMPTY_BYTE, content_type="text/plain"),
438-
None,
439-
)
440-
return
404+
rpayload.value = dict(return_value=rpayload.value, execution_logs=list_handler.log_list)
405+
406+
# set reply, let the message broker decide
407+
scheduler.last_operation_reply = (rpayload, rpreserialized_payload, None)
408+
409+
# quit the loop
410+
break
411+
441412
except Exception as ex:
442413
# error occurred while executing the operation
443414
instance.logger.error(
444415
"Thing {} with ID {} produced error : {} - {}.".format(
445416
instance.__class__.__name__, instance.id, type(ex), ex
446417
)
447418
)
448-
return_value = dict(exception=format_exception_as_json(ex))
449-
if fetch_execution_logs:
450-
return_value["execution_logs"] = list_handler.log_list
451-
scheduler.last_operation_reply = (
452-
SerializableData(return_value, content_type="application/json"),
453-
PreserializedData(EMPTY_BYTE, content_type="text/plain"),
454-
ERROR,
419+
420+
# send a reply with error
421+
rpayload, rpreserialized_payload = self.format_return_value(
422+
dict(exception=format_exception_as_json(ex)), Serializers.json
455423
)
424+
425+
# complete thing execution context
426+
if fetch_execution_logs:
427+
rpayload.value["execution_logs"] = list_handler.log_list
428+
429+
# set error reply
430+
scheduler.last_operation_reply = (rpayload, rpreserialized_payload, ERROR)
431+
456432
finally:
457433
# cleanup
458434
if fetch_execution_logs:
459435
instance.logger.removeHandler(list_handler)
460436
instance.logger.debug(
461-
"thing {} with instance name {} completed execution of operation {} on {}".format(
437+
"thing {} with id {} completed execution of operation {} on {}".format(
462438
instance.__class__.__name__, instance.id, operation, objekt
463439
)
464440
)
@@ -501,17 +477,19 @@ async def execute_operation(
501477
elif operation == Operations.deleteproperty:
502478
prop = instance.properties[objekt] # type: Property
503479
del prop # raises NotImplementedError when deletion is not implemented which is mostly the case
480+
elif operation == Operations.invokeaction and objekt == "get_thing_description":
481+
# special case
482+
if payload is None:
483+
payload = dict()
484+
args = payload.pop("__args__", tuple())
485+
return self.get_thing_description(instance, *args, **payload)
504486
elif operation == Operations.invokeaction:
505487
if payload is None:
506488
payload = dict()
507489
args = payload.pop("__args__", tuple())
508490
# payload then become kwargs
509491
if preserialized_payload != EMPTY_BYTE:
510492
args = (preserialized_payload,) + args
511-
# special case
512-
if objekt == "get_thing_description":
513-
return self.get_thing_description(instance, *args, **payload)
514-
# normal Thing action
515493
action = instance.actions[objekt] # type: BoundAction
516494
if action.execution_info.iscoroutine:
517495
# the actual scheduling as a purely async task is done by the scheduler, not here,
@@ -528,6 +506,30 @@ async def execute_operation(
528506
"Unimplemented execution path for Thing {} for operation {}".format(instance.id, operation)
529507
)
530508

509+
def format_return_value(
510+
self,
511+
return_value: typing.Any,
512+
serializer: BaseSerializer,
513+
) -> tuple[SerializableData, PreserializedData]:
514+
if (
515+
isinstance(return_value, tuple)
516+
and len(return_value) == 2
517+
and (isinstance(return_value[1], bytes) or isinstance(return_value[1], PreserializedData))
518+
):
519+
payload = SerializableData(return_value[0], serializer=serializer, content_type=serializer.content_type)
520+
if isinstance(return_value[1], bytes):
521+
preserialized_payload = PreserializedData(return_value[1])
522+
elif isinstance(return_value, bytes):
523+
payload = SerializableData(None, content_type="application/json")
524+
preserialized_payload = PreserializedData(return_value)
525+
elif isinstance(return_value, PreserializedData):
526+
payload = SerializableData(None, content_type="application/json")
527+
preserialized_payload = return_value
528+
else:
529+
payload = SerializableData(return_value, serializer=serializer, content_type=serializer.content_type)
530+
preserialized_payload = PreserializedData(EMPTY_BYTE, content_type="text/plain")
531+
return payload, preserialized_payload
532+
531533
async def _process_timeouts(
532534
self,
533535
request_message: RequestMessage,

0 commit comments

Comments
 (0)