diff --git a/.github/workflows/test.yml b/.github/workflows/test-dev.yml
similarity index 96%
rename from .github/workflows/test.yml
rename to .github/workflows/test-dev.yml
index ce5083b..2900858 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test-dev.yml
@@ -1,4 +1,4 @@
-name: Python Unit Tests
+name: Unit Tests For Development
on:
workflow_dispatch:
diff --git a/.github/workflows/test-release.yml b/.github/workflows/test-release.yml
new file mode 100644
index 0000000..237c69c
--- /dev/null
+++ b/.github/workflows/test-release.yml
@@ -0,0 +1,32 @@
+name: Unit Tests With TestPyPI
+
+on:
+ workflow_dispatch:
+ pull_request:
+ branches:
+ - release
+
+
+jobs:
+ test:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+
+ - name: Set up Python
+ uses: actions/setup-python@v3
+ with:
+ python-version: 3.11
+
+ - name: Install dependencies
+ run: |
+ pip install jsonschema
+ pip install -i https://test.pypi.org/simple/ hololinked
+
+ - name: Run unit tests to verify if the release to TestPyPI is working
+ run: |
+ python -m unittest discover -s tests -p 'test_*.py'
+
+
+
\ No newline at end of file
diff --git a/CHANGELOG.md b/CHANGELOG.md
index e2afd17..58d8e45 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -10,6 +10,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Security
- cookie auth & its specification in TD
+## [v0.2.2] - 2024-08-09
+
+- thing control panel works better with the server side and support observable properties
+- `ObjectProxy` client API has been improved to resemble WoT operations better, for examplem `get_property` is now
+called `read_property`, `set_properties` is now called `write_multiple_properties`.
+- `ObjectProxy` client reliability for poorly written server side actions improved
+
## [v0.2.1] - 2024-07-21
### Added
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 320cf06..f1c59e6 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -47,7 +47,7 @@ Otherwise, I will then take care of the issue as soon as possible.
Developers are always welcome to contribute to the code base. If you want to tackle any issues, un-existing features, let me know (at my email), I can create some open issues and features which I was never able to solve or did not have the time. You can also suggest what else can be contributed functionally or conceptually or also simply code-refactoring. The lack of issues or features in the [Issues](https://github.com/VigneshVSV/hololinked/issues) section of github does not mean the project is considered feature complete or I dont have ideas what to do next. On the contrary, there is tons of work to do.
There are also repositories which can use your skills:
-- An [admin client](https://github.com/VigneshVSV/hololinked-portal) in react
+- An [admin client](https://github.com/VigneshVSV/thing-control-panel) in react
- [Documentation](https://github.com/VigneshVSV/hololinked-docs) in sphinx which needs significant improvement in How-To's, beginner level docs which may teach people concepts of data acquisition or IoT, Docstring or API documentation of this repository itself
- [Examples](https://github.com/VigneshVSV/hololinked-examples) in nodeJS, Dashboard/PyQt GUIs or server implementations using this package. Hardware implementations of unexisting examples are also welcome, I can open a directory where people can search for code based on hardware and just download your code.
diff --git a/README.md b/README.md
index 00f2ed3..1c13871 100644
--- a/README.md
+++ b/README.md
@@ -1,12 +1,12 @@
-# hololinked - Pythonic Supervisory Control & Data Acquisition / Internet of Things
+# hololinked - Pythonic Object-Oriented Supervisory Control & Data Acquisition / Internet of Things
### Description
-For beginners - `hololinked` is a server side pythonic package suited for instrumentation control and data acquisition over network, especially with HTTP. If you have a requirement to control and capture data from your hardware/instrumentation, show the data in a browser/dashboard, provide a GUI or run automated scripts, `hololinked` can help. Even for isolated applications or a small lab setup without networking concepts, one can still separate the concerns of the tools that interact with the hardware & the hardware itself.
-
-For those familiar with RPC & web development - This package is an implementation of a ZeroMQ-based Object Oriented RPC with customizable HTTP end-points. A dual transport in both ZMQ and HTTP is provided to maximize flexibility in data type, serialization and speed, although HTTP is preferred for networked applications. If one is looking for an object oriented approach towards creating components within a control or data acquisition system, or an IoT device, one may consider this package.
+`hololinked` is a server side pythonic tool suited for instrumentation control and data acquisition over network, especially with HTTP. If you have a requirement to control and capture data from your hardware/instrumentation, show the data in a browser/dashboard, provide a GUI or run automated scripts, `hololinked` can help. Even for isolated applications or a small lab setup without networking concepts, one can still separate the concerns of the tools that interact with the hardware & the hardware itself.
-[![Documentation Status](https://readthedocs.org/projects/hololinked/badge/?version=latest)](https://hololinked.readthedocs.io/en/latest/?badge=latest) [![PyPI](https://img.shields.io/pypi/v/hololinked?label=pypi%20package)](https://pypi.org/project/hololinked/) [![PyPI - Downloads](https://img.shields.io/pypi/dm/hololinked)](https://pypistats.org/packages/hololinked) [![codecov](https://codecov.io/gh/VigneshVSV/hololinked/graph/badge.svg?token=JF1928KTFE)](https://codecov.io/gh/VigneshVSV/hololinked)
+[![Documentation Status](https://readthedocs.org/projects/hololinked/badge/?version=latest)](https://hololinked.readthedocs.io/en/latest/?badge=latest) [![PyPI](https://img.shields.io/pypi/v/hololinked?label=pypi%20package)](https://pypi.org/project/hololinked/) [![PyPI - Downloads](https://img.shields.io/pypi/dm/hololinked)](https://pypistats.org/packages/hololinked) [![codecov](https://codecov.io/gh/VigneshVSV/hololinked/graph/badge.svg?token=JF1928KTFE)](https://codecov.io/gh/VigneshVSV/hololinked)
+
+[![email](https://img.shields.io/badge/email%20me-brown)](mailto:vignesh.vaidyanathan@hololinked.dev) [![find me on discord](https://img.shields.io/badge/find_me_on_discord-brown)](https://discord.com/users/1178428338746966066)
### To Install
@@ -19,16 +19,14 @@ Or, clone the repository (develop branch for latest codebase) and install `pip i
`hololinked` is compatible with the [Web of Things](https://www.w3.org/WoT/) recommended pattern for developing hardware/instrumentation control software.
Each device or thing can be controlled systematically when their design in software is segregated into properties, actions and events. In object oriented terms:
- the hardware is (generally) represented by a class
-- properties are validated get-set attributes of the class which may be used to model hardware settings, hold captured/computed data or generic network accessible quantities
+- 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.
-It does not even matter whether you are controlling your hardware locally or remotely, what protocol you use, what is the nature of the client etc.,
-one has to provide these three interactions with the hardware. 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:
+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:
-> This is a fairly mid-level intro, if you are beginner, for another variant check [How-To](https://hololinked.readthedocs.io/en/latest/howto/index.html)
+> This is a fairly mid-level intro focussed on HTTP. If you are beginner or looking for ZMQ which can be used with no networking knowledge, check [How-To](https://hololinked.readthedocs.io/en/latest/howto/index.html)
#### Import Statements
@@ -64,8 +62,8 @@ class OceanOpticsSpectrometer(Thing):
serial_number = String(default=None, allow_None=True, URL_path='/serial-number',
doc="serial number of the spectrometer to connect/or connected",
http_method=("GET", "PUT"))
- # GET and PUT is default for reading and writing the property respectively.
- # Use other HTTP methods if necessary.
+ # GET and PUT is default for reading and writing the property respectively.
+ # So you can leave it out, especially if you are going to use ZMQ and dont understand HTTP
integration_time = Number(default=1000, bounds=(0.001, None), crop_to_bounds=True,
URL_path='/integration-time',
@@ -81,10 +79,9 @@ class OceanOpticsSpectrometer(Thing):
```
In non-expert terms, properties look like class attributes however their data containers are instantiated at object instance level by default.
-For example, the `integratime_time` property defined above as `Number`, whenever set/written, will be validated as a float or int, cropped to bounds and assigned as an attribute to each instance of the `OceanOpticsSpectrometer` class with an internally generated name. It is not necessary to know this internally generated name as the property value can be accessed again in any python logic, say, `print(self.integration_time)`.
+For example, the `integration_time` property defined above as `Number`, whenever set/written, will be validated as a float or int, cropped to bounds and assigned as an attribute to each instance of the `OceanOpticsSpectrometer` class with an internally generated name. It is not necessary to know this internally generated name as the property value can be accessed again in any python logic, say, `print(self.integration_time)`.
To overload the get-set (or read-write) of properties, one may do the following:
-
```python
class OceanOpticsSpectrometer(Thing):
@@ -132,8 +129,7 @@ Those familiar with Web of Things (WoT) terminology may note that these properti
```
If you are not familiar 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 a client provider to create a client object to interact with the property in the way the property demands. You, as the developer,
-only need to use the client.
+by a client provider to create a client object to interact with the property.
The URL path segment `../spectrometer/..` in href field is taken from the `instance_name` which was specified in the `__init__`.
This is a mandatory key word argument to the parent class `Thing` to generate a unique name/id for the instance. One should use URI compatible strings.
@@ -153,6 +149,13 @@ class OceanOpticsSpectrometer(Thing):
self.serial_number = serial_number
self.device = Spectrometer.from_serial_number(self.serial_number)
self._wavelengths = self.device.wavelengths().tolist()
+
+ # So you can leave it out, especially if you are going to use ZMQ and dont understand HTTP
+ @action()
+ def disconnect(self):
+ """disconnect from the spectrometer"""
+ self.device.close()
+
```
Methods that are neither decorated with action decorator nor acting as getters-setters of properties remain as plain python methods and are **not** accessible on the network.
@@ -266,6 +269,8 @@ what the event represents and how to subscribe to it) with subprotocol SSE (HTTP
```
> data schema ("data" field above which describes the event payload) are optional and discussed later
+Events follow a pub-sub model with '1 publisher to N subscribers' per `Event` object, both through ZMQ and HTTP SSE.
+
Although the code is the very familiar & age-old RPC server style, one can directly specify HTTP methods and URL path for each property, action and event. A configurable HTTP Server is already available (from `hololinked.server.HTTPServer`) which redirects HTTP requests to the object according to the specified HTTP API on the properties, actions and events. To plug in a HTTP server:
```python
@@ -284,14 +289,17 @@ if __name__ == '__main__':
log_level=logging.DEBUG
)
O.run_with_http_server(ssl_context=ssl_context)
+ # or O.run(zmq_protocols='IPC') - interprocess communication and no HTTP
+ # or O.run(zmq_protocols=['IPC', 'TCP'], tcp_socket_address='tcp://*:9999')
+ # both interprocess communication & TCP, no HTTP
```
Here one can see the use of `instance_name` and why it turns up in the URL path. See the detailed example of the above code [here](https://gitlab.com/hololinked-examples/oceanoptics-spectrometer/-/blob/simple/oceanoptics_spectrometer/device.py?ref_type=heads).
-##### NOTE - The package is under active development. Contributors welcome, please check CONTRIBUTING.md.
+##### NOTE - The package is under active development. Contributors welcome, please check CONTRIBUTING.md and the open issues. Some issues can also be independently dealt without much knowledge of this package.
- [example repository](https://github.com/VigneshVSV/hololinked-examples) - detailed examples for both clients and servers
-- [helper GUI](https://github.com/VigneshVSV/hololinked-portal) - view & interact with your object's methods, properties and events.
+- [helper GUI](https://github.com/VigneshVSV/thing-control-panel) - view & interact with your object's actions, properties and events.
See a list of currently supported possibilities while using this package [below](#currently-supported).
@@ -306,12 +314,12 @@ One may use the HTTP API according to one's beliefs (including letting the packa
- auto-generate Thing Description for Web of Things applications.
- use serializer of your choice (except for HTTP) - MessagePack, JSON, pickle etc. & extend serialization to suit your requirement. HTTP Server will support only JSON serializer to maintain compatibility with node-wot. Default is JSON serializer based on msgspec.
- asyncio compatible - async RPC server event-loop and async HTTP Server - write methods in async
-- choose from multiple ZeroMQ transport methods. Some of the possibilities one can achieve by choosing ZMQ transport methods:
+- choose from multiple ZeroMQ transport methods which offers some possibilities like the following without changing the code:
- run HTTP Server & python object in separate processes or the same process
- serve multiple objects with the same HTTP server
- run direct ZMQ-TCP server without HTTP details
- expose only a dashboard or web page on the network without exposing the hardware itself
-
+
Again, please check examples or the code for explanations. Documentation is being activety improved.
### Currently being worked
@@ -319,11 +327,12 @@ Again, please check examples or the code for explanations. Documentation is bein
- improving accuracy of Thing Descriptions
- cookie credentials for authentication - as a workaround until credentials are supported, use `allowed_clients` argument on HTTP server which restricts access based on remote IP supplied with the HTTP headers.
+### Internals
+
+This package is an implementation of a ZeroMQ-based Object Oriented RPC with customizable HTTP end-points. A dual transport in both ZMQ and HTTP is provided to maximize flexibility in data type, serialization and speed, although HTTP is preferred for networked applications. If one is looking for an object oriented approach towards creating components within a control or data acquisition system, or an IoT device, one may consider this package.
+
### Some Day In Future
- mongo DB support for DB operations
- HTTP 2.0
-### Contact
-
-Contributors welcome for all my projects related to hololinked including web apps. Please write to my contact email available at my [website](https://hololinked.dev).
diff --git a/doc b/doc
index 68e1be2..a064864 160000
--- a/doc
+++ b/doc
@@ -1 +1 @@
-Subproject commit 68e1be22ce184ec0c28eafb9b8a715a1a6bc9d33
+Subproject commit a064864119dd4270a69b38621d79678a9f1b8069
diff --git a/examples b/examples
index 52897aa..8a70beb 160000
--- a/examples
+++ b/examples
@@ -1 +1 @@
-Subproject commit 52897aa0bd3e84af3a3fb41eda5c3a2e14cf4024
+Subproject commit 8a70bebddd4da7d66674ec3e3da469db120dab70
diff --git a/hololinked/__init__.py b/hololinked/__init__.py
index 3ced358..b5fdc75 100644
--- a/hololinked/__init__.py
+++ b/hololinked/__init__.py
@@ -1 +1 @@
-__version__ = "0.2.1"
+__version__ = "0.2.2"
diff --git a/hololinked/client/proxy.py b/hololinked/client/proxy.py
index 7f09f22..50f8deb 100644
--- a/hololinked/client/proxy.py
+++ b/hololinked/client/proxy.py
@@ -33,7 +33,7 @@ class ObjectProxy:
when True, remote object is located and its resources are loaded. Otherwise, only the client is initialised.
protocol: str
ZMQ protocol used to connect to server. Unlike the server, only one can be specified.
- **kwargs:
+ **kwargs:
async_mixin: bool, default False
whether to use both synchronous and asynchronous clients.
serializer: BaseSerializer
@@ -52,7 +52,7 @@ class ObjectProxy:
_own_attrs = frozenset([
'__annotations__',
- '_zmq_client', '_async_zmq_client', '_allow_foreign_attributes',
+ 'zmq_client', 'async_zmq_client', '_allow_foreign_attributes',
'identity', 'instance_name', 'logger', 'execution_timeout', 'invokation_timeout',
'_execution_timeout', '_invokation_timeout', '_events', '_noblock_messages',
'_schema_validator'
@@ -71,12 +71,12 @@ def __init__(self, instance_name : str, protocol : str = ZMQ_PROTOCOLS.IPC, invo
# compose ZMQ client in Proxy client so that all sending and receiving is
# done by the ZMQ client and not by the Proxy client directly. Proxy client only
# bothers mainly about __setattr__ and _getattr__
- self._async_zmq_client = None
- self._zmq_client = SyncZMQClient(instance_name, self.identity, client_type=PROXY, protocol=protocol,
+ self.async_zmq_client = None
+ self.zmq_client = SyncZMQClient(instance_name, self.identity, client_type=PROXY, protocol=protocol,
zmq_serializer=kwargs.get('serializer', None), handshake=load_thing,
logger=self.logger, **kwargs)
if kwargs.get("async_mixin", False):
- self._async_zmq_client = AsyncZMQClient(instance_name, self.identity + '|async', client_type=PROXY, protocol=protocol,
+ self.async_zmq_client = AsyncZMQClient(instance_name, self.identity + '|async', client_type=PROXY, protocol=protocol,
zmq_serializer=kwargs.get('serializer', None), handshake=load_thing,
logger=self.logger, **kwargs)
if load_thing:
@@ -108,11 +108,11 @@ def __enter__(self):
return self
def __exit__(self, exc_type, exc_value, traceback):
- del self
+ pass
def __bool__(self) -> bool:
try:
- self._zmq_client.handshake(num_of_tries=10)
+ self.zmq_client.handshake(num_of_tries=10)
return True
except RuntimeError:
return False
@@ -121,12 +121,12 @@ def __eq__(self, other) -> bool:
if other is self:
return True
return (isinstance(other, ObjectProxy) and other.instance_name == self.instance_name and
- other._zmq_client.protocol == self._zmq_client.protocol)
+ other.zmq_client.protocol == self.zmq_client.protocol)
def __ne__(self, other) -> bool:
if other and isinstance(other, ObjectProxy):
return (other.instance_name != self.instance_name or
- other._zmq_client.protocol != self._zmq_client.protocol)
+ other.zmq_client.protocol != self.zmq_client.protocol)
return True
def __hash__(self) -> int:
@@ -164,7 +164,7 @@ def set_execution_timeout(self, value : typing.Union[float, int]) -> None:
)
- def invoke(self, method : str, oneway : bool = False, noblock : bool = False,
+ def invoke_action(self, method : str, oneway : bool = False, noblock : bool = False,
*args, **kwargs) -> typing.Any:
"""
call a method specified by name on the server with positional/keyword arguments
@@ -207,7 +207,7 @@ def invoke(self, method : str, oneway : bool = False, noblock : bool = False,
return method(*args, **kwargs)
- async def async_invoke(self, method : str, *args, **kwargs) -> typing.Any:
+ async def async_invoke_action(self, method : str, *args, **kwargs) -> typing.Any:
"""
async(io) call a method specified by name on the server with positional/keyword
arguments. noblock and oneway not supported for async calls.
@@ -241,7 +241,7 @@ async def async_invoke(self, method : str, *args, **kwargs) -> typing.Any:
return await method.async_call(*args, **kwargs)
- def get_property(self, name : str, noblock : bool = False) -> typing.Any:
+ def read_property(self, name : str, noblock : bool = False) -> typing.Any:
"""
get property specified by name on server.
@@ -270,7 +270,7 @@ def get_property(self, name : str, noblock : bool = False) -> typing.Any:
return prop.get()
- def set_property(self, name : str, value : typing.Any, oneway : bool = False,
+ def write_property(self, name : str, value : typing.Any, oneway : bool = False,
noblock : bool = False) -> None:
"""
set property specified by name on server with specified value.
@@ -307,7 +307,7 @@ def set_property(self, name : str, value : typing.Any, oneway : bool = False,
prop.set(value)
- async def async_get_property(self, name : str) -> None:
+ async def async_read_property(self, name : str) -> None:
"""
async(io) get property specified by name on server.
@@ -329,7 +329,7 @@ async def async_get_property(self, name : str) -> None:
return await prop.async_get()
- async def async_set_property(self, name : str, value : typing.Any) -> None:
+ async def async_write_property(self, name : str, value : typing.Any) -> None:
"""
async(io) set property specified by name on server with specified value.
noblock and oneway not supported for async calls.
@@ -354,7 +354,7 @@ async def async_set_property(self, name : str, value : typing.Any) -> None:
await prop.async_set(value)
- def get_properties(self, names : typing.List[str], noblock : bool = False) -> typing.Any:
+ def read_multiple_properties(self, names : typing.List[str], noblock : bool = False) -> typing.Any:
"""
get properties specified by list of names.
@@ -381,7 +381,7 @@ def get_properties(self, names : typing.List[str], noblock : bool = False) -> ty
return method(names=names)
- def set_properties(self, oneway : bool = False, noblock : bool = False,
+ def write_multiple_properties(self, oneway : bool = False, noblock : bool = False,
**properties : typing.Dict[str, typing.Any]) -> None:
"""
set properties whose name is specified by keys of a dictionary
@@ -418,7 +418,7 @@ def set_properties(self, oneway : bool = False, noblock : bool = False,
return method(**properties)
- async def async_get_properties(self, names) -> None:
+ async def async_read_multiple_properties(self, names) -> None:
"""
async(io) get properties specified by list of names. no block gets are not supported for asyncio.
@@ -438,7 +438,7 @@ async def async_get_properties(self, names) -> None:
return await method.async_call(names=names)
- async def async_set_properties(self, **properties) -> None:
+ async def async_write_multiple_properties(self, **properties) -> None:
"""
async(io) set properties whose name is specified by keys of a dictionary
@@ -523,9 +523,9 @@ def read_reply(self, message_id : bytes, timeout : typing.Optional[float] = 5000
obj = self._noblock_messages.get(message_id, None)
if not obj:
raise ValueError('given message id not a one way call or invalid.')
- reply = self._zmq_client._reply_cache.get(message_id, None)
+ reply = self.zmq_client._reply_cache.get(message_id, None)
if not reply:
- reply = self._zmq_client.recv_reply(message_id=message_id, timeout=timeout,
+ reply = self.zmq_client.recv_reply(message_id=message_id, timeout=timeout,
raise_client_side_exception=True)
if not reply:
raise ReplyNotArrivedError(f"could not fetch reply within timeout for message id '{message_id}'")
@@ -541,7 +541,7 @@ def load_thing(self):
"""
Get exposed resources from server (methods, properties, events) and remember them as attributes of the proxy.
"""
- fetch = _RemoteMethod(self._zmq_client, CommonRPC.zmq_resource_read(instance_name=self.instance_name),
+ fetch = _RemoteMethod(self.zmq_client, CommonRPC.zmq_resource_read(instance_name=self.instance_name),
invokation_timeout=self._invokation_timeout) # type: _RemoteMethod
reply = fetch() # type: typing.Dict[str, typing.Dict[str, typing.Any]]
@@ -559,15 +559,15 @@ def load_thing(self):
elif not isinstance(data, (ZMQResource, ServerSentEvent)):
raise RuntimeError("Logic error - deserialized info about server not instance of hololinked.server.data_classes.ZMQResource")
if data.what == ResourceTypes.ACTION:
- _add_method(self, _RemoteMethod(self._zmq_client, data.instruction, self.invokation_timeout,
- self.execution_timeout, data.argument_schema, self._async_zmq_client, self._schema_validator), data)
+ _add_method(self, _RemoteMethod(self.zmq_client, data.instruction, self.invokation_timeout,
+ self.execution_timeout, data.argument_schema, self.async_zmq_client, self._schema_validator), data)
elif data.what == ResourceTypes.PROPERTY:
- _add_property(self, _Property(self._zmq_client, data.instruction, self.invokation_timeout,
- self.execution_timeout, self._async_zmq_client), data)
+ _add_property(self, _Property(self.zmq_client, data.instruction, self.invokation_timeout,
+ self.execution_timeout, self.async_zmq_client), data)
elif data.what == ResourceTypes.EVENT:
assert isinstance(data, ServerSentEvent)
- event = _Event(self._zmq_client, data.name, data.obj_name, data.unique_identifier, data.socket_address,
- serializer=self._zmq_client.zmq_serializer, logger=self.logger)
+ event = _Event(self.zmq_client, data.name, data.obj_name, data.unique_identifier, data.socket_address,
+ serializer=self.zmq_client.zmq_serializer, logger=self.logger)
_add_event(self, event, data)
self.__dict__[data.name] = event
diff --git a/hololinked/server/dataklasses.py b/hololinked/server/dataklasses.py
index aca9107..cf0f018 100644
--- a/hololinked/server/dataklasses.py
+++ b/hololinked/server/dataklasses.py
@@ -394,99 +394,28 @@ class ServerSentEvent(SerializableDataclass):
socket_address : str = field(default=UNSPECIFIED)
what : str = field(default=ResourceTypes.EVENT)
-
-@dataclass
-class GUIResources(SerializableDataclass):
+def build_our_temp_TD(instance):
"""
- Encapsulation of all information required to populate hololinked-portal GUI for a thing.
-
- Attributes
- ----------
- instance_name : str
- instance name of the ``Thing``
- inheritance : List[str]
- inheritance tree of the ``Thing``
- classdoc : str
- class docstring
- properties : nested JSON (dictionary)
- defined properties and their metadata
- actions : nested JSON (dictionary)
- defined actions
- events : nested JSON (dictionary)
- defined events
- documentation : Dict[str, str]
- documentation files, name as key and path as value
- GUI : nested JSON (dictionary)
- generated from ``hololinked.webdashboard.ReactApp``, a GUI can be shown under 'default GUI' tab in the portal
+ A temporary extension of TD used to build GUI of thing control panel.
+ Will be later replaced by a more sophisticated TD builder which is compliant to the actual spec & its theory.
"""
- instance_name : str
- inheritance : typing.List[str]
- classdoc : typing.Optional[typing.List[str]]
- properties : typing.Dict[str, typing.Any] = field(default_factory=dict)
- actions : typing.Dict[str, typing.Any] = field(default_factory=dict)
- events : typing.Dict[str, typing.Any] = field(default_factory=dict)
- documentation : typing.Optional[typing.Dict[str, typing.Any]] = field(default=None)
- GUI : typing.Optional[typing.Dict] = field(default=None)
-
- def __init__(self):
- """
- initialize first, then call build action.
- """
- super(SerializableDataclass, self).__init__()
-
- def build(self, instance):
- from .thing import Thing
- from .events import Event
-
- assert isinstance(instance, Thing), f"got invalid type {type(instance)}"
-
- self.instance_name = instance.instance_name
- self.inheritance = [class_.__name__ for class_ in instance.__class__.mro()]
- self.classdoc = instance.__class__.__doc__.splitlines() if instance.__class__.__doc__ is not None else None
- self.GUI = instance.GUI
-
- self.events = {
- event._unique_identifier.decode() : dict(
- name = event._remote_info.name,
- instruction = event._unique_identifier.decode(),
- owner = event._owner_inst.__class__.__name__,
- owner_instance_name = event._owner_inst.instance_name,
- address = instance.event_publisher.socket_address
- ) for event in instance.event_publisher.events
-
- }
- self.actions = dict()
- self.properties = dict()
-
- for instruction, remote_info in instance.instance_resources.items():
- if remote_info.isaction:
- try:
- self.actions[instruction] = instance.zmq_resources[instruction].json()
- self.actions[instruction]["remote_info"] = instance.httpserver_resources[instruction].json()
- self.actions[instruction]["remote_info"]["http_method"] = instance.httpserver_resources[instruction].instructions.supported_methods()
- # to check - apparently the recursive json() calling does not reach inner depths of a dict,
- # therefore we call json ourselves
- self.actions[instruction]["owner"] = instance.zmq_resources[instruction].qualname.split('.')[0]
- self.actions[instruction]["owner_instance_name"] = remote_info.bound_obj.instance_name
- self.actions[instruction]["type"] = 'classmethod' if isinstance(remote_info.obj, classmethod) else ''
- self.actions[instruction]["signature"] = get_signature(remote_info.obj)[0]
- except KeyError:
- pass
- elif remote_info.isproperty:
- path_without_RW = instruction.rsplit('/', 1)[0]
- if path_without_RW not in self.properties:
- self.properties[path_without_RW] = instance.__class__.properties.webgui_info(remote_info.obj)[remote_info.obj.name]
- self.properties[path_without_RW]["remote_info"] = self.properties[path_without_RW]["remote_info"].json()
- self.properties[path_without_RW]["instruction"] = path_without_RW
- self.properties[path_without_RW]["remote_info"]["http_method"] = instance.httpserver_resources[path_without_RW].instructions.supported_methods()
- """
- The instruction part has to be cleaned up to be called as fullpath. Setting the full path back into
- remote_info is not correct because the unbound method is used by multiple instances.
- """
- self.properties[path_without_RW]["owner_instance_name"] = remote_info.bound_obj.instance_name
- return self
+ from .thing import Thing
+
+ assert isinstance(instance, Thing), f"got invalid type {type(instance)}"
+ our_TD = instance.get_thing_description()
+ our_TD["inheritance"] = [class_.__name__ for class_ in instance.__class__.mro()]
+
+ for instruction, remote_info in instance.instance_resources.items():
+ if remote_info.isaction and remote_info.obj_name in our_TD["actions"]:
+ if isinstance(remote_info.obj, classmethod):
+ our_TD["actions"][remote_info.obj_name]["type"] = 'classmethod'
+ our_TD["actions"][remote_info.obj_name]["signature"] = get_signature(remote_info.obj)[0]
+ elif remote_info.isproperty and remote_info.obj_name in our_TD["properties"]:
+ our_TD["properties"][remote_info.obj_name].update(instance.__class__.properties.webgui_info(remote_info.obj)[remote_info.obj_name])
+ return our_TD
+
def get_organised_resources(instance):
diff --git a/hololinked/server/eventloop.py b/hololinked/server/eventloop.py
index 4e64f98..e605ff4 100644
--- a/hololinked/server/eventloop.py
+++ b/hololinked/server/eventloop.py
@@ -188,7 +188,7 @@ def instantiate(self, id : str, kwargs : typing.Dict = {}):
if not self.threaded:
self.thing_executor_loop.call_soon(asyncio.create_task(lambda : self.run_single_target(instance)))
else:
- _thing_executor = threading.Thread(target=self.run_thing_executor, args=([instance],))
+ _thing_executor = threading.Thread(target=self.run_things_executor, args=([instance],))
_thing_executor.start()
def run(self):
@@ -248,6 +248,7 @@ def run_things_executor(self, things):
Please dont call this method when the async loop is already running.
"""
thing_executor_loop = self.get_async_loop()
+ self.thing_executor_loop = thing_executor_loop # atomic assignment for thread safety
self.logger.info(f"starting thing executor loop in thread {threading.get_ident()} for {[obj.instance_name for obj in things]}")
thing_executor_loop.run_until_complete(
asyncio.gather(*[self.run_single_target(instance) for instance in things])
diff --git a/hololinked/server/events.py b/hololinked/server/events.py
index 7c73c53..fafe01e 100644
--- a/hololinked/server/events.py
+++ b/hololinked/server/events.py
@@ -11,6 +11,7 @@
from .security_definitions import BaseSecurityDefinition
+
class Event:
"""
Asynchronously push arbitrary messages to clients. Apart from default events created by the package (like state
@@ -54,7 +55,11 @@ def __set_name__(self, owner : ParameterizedMetaclass, name : str) -> None:
self._obj_name = name
self.owner = owner
- def __get__(self, obj : ParameterizedMetaclass, objtype : typing.Optional[type] = None) -> "EventDispatcher":
+ @typing.overload
+ def __get__(self, obj, objtype) -> "EventDispatcher":
+ ...
+
+ def __get__(self, obj : ParameterizedMetaclass, objtype : typing.Optional[type] = None):
try:
return obj.__dict__[self._internal_name]
except KeyError:
@@ -74,6 +79,7 @@ def __set__(self, obj : Parameterized, value : typing.Any) -> None:
else:
raise TypeError(f"Supply EventDispatcher object to event {self._obj_name}, not type {type(value)}.")
+
class EventDispatcher:
"""
@@ -123,6 +129,8 @@ def push(self, data : typing.Any = None, *, serialize : bool = True, **kwargs) -
http_clients=kwargs.get('http_clients', True), serialize=serialize)
+
+
class CriticalEvent(Event):
"""
Push events to client and get acknowledgement for that
diff --git a/hololinked/server/logger.py b/hololinked/server/logger.py
index 9222032..1db610d 100644
--- a/hololinked/server/logger.py
+++ b/hololinked/server/logger.py
@@ -8,7 +8,7 @@
from .constants import HTTP_METHODS
from .events import Event
-from .property import Property
+from .properties import List
from .properties import Integer, Number
from .thing import Thing as RemoteObject
from .action import action as remote_method
@@ -193,22 +193,22 @@ async def _async_push_diff_logs(self) -> None:
self.diff_logs.clear()
self._owner.logger.info(f"ending log events.")
- debug_logs = Property(readonly=True, URL_path='/logs/debug', fget=lambda self: self._debug_logs,
+ debug_logs = List(default=[], readonly=True, URL_path='/logs/debug', fget=lambda self: self._debug_logs,
doc="logs at logging.DEBUG level")
- warn_logs = Property(readonly=True, URL_path='/logs/warn', fget=lambda self: self._warn_logs,
+ warn_logs = List(default=[], readonly=True, URL_path='/logs/warn', fget=lambda self: self._warn_logs,
doc="logs at logging.WARN level")
- info_logs = Property(readonly=True, URL_path='/logs/info', fget=lambda self: self._info_logs,
+ info_logs = List(default=[], readonly=True, URL_path='/logs/info', fget=lambda self: self._info_logs,
doc="logs at logging.INFO level")
- error_logs = Property(readonly=True, URL_path='/logs/error', fget=lambda self: self._error_logs,
+ error_logs = List(default=[], readonly=True, URL_path='/logs/error', fget=lambda self: self._error_logs,
doc="logs at logging.ERROR level")
- critical_logs = Property(readonly=True, URL_path='/logs/critical', fget=lambda self: self._critical_logs,
+ critical_logs = List(default=[], readonly=True, URL_path='/logs/critical', fget=lambda self: self._critical_logs,
doc="logs at logging.CRITICAL level")
- execution_logs = Property(readonly=True, URL_path='/logs/execution', fget=lambda self: self._execution_logs,
+ execution_logs = List(default=[], readonly=True, URL_path='/logs/execution', fget=lambda self: self._execution_logs,
doc="logs at all levels accumulated in order of collection/execution")
diff --git a/hololinked/server/properties.py b/hololinked/server/properties.py
index 8bf9a91..3fe33ba 100644
--- a/hololinked/server/properties.py
+++ b/hololinked/server/properties.py
@@ -23,7 +23,15 @@
class String(Property):
- """A string property with optional regular expression (regex) matching."""
+ """A string property with optional regular expression (regex) matching.
+
+ Parameters
+ ----------
+ default: str
+ default value of the string, if not None or empty
+ regex: str
+ the regular expression to match during validation
+ """
type = 'string' # TD type
diff --git a/hololinked/server/property.py b/hololinked/server/property.py
index 024314b..6bdc031 100644
--- a/hololinked/server/property.py
+++ b/hololinked/server/property.py
@@ -256,8 +256,7 @@ def comparator(self, func : typing.Callable) -> typing.Callable:
__property_info__ = [
'allow_None' , 'class_member', 'db_init', 'db_persist',
'db_commit', 'deepcopy_default', 'per_instance_descriptor',
- 'default', 'doc', 'constant',
- 'metadata', 'name', 'readonly'
+ 'state', 'precedence', 'constant', 'default'
# 'scada_info', 'property_type' # descriptor related info is also necessary
]
@@ -319,9 +318,7 @@ def webgui_info(self, for_remote_params : typing.Union[Property, typing.Dict[str
for param in objects.values():
state = param.__getstate__()
info[param.name] = dict(
- remote_info = state.get("_remote_info", None).to_dataclass(),
- type = param.__class__.__name__,
- owner = param.owner.__name__
+ python_type = param.__class__.__name__,
)
for field in __property_info__:
info[param.name][field] = state.get(field, None)
diff --git a/hololinked/server/td.py b/hololinked/server/td.py
index e553870..1a549fc 100644
--- a/hololinked/server/td.py
+++ b/hololinked/server/td.py
@@ -1,5 +1,4 @@
-import inspect
-import typing
+import typing, inspect
from dataclasses import dataclass, field
@@ -10,7 +9,8 @@
from .properties import *
from .property import Property
from .thing import Thing
-from .eventloop import EventLoop
+from .state_machine import StateMachine
+
@@ -263,7 +263,7 @@ def generate_schema(self, property : Property, owner : Thing, authority : str) -
elif self._custom_schema_generators.get(property, NotImplemented) is not NotImplemented:
schema = self._custom_schema_generators[property]()
else:
- raise TypeError(f"WoT schema generator for this descriptor/property is not implemented. type {type(property)}")
+ raise TypeError(f"WoT schema generator for this descriptor/property is not implemented. name {property.name} & type {type(property)}")
schema.build(property=property, owner=owner, authority=authority)
return schema.asdict()
@@ -716,11 +716,10 @@ class ThingDescription(Schema):
schemaDefinitions : typing.Optional[typing.List[DataSchema]]
skip_properties = ['expose', 'httpserver_resources', 'zmq_resources', 'gui_resources',
- 'events', 'debug_logs', 'warn_logs', 'info_logs', 'error_logs', 'critical_logs',
- 'thing_description', 'maxlen', 'execution_logs', 'GUI', 'object_info' ]
+ 'events', 'thing_description', 'GUI', 'object_info' ]
- skip_actions = ['_set_properties', '_get_properties', '_add_property', 'push_events', 'stop_events',
- 'get_postman_collection', 'get_thing_description']
+ skip_actions = ['_set_properties', '_get_properties', '_add_property', '_get_properties_in_db',
+ 'push_events', 'stop_events', 'get_postman_collection', 'get_thing_description']
# not the best code and logic, but works for now
@@ -758,8 +757,8 @@ def add_interaction_affordances(self):
if (resource.isproperty and resource.obj_name not in self.properties and
resource.obj_name not in self.skip_properties and hasattr(resource.obj, "_remote_info") and
resource.obj._remote_info is not None):
- if (resource.obj_name == 'state' and hasattr(self.instance, 'state_machine') is None and
- self.instance.state_machine is not None):
+ if (resource.obj_name == 'state' and (not hasattr(self.instance, 'state_machine') or
+ not isinstance(self.instance.state_machine, StateMachine))):
continue
self.properties[resource.obj_name] = PropertyAffordance.generate_schema(resource.obj,
self.instance, self.authority)
diff --git a/hololinked/server/thing.py b/hololinked/server/thing.py
index bbdcf0f..10cce80 100644
--- a/hololinked/server/thing.py
+++ b/hololinked/server/thing.py
@@ -8,17 +8,17 @@
import zmq.asyncio
from ..param.parameterized import Parameterized, ParameterizedMetaclass, edit_constant as edit_constant_parameters
-from .constants import (JSON, LOGLEVEL, ZMQ_PROTOCOLS, HTTP_METHODS)
+from .constants import (JSON, LOGLEVEL, ZMQ_PROTOCOLS, HTTP_METHODS, JSONSerializable)
from .database import ThingDB, ThingInformation
from .serializers import _get_serializer_from_user_given_options, BaseSerializer, JSONSerializer
from .schema_validators import BaseSchemaValidator, JsonSchemaValidator
from .exceptions import BreakInnerLoop
from .action import action
-from .dataklasses import GUIResources, HTTPResource, ZMQResource, get_organised_resources
+from .dataklasses import HTTPResource, ZMQResource, build_our_temp_TD, get_organised_resources
from .utils import get_default_logger, getattr_without_descriptor_read
from .property import Property, ClassProperties
from .properties import String, ClassSelector, Selector, TypedKeyMappingsConstrainedDict
-from .zmq_message_brokers import RPCServer, ServerTypes, AsyncPollingZMQServer, EventPublisher
+from .zmq_message_brokers import RPCServer, ServerTypes, EventPublisher
from .state_machine import StateMachine
from .events import Event
@@ -111,7 +111,6 @@ class Thing(Parameterized, metaclass=ThingMeta):
state = String(default=None, allow_None=True, URL_path='/state', readonly=True, observable=True,
fget=lambda self : self.state_machine.current_state if hasattr(self, 'state_machine') else None,
doc="current state machine's state if state machine present, None indicates absence of state machine.") #type: typing.Optional[str]
-
httpserver_resources = Property(readonly=True, URL_path='/resources/http-server',
doc="object's resources exposed to HTTP client (through ``hololinked.server.HTTPServer.HTTPServer``)",
fget=lambda self: self._httpserver_resources ) # type: typing.Dict[str, HTTPResource]
@@ -121,7 +120,7 @@ class Thing(Parameterized, metaclass=ThingMeta):
gui_resources = Property(readonly=True, URL_path='/resources/portal-app',
doc="""object's data read by hololinked-portal GUI client, similar to http_resources but differs
in details.""",
- fget=lambda self: GUIResources().build(self)) # type: typing.Dict[str, typing.Any]
+ fget=lambda self: build_our_temp_TD(self)) # type: typing.Dict[str, typing.Any]
GUI = Property(default=None, allow_None=True, URL_path='/resources/web-gui', fget = lambda self : self._gui,
doc="GUI specified here will become visible at GUI tab of hololinked-portal dashboard tool")
object_info = Property(doc="contains information about this object like the class name, script location etc.",
@@ -371,6 +370,28 @@ def _set_properties(self, **values : typing.Dict[str, typing.Any]) -> None:
ex.__notes__ = errors
raise ex from None
+ @action(URL_path='/properties/db', http_method=HTTP_METHODS.GET)
+ def _get_properties_in_db(self) -> typing.Dict[str, JSONSerializable]:
+ """
+ get all properties in the database
+
+ Returns
+ -------
+ Dict[str, JSONSerializable]
+ dictionary of property names and their values
+ """
+ if not hasattr(self, 'db_engine'):
+ return {}
+ props = self.db_engine.get_all_properties()
+ final_list = {}
+ for name, prop in props.items():
+ try:
+ self.http_serializer.dumps(prop)
+ final_list[name] = prop
+ except Exception as ex:
+ self.logger.error(f"could not serialize property {name} to JSON due to error {str(ex)}, skipping this property")
+ return final_list
+
@action(URL_path='/properties', http_method=HTTP_METHODS.POST)
def _add_property(self, name : str, prop : JSON) -> None:
"""
diff --git a/hololinked/server/zmq_message_brokers.py b/hololinked/server/zmq_message_brokers.py
index ee6e481..cc33829 100644
--- a/hololinked/server/zmq_message_brokers.py
+++ b/hololinked/server/zmq_message_brokers.py
@@ -1542,12 +1542,16 @@ def execute(self, instruction : str, arguments : typing.Dict[str, typing.Any] =
message id : bytes
a byte representation of message id
"""
+ acquire_timeout = -1 if invokation_timeout is None else invokation_timeout
+ acquired = self._client_queue.acquire(timeout=acquire_timeout)
+ if not acquired:
+ raise TimeoutError("previous request still in progress")
try:
- self._client_queue.acquire()
- msg_id = self.send_instruction(instruction, arguments, invokation_timeout, execution_timeout, context, argument_schema)
+ msg_id = self.send_instruction(instruction, arguments, invokation_timeout,
+ execution_timeout, context, argument_schema)
return self.recv_reply(msg_id, raise_client_side_exception=raise_client_side_exception, deserialize=deserialize_reply)
finally:
- self._client_queue.release()
+ self._client_queue.release()
def handshake(self, timeout : typing.Union[float, int] = 60000) -> None:
@@ -1742,7 +1746,10 @@ async def async_execute(self, instruction : str, arguments : typing.Dict[str, ty
a byte representation of message id
"""
try:
- await self._client_queue.acquire()
+ await asyncio.wait_for(self._client_queue.acquire(), timeout=invokation_timeout)
+ except TimeoutError:
+ raise TimeoutError("previous request still in progress") from None
+ try:
msg_id = await self.async_send_instruction(instruction, arguments, invokation_timeout, execution_timeout,
context, argument_schema)
return await self.async_recv_reply(msg_id, raise_client_side_exception=raise_client_side_exception,
diff --git a/setup.py b/setup.py
index 59172c8..e4083a5 100644
--- a/setup.py
+++ b/setup.py
@@ -7,7 +7,7 @@
setuptools.setup(
name="hololinked",
- version="0.2.1",
+ version="0.2.2",
author="Vigneh Vaidyanathan",
author_email="vignesh.vaidyanathan@hololinked.dev",
description="A ZMQ-based Object Oriented RPC tool-kit with HTTP support for instrument control/data acquisition or controlling generic python objects.",
diff --git a/tests/test_property.py b/tests/test_property.py
index 8583a3d..bfa1710 100644
--- a/tests/test_property.py
+++ b/tests/test_property.py
@@ -97,7 +97,7 @@ def test_1_client_api(self):
def test_2_RW_multiple_properties(self):
# Test partial list of read write properties
- self.thing_client.set_properties(
+ self.thing_client.write_multiple_properties(
number_prop=15,
string_prop='foobar'
)
@@ -108,7 +108,7 @@ def test_2_RW_multiple_properties(self):
self.thing_client.selector_prop = 'b'
self.thing_client.number_prop = -15
- props = self.thing_client.get_properties(names=['selector_prop', 'int_prop',
+ props = self.thing_client.read_multiple_properties(names=['selector_prop', 'int_prop',
'number_prop', 'string_prop'])
self.assertEqual(props['selector_prop'], 'b')
self.assertEqual(props['int_prop'], 5)
diff --git a/tests/test_rpc.py b/tests/test_rpc.py
index 5b16853..5bec84e 100644
--- a/tests/test_rpc.py
+++ b/tests/test_rpc.py
@@ -191,7 +191,7 @@ async def message_coro():
nonlocal success, client
for i in range(2000):
value = gen_random_data()
- ret = await client.async_invoke('test_echo', value)
+ ret = await client.async_invoke_action('test_echo', value)
# print("async", 1, i, value, ret)
if value != ret:
print("error", "async", 1, i, value, ret)
@@ -211,7 +211,7 @@ async def message_coro(id):
nonlocal success, client
for i in range(1000):
value = gen_random_data()
- ret = await client.async_invoke('test_echo', value)
+ ret = await client.async_invoke_action('test_echo', value)
# print("multi-coro", id, i, value, ret)
if value != ret:
print("error", "multi-coro", id, i, value, ret)