Skip to content

v0.2.10 #66

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 36 commits into from
Apr 5, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
cbecce7
remove auto-testing and change it to manual along with branch protect…
VigneshVSV Oct 5, 2024
aedccce
update README
Oct 24, 2024
66bb69a
update README
Oct 24, 2024
532a9fa
update README
Oct 24, 2024
d5a9cc9
update README
Oct 24, 2024
bfc4009
udpate changelog
VigneshVSV Nov 3, 2024
c53cfe7
update examples
VigneshVSV Nov 3, 2024
46ceb3f
update README
Nov 6, 2024
bdb60b4
update readme
Nov 21, 2024
54a3bcb
update readme
Dec 2, 2024
bb025ec
generic enhacements especially pydantic & JSON schema validation for …
VigneshVSV Dec 6, 2024
f4e947d
added test for pydantic and JSON schema properties
VigneshVSV Dec 6, 2024
40bedc8
added test for pydantic and JSON schema properties
VigneshVSV Dec 6, 2024
8102c74
add pydantic to test requirements
VigneshVSV Dec 6, 2024
dea641f
account property model in TD correctly for JSON schema based model
VigneshVSV Dec 6, 2024
ba54579
subthing now re-exposed
VigneshVSV Dec 7, 2024
bd84d55
add subthing to changelog
VigneshVSV Dec 7, 2024
13f51c8
Merge pull request #52 from VigneshVSV/subthing
VigneshVSV Dec 7, 2024
781470a
bump version 0.2.8
VigneshVSV Dec 7, 2024
4c3c7ac
Merge branch 'develop' into main
VigneshVSV Jan 10, 2025
687a7bd
announce next release features
VigneshVSV Feb 2, 2025
fe23ae4
reinstate tests on main branch and bug fix to accept null/None value …
VigneshVSV Mar 24, 2025
d7ae9bb
v0.2.9 release changes
VigneshVSV Mar 25, 2025
b6dcb48
propose fix for issue 61
zh3nl Apr 1, 2025
ba0428f
implement refactor suggestions
zh3nl Apr 1, 2025
fff5281
set getter and setter decorator to return self
zh3nl Apr 1, 2025
3a0e431
adjusted test cases for more instance access checks and revert change…
zh3nl Apr 3, 2025
8f7cff9
attempt to test case failures
zh3nl Apr 5, 2025
35daf36
forgot to change this
zh3nl Apr 5, 2025
ce5643f
implemented changes as described
zh3nl Apr 5, 2025
f277536
Merge pull request #62 from zh3nl/issue-61
VigneshVSV Apr 5, 2025
87f45d2
fixes delattr implementation and explicitly binds class or instance t…
VigneshVSV Apr 5, 2025
8a09e3e
suppress log messages in test_property
VigneshVSV Apr 5, 2025
13db7f0
update changelog
VigneshVSV Apr 5, 2025
b2adf84
improve delattr & fdel implementation and add descriptor access tests…
VigneshVSV Apr 5, 2025
a127907
Merge pull request #65 from hololinked-dev/issue-61
VigneshVSV Apr 5, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/FUNDING.yml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# FUNDING.yml

github: VigneshVSV
open_collective: hololinked-dev
buy_me_a_coffee: vigneshvsv
thanks_dev: gh/vigneshvsv
open_collective: hololinked-dev
5 changes: 2 additions & 3 deletions .github/workflows/test-dev.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,13 @@ name: Unit Tests For Development

on:
workflow_dispatch:
push:
pull_request:
branches:
- main
pull_request:
push:
branches:
- main


jobs:
test:
strategy:
Expand Down
33 changes: 27 additions & 6 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,19 +9,40 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

✓ means ready to try

New:
## [v0.3.0] - 2025 between Apr-Jun

This release will contain a lot of new features and improvements so that a version 1.0.0 may be published sooner.
- better conceptual alignment with WoT in code structure
- docs recreated in mkdocs-material, more examples and full coverage of features (hosted [here for now](https://docs.hololinked.dev))
- more tests coverage (over 70%)
- easier to add more protocols (like MQTT, CoAP, etc)
- adding custom handlers for each property, action and event to override default behaviour for HTTP protocol
- schedule async methods & threaded actions more easily
- supports pydantic models for action schema validation, directly with python typing annotations - no need explicitly specify schema.
- bug fix to create pydantic models for properties during validation & pass the model to the setter (instead of JSON)
- bug fix to remove shared state machine for multiple instances of the same class in the same process
- bug fix state machine instance creation for multiple instances of the same class in the same process
Not finalised:
- cookie auth & its specification in TD (cookie auth branch)
- adding custom handlers for each property, action and event to override default behaviour
- pydantic support for property models

Bug Fixes:
- composed sub`Thing`s exposed with correct URL path ✓
## [v0.2.10] - 2025-04-05

- bug fixes to support `class_member` properties to work with `fget`, `fset` and `fdel` methods. While using custom `fget`, `fset` and `fdel` methods for `class_member`s,
the class will be passed as the first argument.

## [v0.2.9] - 2025-03-25

- bug fix to execute action when payload is explicitly null in a HTTP request. Whether action takes a payload or not, there was an error which caused the execution to be rejected.

## [v0.2.8] - 2024-12-07

- pydantic & JSON schema support for property models
- composed sub`Thing`s exposed with correct URL path

## [v0.2.7] - 2024-10-22

- HTTP SSE would previously remain unclosed when client abruptly disconnected (like closing a browser tab), but now it would close correctly
- retrieve unserialized data from events with `ObjectProxy` (like JPEG images) by setting `deserialize=False` in `subscribe_event()`
-

## [v0.2.6] - 2024-09-09

Expand Down
18 changes: 6 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ For those that understand, this package is a ZMQ/HTTP-RPC.

### To Install

From pip - ``pip install hololinked``
From pip - ``pip install hololinked`` <br>
From conda - `conda install -c conda-forge hololinked`

Or, clone the repository (main branch for latest codebase) and install `pip install .` / `pip install -e .`. The conda env ``hololinked.yml`` can also help to setup all dependencies.
Expand All @@ -28,7 +28,7 @@ Each device or thing can be controlled systematically when their design in softw
- the hardware is (generally) represented by a class
- properties are validated get-set attributes of the class which may be used to model settings, hold captured/computed data or generic network accessible quantities
- actions are methods which issue commands like connect/disconnect, execute a control routine, start/stop measurement, or run arbitray python logic
- events can asynchronously communicate/push (arbitrary) data to a client (say, a GUI), like alarm messages, streaming measured quantities etc.
- events can asynchronously communicate/push arbitrary data to a client, like alarm messages, streaming measured quantities etc.

In this package, the base class which enables this classification is the `Thing` class. Any class that inherits the `Thing` class
can instantiate properties, actions and events which become visible to a client in this segragated manner. For example, consider an optical spectrometer, the following code is possible:
Expand Down Expand Up @@ -136,7 +136,7 @@ Those familiar with Web of Things (WoT) terminology may note that these properti
},
```
If you are <span style="text-decoration: underline">not familiar</span> with Web of Things or the term "property affordance", consider the above JSON as a description of
what the property represents and how to interact with it from somewhere else. Such a JSON is both human-readable, yet consumable by any application that may use the property, say a client provider to create a client object to interact with the property or a GUI application to autogenerate a suitable input field for this property.
what the property represents and how to interact with it from somewhere else. Such a JSON is both human-readable, yet consumable by any application that may use the property - say, a client provider to create a client object to interact with the property or a GUI application to autogenerate a suitable input field for this property.
For example, the Eclipse ThingWeb [node-wot](https://github.com/eclipse-thingweb/node-wot) supports this feature to produce a HTTP(s) client that can issue `readProperty("integration_time")` and `writeProperty("integration_time", 1000)` to read and write this property.

The URL path segment `../spectrometer/..` in href field is taken from the `instance_name` which was specified in the `__init__`.
Expand Down Expand Up @@ -314,20 +314,14 @@ See a list of currently supported possibilities while using this package [below]

> You may use a script deployment/automation tool to remote stop and start servers, in an attempt to remotely control your hardware scripts.

### Looking for sponsorships

Kindly read my message [in my README](https://github.com/VigneshVSV#sponsor)

### A little more about Usage

One may use the HTTP API according to one's beliefs (including letting the package auto-generate it), but it is mainly intended for web development and cross platform clients
like the interoperable [node-wot](https://github.com/eclipse-thingweb/node-wot) HTTP(s) client. If your plan is to develop a truly networked system, it is recommended to learn more and
se [Thing Descriptions](https://www.w3.org/TR/wot-thing-description11) to describe your hardware. A Thing Description will be automatically generated if absent as shown in JSON examples above or can be supplied manually. The default end point to
fetch thing descriptions are: <br> `http(s)://<host name>/<instance name of the thing>/resources/wot-td`
The HTTP API may be autogenerated or adjusted by the user. If your plan is to develop a truly networked system, it is recommended to learn more and
use [Thing Descriptions](https://www.w3.org/TR/wot-thing-description11) to describe your hardware (This is optional and one can still use a classic HTTP client). A Thing Description will be automatically generated if absent as shown in JSON examples above or can be supplied manually. The default end point to fetch thing descriptions are: <br> `http(s)://<host name>/<instance name of the thing>/resources/wot-td` <br>
If there are errors in generation of Thing Description
(mostly due to JSON non-complaint types), one could use: <br> `http(s)://<host name>/<instance name of the thing>/resources/wot-td?ignore_errors=true`

(client docs will be updated here next)
(client docs will be updated here next, also check official docs)

### Currently Supported

Expand Down
2 changes: 1 addition & 1 deletion examples
2 changes: 1 addition & 1 deletion hololinked/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
__version__ = "0.2.7"
__version__ = "0.2.10"
2 changes: 1 addition & 1 deletion hololinked/client/proxy.py
Original file line number Diff line number Diff line change
Expand Up @@ -783,7 +783,7 @@ def add_callbacks(self, callbacks : typing.Union[typing.List[typing.Callable], t
self._callbacks.append(callbacks)

def subscribe(self, callbacks : typing.Union[typing.List[typing.Callable], typing.Callable],
thread_callbacks : bool = False, deserialize : bool = True):
thread_callbacks : bool = False, deserialize : bool = True) -> None:
self._event_consumer = EventConsumer(
'zmq-' + self._unique_identifier if self._serialization_specific else self._unique_identifier,
self._socket_address, f"{self._name}|RPCEvent|{uuid.uuid4()}", b'PROXY',
Expand Down
92 changes: 71 additions & 21 deletions hololinked/param/parameterized.py
Original file line number Diff line number Diff line change
Expand Up @@ -354,7 +354,7 @@ def _post_slot_set(self, slot : str, old : typing.Any, value : typing.Any) -> No
self.default = self.validate_and_adapt(self.default)

def __get__(self, obj : typing.Union['Parameterized', typing.Any],
objtype : typing.Union['ParameterizedMetaclass', typing.Any]) -> typing.Any: # pylint: disable-msg=W0613
objtype : typing.Union['ParameterizedMetaclass', typing.Any]) -> typing.Any: # pylint: disable-msg=W0613
"""
Return the value for this Parameter.

Expand All @@ -367,8 +367,13 @@ def __get__(self, obj : typing.Union['Parameterized', typing.Any],
class's value (default).
"""
if self.class_member:
return objtype.__dict__.get(self._internal_name, self.default)
if obj is None:
if self.fget is not None:
# self.fdef.__get__(None, objtype) is the same as self.fdel
return self.fget(objtype)
return getattr(objtype, self._internal_name, self.default)
if obj is None:
# this is a precedence why __get__ should be called with None for class_member
# therefore class_member logic above is handled in that way
return self
if self.fget is not None:
return self.fget(obj)
Expand Down Expand Up @@ -404,23 +409,30 @@ def __set__(self, obj : typing.Union['Parameterized', typing.Any], value : typin
raise_ValueError("Read-only parameter cannot be set/modified.", self)

value = self.validate_and_adapt(value)

obj = obj if not self.class_member else self.owner


if self.class_member and obj is not self.owner: # safety check
obj = self.owner

old = NotImplemented
if self.constant:
old = None
if (obj.__dict__.get(self._internal_name, NotImplemented) != NotImplemented) or self.default is not None:
if (getattr(obj, self._internal_name, NotImplemented) != NotImplemented) or self.default is not None:
# Dont even entertain any type of setting, even if its the same value
raise_ValueError("Constant parameter cannot be modified.", self)
else:
old = obj.__dict__.get(self._internal_name, self.default)
old = getattr(obj, self._internal_name, self.default)

# The following needs to be optimised, probably through lambda functions?
if self.fset is not None:
# for class_member, self.fset.__get__(None, obj) is same as self.fset
self.fset(obj, value)
else:
obj.__dict__[self._internal_name] = value
if self.class_member:
# For class properties, store the value in the class's __dict__ using setattr
# as mapping proxy does not allow setting values directly
setattr(obj, self._internal_name, value)
else:
obj.__dict__[self._internal_name] = value

self._post_value_set(obj, value)

Expand Down Expand Up @@ -453,7 +465,13 @@ def __set__(self, obj : typing.Union['Parameterized', typing.Any], value : typin

def __delete__(self, obj : typing.Union['Parameterized', typing.Any]) -> None:
if self.fdel is not None:
return self.fdel(obj)
if self.class_member:
# For class properties, bind the deletor to the class,
# especially when this method is called as del instance.parameter_name
# which will make obj take the value of the instance.
return self.fdel(self.owner)
elif obj is not self.owner:
return self.fdel(obj)
raise NotImplementedError("Parameter deletion not implemented.")

def validate_and_adapt(self, value : typing.Any) -> typing.Any:
Expand Down Expand Up @@ -1444,6 +1462,7 @@ def trigger(self, *parameters : str) -> None:
changed for a Parameter of type Event, setting it to True so
that it is clear which Event parameter has been triggered.
"""
raise NotImplementedError(wrap_error_text("""Triggering of events is not supported due to incomplete logic."""))
trigger_params = [p for p in self_.self_or_cls.param
if hasattr(self_.self_or_cls.param[p], '_autotrigger_value')]
triggers = {p:self_.self_or_cls.param[p]._autotrigger_value
Expand Down Expand Up @@ -1787,19 +1806,50 @@ def __setattr__(mcs, attribute_name : str, value : typing.Any) -> None:
# class attribute of this class - if not, parameter is None.
if attribute_name != '_param_container' and attribute_name != '__%s_params__' % mcs.__name__:
parameter = mcs.parameters.descriptors.get(attribute_name, None)
# checking isinstance(value, Parameter) will not work for ClassSelector
# and besides value is anyway validated. On the downside, this does not allow
# altering of parameter instances if class already of the parameter with attribute_name
if parameter: # and not isinstance(value, Parameter):
# if owning_class != mcs:
# parameter = copy.copy(parameter)
# parameter.owner = mcs
# type.__setattr__(mcs, attribute_name, parameter)
mcs.__dict__[attribute_name].__set__(mcs, value)
parameter.__set__(mcs, value)
return
# set with None should not supported as with mcs it supports
# class attributes which can be validated
type.__setattr__(mcs, attribute_name, value)
return type.__setattr__(mcs, attribute_name, value)

def __getattr__(mcs, attribute_name : str) -> typing.Any:
"""
Implements 'self.attribute_name' in a way that also supports Parameters.

If there is a Parameter descriptor named attribute_name, it will be
retrieved using the descriptor protocol.
"""
if attribute_name != '_param_container' and attribute_name != '__%s_params__' % mcs.__name__:
parameter = mcs.parameters.descriptors.get(attribute_name, None)
if parameter and parameter.class_member:
return parameter.__get__(None, mcs)
return type.__getattr__(mcs, attribute_name)

def __delattr__(mcs, attribute_name : str) -> None:
"""
Implements 'del self.attribute_name' in a way that also supports Parameters.

If there is a Parameter descriptor named attribute_name, it will be deleted
from the class. This is different from setting the parameter value to None,
as it completely removes the parameter from the class.
"""
if attribute_name != '_param_container' and attribute_name != '__%s_params__' % mcs.__name__:
parameter = mcs.parameters.descriptors.get(attribute_name, None)
if parameter:
# Delete the parameter from the descriptors dictionary
try:
parameter.__delete__(mcs)
return
except NotImplementedError: # raised by __delete__ if fset is not defined
del mcs.parameters.descriptors[attribute_name]
# Delete the parameter from the instance parameters dictionary
try:
delattr(mcs, '__%s_params__' % mcs.__name__)
except AttributeError:
pass
# After deleting the parameter from our own reference,
# we also delete it from the class, so dont return but pass the call
# to type.__delattr__
return type.__delattr__(mcs, attribute_name)



Expand Down
15 changes: 10 additions & 5 deletions hololinked/server/HTTPServer.py
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,7 @@ def all_ok(self) -> bool:
)
# print("client pool context", self.zmq_client_pool.context)
event_loop = EventLoop.get_async_loop() # sets async loop for a non-possessing thread as well
event_loop.call_soon(lambda : asyncio.create_task(self.update_router_with_things()))
self.update_router_with_things()
event_loop.call_soon(lambda : asyncio.create_task(self.subscribe_to_host()))
event_loop.call_soon(lambda : asyncio.create_task(self.zmq_client_pool.poll()) )
for client in self.zmq_client_pool:
Expand Down Expand Up @@ -258,19 +258,22 @@ async def stop(self) -> None:
self.tornado_event_loop.stop()


async def update_router_with_things(self) -> None:
def update_router_with_things(self) -> None:
"""
updates HTTP router with paths from ``Thing`` (s)
"""
await asyncio.gather(*[self.update_router_with_thing(client) for client in self.zmq_client_pool])
event_loop = EventLoop.get_async_loop() # sets async loop for a non-possessing thread as well
for client in self.zmq_client_pool:
event_loop.call_soon(lambda : asyncio.create_task(self.update_router_with_thing(client)))



async def update_router_with_thing(self, client : AsyncZMQClient):
if client.instance_name in self._lost_things:
# Just to avoid duplication of this call as we proceed at single client level and not message mapped level
return
self._lost_things[client.instance_name] = client
self.logger.info(f"attempting to update router with thing {client.instance_name}.")
self._lost_things[client.instance_name] = client
while True:
try:
await client.handshake_complete()
Expand Down Expand Up @@ -354,6 +357,7 @@ def __init__(
arguments=dict(value=object_info),
raise_client_side_exception=True
)
self.logger.info(f"updated ThingInformation to {client.instance_name}")
except Exception as ex:
self.logger.error(f"error while trying to update thing with HTTP server details - {str(ex)}. " +
"Trying again in 5 seconds")
Expand Down Expand Up @@ -388,7 +392,8 @@ def add_event(self, URL_path : str, event : Event, handler : typing.Optional[Bas
kwargs=kwargs)
if obj not in self._local_rules[event.owner.__name__]:
self._local_rules[event.owner.__name__].append(obj)




__all__ = [
HTTPServer.__name__
Expand Down
7 changes: 4 additions & 3 deletions hololinked/server/dataklasses.py
Original file line number Diff line number Diff line change
Expand Up @@ -565,12 +565,13 @@ def get_organised_resources(instance):
assert isinstance(resource, Thing), ("thing children query from inspect.ismethod is not a Thing",
"logic error - visit https://github.com/VigneshVSV/hololinked/issues to report")
# above assertion is only a typing convenience
if name == '_owner' or resource._owner is not None:
if name == '_owner':
# second condition allows sharing of Things without adding once again to the list of exposed resources
# for example, a shared logger
continue
resource._owner = instance
resource._prepare_resources()
if resource._owner is None:
resource._owner = instance
resource._prepare_resources()
httpserver_resources.update(resource.httpserver_resources)
# zmq_resources.update(resource.zmq_resources)
instance_resources.update(resource.instance_resources)
Expand Down
Loading
Loading