diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md
index 0015dcc3..18b8904e 100644
--- a/.github/ISSUE_TEMPLATE/bug_report.md
+++ b/.github/ISSUE_TEMPLATE/bug_report.md
@@ -1,10 +1,9 @@
---
name: Bug report
about: Create a report to help us improve
-title: ''
-labels: ''
-assignees: ''
-
+title: ""
+labels: ""
+assignees: ""
---
**Describe the bug**
@@ -12,15 +11,17 @@ A clear and concise description of what the bug is.
**To Reproduce**
Steps to reproduce the behavior.
-- code snippet or fully functional code block .
-- log statements
+
+- code snippet or fully functional code block .
+- log statements
**Expected behavior**
A clear and concise description of what you expected to happen.
**OS/python version:**
- - windows/linux
- - python 3.11/3.12 or 13?
+
+- windows/linux
+- python 3.11/3.12 or 13?
**Additional context**
Add any other context about the problem here.
diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md
index 9f890037..1350255b 100644
--- a/.github/ISSUE_TEMPLATE/feature_request.md
+++ b/.github/ISSUE_TEMPLATE/feature_request.md
@@ -1,10 +1,9 @@
---
name: Feature request
about: Suggest an idea for this project
-title: ''
-labels: ''
-assignees: ''
-
+title: ""
+labels: ""
+assignees: ""
---
**Please describe.**
@@ -14,7 +13,7 @@ A clear and concise description of what the feature achieves
Practical use cases where this feature would be useful (if any)
**Describe the solution you'd like**
-Final API, code snippets etc., a clear and concise description of what you want to happen.
+Final API, code snippets etc., a clear and concise description of what you want to happen.
**Describe alternatives you've considered**
A clear and concise description of any alternative solutions or features you've considered.
diff --git a/.github/workflows/ci-pipeline.yml b/.github/workflows/ci-pipeline.yml
index 3e35450a..5681d70b 100644
--- a/.github/workflows/ci-pipeline.yml
+++ b/.github/workflows/ci-pipeline.yml
@@ -76,33 +76,32 @@ jobs:
run: |
uv venv .venv
source .venv/bin/activate
- uv sync --no-install-project --group test --group dev
+ uv sync --group test --group dev
- name: install dependencies (windows)
if: runner.os == 'Windows'
run: |
uv venv .venv
.venv\Scripts\activate
- uv sync --no-install-project --group test --group dev
+ uv sync --group test --group dev
- name: run unit tests (linux/macOS)
if: runner.os != 'Windows' && matrix.python-version != 3.13
run: |
source .venv/bin/activate
- uv run coverage run -m unittest discover -s tests -p 'test_*.py'
+ pytest -s -v
- name: run unit tests (Windows)
if: runner.os == 'Windows'
run: |
.venv\Scripts\activate
- uv run coverage run -m unittest discover -s tests -p "test_*.py"
+ pytest -s -v
- name: run unit tests and generate coverage report (linux/macOS python 3.13)
if: runner.os != 'Windows' && matrix.python-version == 3.13
run: |
source .venv/bin/activate
- uv run coverage run -m unittest discover -s tests -p 'test_*.py'
- uv run coverage xml -o coverage.xml
+ pytest -s -v --cov=hololinked --cov-report=xml:coverage.xml
- name: upload coverage report as artifact
uses: actions/upload-artifact@v4
diff --git a/.github/workflows/pytest-dev.yml b/.github/workflows/pytest-dev.yml
deleted file mode 100644
index 1277ebc0..00000000
--- a/.github/workflows/pytest-dev.yml
+++ /dev/null
@@ -1,100 +0,0 @@
-name: Pytest Tests For Development
-
-on:
- workflow_dispatch:
- pull_request:
- branches:
- - main
- push:
- branches:
- - main
-
-jobs:
- test:
- strategy:
- matrix:
- include:
- - os: windows-latest
- python-version: 3.11
- - os: ubuntu-latest
- python-version: 3.11
-
- runs-on: ${{ matrix.os }}
-
- steps:
- - name: Checkout code
- uses: actions/checkout@v4
-
- - name: Set up Python ${{ matrix.python-version }}
- uses: actions/setup-python@v3
- with:
- python-version: ${{ matrix.python-version }}
-
- - name: Install uv (Linux/macOS)
- if: runner.os != 'Windows'
- run: curl -LsSf https://astral.sh/uv/install.sh | sh
-
- - name: Install uv (Windows)
- if: runner.os == 'Windows'
- run: |
- python -m pip install uv
-
- - name: Install dependencies (Linux/macOS)
- if: runner.os != 'Windows'
- run: |
- uv venv .venv
- source .venv/bin/activate
- uv sync --group test --group dev
-
- - name: Install dependencies (Windows)
- if: runner.os == 'Windows'
- run: |
- uv venv .venv
- .venv\Scripts\activate
- uv sync --group test --group dev
-
- - name: Run pytest tests (Linux/macOS)
- if: runner.os != 'Windows'
- run: |
- source .venv/bin/activate
- pytest tests/pytests-new -s -v --cov=hololinked --cov-report=term-missing
-
- - name: Run pytest tests (Windows)
- if: runner.os == 'Windows'
- run: |
- .venv\Scripts\activate
- pytest tests/pytests-new -s -v --cov=hololinked --cov-report=term-missing
-
- - name: Upload coverage report as artifact
- uses: actions/upload-artifact@v4
- if: runner.os != 'Windows'
- with:
- name: pytest-coverage-report-ubuntu-latest-py3.11
- path: coverage.xml
- if-no-files-found: warn
-
- publish:
- name: Publish coverage (disabled for pytest per issue #107)
- needs: test
- runs-on: ubuntu-latest
- if: ${{ false }}
- steps:
- - name: Checkout code
- uses: actions/checkout@v4
-
- - name: Download Ubuntu 3.11 coverage artifact
- id: dl
- uses: actions/download-artifact@v4
- with:
- name: pytest-coverage-report-ubuntu-latest-py3.11
- path: .
- continue-on-error: true
-
- - name: Upload coverage to Codecov (disabled)
- if: false
- uses: codecov/codecov-action@v4
- with:
- files: coverage.xml
-
- - name: Skip note (coverage upload disabled for pytest)
- run: echo "Skipping Codecov upload in pytest workflow per issue #107."
diff --git a/.gitignore b/.gitignore
index 92146185..c68d663f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,6 +3,7 @@ __pycache__/
*.py[cod]
*$py.class
*.crt
+test*.db
# C extensions
*.so
diff --git a/.vscode/settings.json b/.vscode/settings.json
index 12c2903a..c643080c 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -1,9 +1,25 @@
{
+ // VS code settings used for this project
+ // Please consider installing and using these extensions to make
+ // PRs consistent in terms of formatting and linting
"editor.rulers": [
80, 120
],
"editor.formatOnSave": true,
+
"ruff.lineLength": 120,
+ "ruff.organizeImports": true,
+ "ruff.lint.enable": true,
+
+ "[python]": {
+ "editor.defaultFormatter": "charliermarsh.ruff",
+ "editor.formatOnSave": true,
+ "editor.codeActionsOnSave": {
+ "source.organizeImports.ruff": "explicit",
+ "source.fixAll.ruff": "explicit"
+ }
+ },
+
"[yaml]": {
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true,
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index f06987d7..5e1313f1 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -25,7 +25,7 @@ Partial contributions are also taken if its easier to continue working on it. In
To start developing and complete the contribution, following steps may be followed:
1. Fork the repository and clone it to your local machine.
-2. Setup python environment, preferably using `uv`, instructions are in the [README](https://github.com/hololinked-dev/hololinked/blob/main/README.md#contributing).
+2. Setup python environment, preferably using `uv`, instructions are in the [docs]([setup development environment](https://docs.hololinked.dev/introduction/contributing#setup-development-environment).
3. There are some test things under `tests/helper-scripts` directory or in the [examples repository](https://gitlab.com/hololinked/examples), especially the simulators (they may not be up to date, one could take what one needs). These can be used to test your changes.
4. All code must follow [PEP 8](https://peps.python.org/pep-0008/) style guide. One needs to use ruff to check for style issues - `uvx ruff check hololinked`.
5. Unit/Integration tests are recommended to be added for any new feature or bug fix. These tests can be run with `python -m unittest` under the `tests` directory.
diff --git a/README.md b/README.md
index 9c417a4d..6e8c4fe1 100644
--- a/README.md
+++ b/README.md
@@ -6,11 +6,11 @@
As a novice, you have a requirement to control and capture data from your hardware, say in your electronics or science lab, and you want to show the data in a dashboard, provide a PyQt GUI or run automated scripts, `hololinked` can help. Even for isolated desktop applications or a small setup without networking, one can still separate the concerns of the tools that interact with the hardware & the hardware itself.
-If you are a web developer or an industry professional looking for a web standards compatible (high-speed) IoT runtime, `hololinked` can be a decent choice. By conforming to [W3C Web of Things](https://www.w3.org/WoT/), one can expect a consistent API and flexible bidirectional message flow to interact with your devices, irrespective of the underlying protocol. Currently HTTP, MQTT & ZMQ are supported. See [Use Cases Table](#use-cases-table).
+If you are a web developer or an industry professional looking for a web standards compatible (high-speed) IoT runtime, `hololinked` can be a decent choice. By conforming to [W3C Web of Things](https://www.w3.org/WoT/), one can expect a consistent API and flexible bidirectional message flow to interact with your devices, irrespective of the underlying protocol. Currently HTTP, MQTT & ZMQ are supported. See [Use Cases Table](https://docs.hololinked.dev/introduction/use-cases).
This implementation is based on RPC, built ground-up in python keeping both the latest web technologies and python principles in mind.
-[](https://github.com/hololinked-dev/docs) [](https://github.com/hololinked-dev/hololinked/actions/workflows/ci-pipeline.yml)  [](https://pypi.org/project/hololinked/) [](https://anaconda.org/conda-forge/hololinked) [](https://codecov.io/github/hololinked-dev/hololinked) [](https://anaconda.org/conda-forge/hololinked) [](https://pypistats.org/packages/hololinked) [](https://doi.org/10.5281/zenodo.12802841) [](https://discord.com/invite/kEz87zqQXh) [](mailto:info@hololinked.dev) [](https://forms.gle/FB4XwkUDt1wV4GGPA)
+[](https://github.com/hololinked-dev/docs) [](https://github.com/hololinked-dev/hololinked/actions/workflows/ci-pipeline.yml)  [](https://pypi.org/project/hololinked/) [](https://anaconda.org/conda-forge/hololinked) [](https://codecov.io/github/hololinked-dev/hololinked) [](https://anaconda.org/conda-forge/hololinked) [](https://pypistats.org/packages/hololinked) [](https://doi.org/10.5281/zenodo.12802841) [](https://discord.com/invite/kEz87zqQXh) [](mailto:info@hololinked.dev) [](https://forms.gle/FB4XwkUDt1wV4GGPA)
## To Install
@@ -544,121 +544,17 @@ In React, the Thing Description may be fetched inside `useEffect` hook, the clie
See [organization info](https://github.com/hololinked-dev) for details regarding contributing to this package. There are:
- [good first issues](https://github.com/hololinked-dev/hololinked/issues?q=is%3Aopen+is%3Aissue+label%3A%22good+first+issue%22)
+- [setup development environment](https://docs.hololinked.dev/introduction/contributing#setup-development-environment)
- [discord group](https://discord.com/invite/kEz87zqQXh)
- [weekly meetings](https://github.com/hololinked-dev/#monthly-meetings) and
- [project planning](https://github.com/orgs/hololinked-dev/projects/4) to discuss activities around this repository.
-### Development with UV
-
-One can setup a development environment with [uv](https://docs.astral.sh/uv/) as follows:
-
-##### Setup Development Environment
-
-1. Install uv if you don't have it already: https://docs.astral.sh/uv/getting-started/installation/
-2. Create and activate a virtual environment:
-
-```bash
-uv venv venv
-source venv/bin/activate # On Windows: venv\Scripts\activate
-```
-
-3. Install the package in development mode with all dependencies:
-
-```bash
-uv pip install -e .
-uv pip install -e ".[dev,test]"
-```
-
-##### Running Tests
-
-To run the tests with uv:
-
-In linux:
-
-```bash
-uv run --active coverage run -m unittest discover -s tests -p 'test_*.py'
-uv run --active coverage report -m
-```
-
-In windows:
-
-```bash
-python -m unittest
-```
-
## Currently Supported Features
Some other features that are currently supported:
-- control method execution and property write with a custom finite state machine.
+- use a custom finite state machine.
- database (Postgres, MySQL, SQLite - based on SQLAlchemy) support for storing and loading properties when the object dies and restarts.
- 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
- asyncio event loops on server side
-
-## Use Cases
-
-
-
- | Protocol |
- Plausible Use Cases |
- Operations |
-
-
- | HTTP |
- Web Apps |
-
- readproperty,
- writeproperty,
- observeproperty,
- unobserveproperty,
- invokeaction,
- subscribeevent,
- unsubscribeevent,
- readmultipleproperties,
- writemultipleproperties,
- readallproperties,
- writeallproperties
-
- properties and actions can be operated in a oneway and no-block manner (issue and query later format) as well
- |
-
-
- | ZMQ TCP |
- Networked Control Systems, subnet protected containerized apps like in Kubernetes |
-
-
- | ZMQ IPC |
- Desktop Applications, Python Dashboards without exposing device API directly on network |
-
-
- | ZMQ INPROC |
-
- High Speed Desktop Applications (again, not exposed on network), currently you will need some CPP magic or disable GIL to leverage it fully
- |
-
-
- | MQTT |
-
- Reliable pub-sub & incorporating into existing systems that use MQTT for lightweight messaging
- |
-
- observeproperty,
- unobserveproperty,
- subscribeevent,
- unsubscribeevent
- |
-
-
- | MQTT with websockets |
-
- Reliable pub-sub for web applications, planned for November 2025 release.
- |
-
- observeproperty,
- unobserveproperty,
- subscribeevent,
- unsubscribeevent
- |
-
-
diff --git a/doc b/doc
index d4e965b5..e8ec16a0 160000
--- a/doc
+++ b/doc
@@ -1 +1 @@
-Subproject commit d4e965b5ad5b8c0b88f807d031b72e76acf9cde9
+Subproject commit e8ec16a0f5ff9a74c38a8a13fab26baf615e73cb
diff --git a/hololinked/logger.py b/hololinked/logger.py
index 5d1b6613..1d816688 100644
--- a/hololinked/logger.py
+++ b/hololinked/logger.py
@@ -1,11 +1,15 @@
-import logging
-import structlog
import copy
-from typing import Any
-from structlog.dev import KeyValueColumnFormatter
+import logging
import sys
import types
+from typing import Any
+
+import structlog
+
+from structlog.dev import KeyValueColumnFormatter
+
+
default_label_formatter = None
@@ -67,8 +71,8 @@ def setup_logging(log_level: int = logging.INFO, colored_logs: bool = False, log
import httpx # noqa: F401
# httpx_log = structlog.get_logger().bind(component="library|httpx")
- logging.getLogger("httpcore").setLevel(logging.WARNING)
- logging.getLogger("httpx").setLevel(logging.WARNING)
+ logging.getLogger("httpcore").setLevel(logging.WARNING if log_level <= logging.WARNING else log_level)
+ logging.getLogger("httpx").setLevel(logging.WARNING if log_level <= logging.WARNING else log_level)
except ImportError:
pass
diff --git a/hololinked/param/copy_parameters.py b/hololinked/param/copy_parameters.py
deleted file mode 100644
index 23ebe6bb..00000000
--- a/hololinked/param/copy_parameters.py
+++ /dev/null
@@ -1,119 +0,0 @@
-def copy_parameters(
- src: str = "D:/onedrive/desktop/dashboard/scada/scadapy/scadapy/param/parameters.py",
- dst: str = "D:/onedrive/desktop/dashboard/scada/scadapy/scadapy/server/remote_parameters.py",
-) -> None:
- skip_classes = [
- "Infinity",
- "resolve_path",
- "normalize_path",
- "BaseConstrainedList",
- "TypeConstrainedList",
- "TypeConstrainedDict",
- "TypedKeyMappingsConstrainedDict",
- "Event",
- ]
- end_line = "def hashable"
- additional_imports = [
- "from ..param.parameters import (TypeConstrainedList, TypeConstrainedDict, abbreviate_paths,\n",
- " TypedKeyMappingsConstrainedDict, resolve_path, concrete_descendents, named_objs)\n"
- "from .remote_parameter import RemoteParameter\n",
- "from .constants import HTTP, PROXY, USE_OBJECT_NAME, GET, PUT",
- ]
-
- def fetch_line() -> typing.Generator[str]:
- with open(src, "r") as file:
- oldlines = file.readlines()
- for line in oldlines:
- yield line
-
- remote_init_kwargs = [
- "\t\t\tURL_path : str = USE_OBJECT_NAME, http_method : typing.Tuple[str, str] = (GET, PUT),\n",
- "\t\t\tstate : typing.Optional[typing.Union[typing.List, typing.Tuple, str, Enum]] = None,\n",
- "\t\t\tdb_persist : bool = False, db_init : bool = False, db_commit : bool = False,\n"
- "\t\t\taccess_type : str = (HTTP, PROXY),\n",
- ]
-
- remote_super_init = [
- "\t\t\tURL_path=URL_path, http_method=http_method, state=state, db_persist=db_persist,\n",
- "\t\t\tdb_init=db_init, db_commit=db_commit, access_type=access_type)\n",
- ]
-
- common_linegen = fetch_line()
- newlines = []
-
- def skip_to_init_doc():
- for line in common_linegen:
- if "doc : typing.Optional[str] = None" in line:
- return line
- else:
- newlines.append(line)
-
- def skip_to_super_init_end():
- for line in common_linegen:
- if "precedence=precedence)" in line:
- return line
- else:
- newlines.append(line)
-
- def is_function(line: str) -> bool:
- if "def " in line and "self" not in line and "cls" not in line and "obj" not in line:
- return True
- return False
-
- def next_line_after_skip_class_or_function() -> str:
- for line_ in common_linegen:
- if ("class " in line_ and ":" in line_) or is_function(line_):
- return line_
-
- def process_current_line(line: str):
- newline = line
- if "import " in line and "parameterized " in line:
- newlines_ = [
- line.replace("from .parameterized", "from ..param.parameterized").replace("ParamOverrides,", ""),
- next(common_linegen).replace("ParameterizedFunction, descendents,", ""),
- *additional_imports,
- ]
- newlines.extend(newlines_)
- return
- elif "from collections import OrderedDict" in line:
- newlines.append("from enum import Enum\n")
- elif "from .utils" in line or "from .exceptions" in line:
- newline = line.replace("from .", "from ..param.")
- elif "class " in line and ":" in line and line.startswith("class"):
- if "(Parameter):" in line:
- newline = line.replace("(Parameter):", "(RemoteParameter):")
- newlines.append(newline)
- else:
- classname_with_inheritance = line.split(" ", 1)[1][:-2] # [:-2] for removing colon
- classname_without_inheritance = classname_with_inheritance.split("(", 1)[0]
- if classname_without_inheritance in skip_classes:
- newline = next_line_after_skip_class_or_function()
- process_current_line(newline)
- return
- else:
- newlines.append(line)
- newline = skip_to_init_doc()
- newlines.append(newline)
- newlines.extend(remote_init_kwargs)
- newline = skip_to_super_init_end()
- if newline:
- newline = newline.replace("precedence=precedence)", "precedence=precedence,")
- newlines.append(newline)
- newlines.extend(remote_super_init)
- return
- elif "Parameter.__init__" in line:
- newline = line.replace("Parameter.__init__", "RemoteParameter.__init__")
- elif is_function(line):
- newline = next_line_after_skip_class_or_function()
- process_current_line(newline)
- return
- newlines.append(newline)
-
- for line in common_linegen:
- process_current_line(line)
- if end_line in line:
- newlines.pop()
- break
-
- with open(dst, "w") as file:
- file.writelines(newlines)
diff --git a/hololinked/server/http/__init__.py b/hololinked/server/http/__init__.py
index 74b13a41..18bc5164 100644
--- a/hololinked/server/http/__init__.py
+++ b/hololinked/server/http/__init__.py
@@ -1,45 +1,49 @@
-import warnings
import logging
import socket
import ssl
import typing
+import warnings
+
+from copy import deepcopy
+
import structlog
+
from pydantic import BaseModel
-from copy import deepcopy
from tornado import ioloop
-from tornado.web import Application
from tornado.httpserver import HTTPServer as TornadoHTTP1Server
-# from tornado_http2.server import Server as TornadoHTTP2Server
+from tornado.web import Application
-from ...param.parameters import IPAddress, ClassSelector, TypedList
+from ...config import global_config
from ...constants import HTTP_METHODS
+from ...core.actions import Action
+from ...core.events import Event
+from ...core.property import Property
+from ...core.thing import Thing, ThingMeta
+from ...core.zmq.brokers import MessageMappedZMQClientPool
+
+# from tornado_http2.server import Server as TornadoHTTP2Server
+from ...param.parameters import ClassSelector, IPAddress, TypedList
+from ...td import ActionAffordance, EventAffordance, PropertyAffordance
from ...utils import (
get_current_async_loop,
issubklass,
pep8_to_dashed_name,
run_callable_somehow,
)
-from ...config import global_config
-from ...core.property import Property
-from ...core.actions import Action
-from ...core.events import Event
-from ...core.thing import Thing, ThingMeta
-from ...core.zmq.brokers import MessageMappedZMQClientPool
-from ...td import ActionAffordance, EventAffordance, PropertyAffordance
-from ..server import BaseProtocolServer, BrokerThing
from ..security import Security
+from ..server import BaseProtocolServer, BrokerThing
from ..utils import consume_broker_queue
from .handlers import (
ActionHandler,
+ BaseHandler,
+ EventHandler,
LivenessProbeHandler,
- ReadinessProbeHandler,
PropertyHandler,
- EventHandler,
- BaseHandler,
+ ReadinessProbeHandler,
+ RPCHandler,
RWMultiplePropertiesHandler,
StopHandler,
ThingDescriptionHandler,
- RPCHandler,
)
@@ -274,8 +278,14 @@ async def async_stop(self) -> None:
self.zmq_client_pool.stop_polling()
if not self.tornado_instance:
return
- self.tornado_instance.stop()
- await self.tornado_instance.close_all_connections()
+ try:
+ self.tornado_instance.stop()
+ await self.tornado_instance.close_all_connections()
+ except Exception as ex:
+ self.logger.error(
+ "error while stopping tornado server, use stop() method "
+ + f"from hololinked.server and do not reuse the port - {ex}"
+ )
def add_property(
self,
diff --git a/hololinked/server/http/handlers.py b/hololinked/server/http/handlers.py
index f8ace3c7..afef9d78 100644
--- a/hololinked/server/http/handlers.py
+++ b/hololinked/server/http/handlers.py
@@ -1,37 +1,40 @@
import copy
import typing
import uuid
-from tornado.web import RequestHandler, StaticFileHandler
-from tornado.iostream import StreamClosedError
+
import msgspec
+
from msgspec import DecodeError as MsgspecJSONDecodeError
+from tornado.iostream import StreamClosedError
+from tornado.web import RequestHandler, StaticFileHandler
-from ...utils import format_exception_as_json, get_current_async_loop
from ...config import global_config
+from ...constants import JSONSerializable, Operations
from ...core.zmq.brokers import AsyncEventConsumer, EventConsumer
from ...core.zmq.message import (
EMPTY_BYTE,
- TIMEOUT,
ERROR,
INVALID_MESSAGE,
+ TIMEOUT,
ResponseMessage,
+ SerializableNone,
+ ServerExecutionContext,
+ ThingExecutionContext,
default_server_execution_context,
default_thing_execution_context,
- ThingExecutionContext,
- ServerExecutionContext,
)
-from ...core.zmq.message import SerializableNone
-from ...constants import JSONSerializable, Operations
from ...schema_validators import BaseSchemaValidator
-from ...serializers.payloads import PreserializedData, SerializableData
from ...serializers import Serializers
+from ...serializers.payloads import PreserializedData, SerializableData
from ...td import (
- InteractionAffordance,
- PropertyAffordance,
ActionAffordance,
EventAffordance,
+ InteractionAffordance,
+ PropertyAffordance,
)
from ...td.forms import Form
+from ...utils import format_exception_as_json, get_current_async_loop
+
try:
from ..security import BcryptBasicSecurity
@@ -662,6 +665,7 @@ def initialize(self, owner_inst=None) -> None:
self.server = owner_inst
self.allowed_clients = self.server.allowed_clients
self.security_schemes = self.server.security_schemes
+ self.logger = self.server.logger.bind(path=self.request.path)
async def post(self):
if not self.has_access_control:
@@ -691,6 +695,7 @@ def initialize(self, owner_inst=None) -> None:
assert isinstance(owner_inst, HTTPServer)
self.server = owner_inst
+ self.logger = self.server.logger.bind(path=self.request.path)
async def get(self):
self.set_status(200, "ok")
@@ -704,6 +709,7 @@ def initialize(self, owner_inst=None) -> None:
assert isinstance(owner_inst, HTTPServer)
self.server = owner_inst
+ self.logger = self.server.logger.bind(path=self.request.path)
async def get(self):
try:
diff --git a/hololinked/utils.py b/hololinked/utils.py
index f19b3fc1..0f24b892 100644
--- a/hololinked/utils.py
+++ b/hololinked/utils.py
@@ -1,19 +1,20 @@
-import sys
-import logging
-import re
import asyncio
import inspect
-import typing
-import types
-import traceback
-import ifaddr
+import logging
+import re
+import sys
import threading
-from functools import wraps
+import traceback
+import types
+import typing
from collections import OrderedDict
from dataclasses import asdict
-from pydantic import BaseModel, ConfigDict, create_model, Field, RootModel
+from functools import wraps
from inspect import Parameter, signature
+import ifaddr
+from pydantic import BaseModel, ConfigDict, Field, RootModel, create_model
+
def get_IP_from_interface(interface_name: str = "Ethernet", adapter_name=None) -> str:
"""
diff --git a/pyproject.toml b/pyproject.toml
index a14503e6..8cc0bcdb 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -88,7 +88,8 @@ test = [
"fastjsonschema==2.20.0",
"pytest>=8.0.0",
"pytest-cov>=4.0.0",
- "pytest-order>=1.0.0"
+ "pytest-order>=1.0.0",
+ "pytest-asyncio>=1.3.0",
]
linux = [
"uvloop==0.20.0"
@@ -97,14 +98,17 @@ linux = [
[tool.pytest.ini_options]
minversion = "8.0"
addopts = "-ra --strict-markers --strict-config --ignore=lib64"
-testpaths = ["tests/pytests-new"]
-python_files = ["test_*_pytest.py"]
+testpaths = ["tests"]
+norecursedirs = ["tests/yet-to-be-integrated*"]
+python_files = ["test_*.py"]
python_classes = ["Test*"]
python_functions = ["test_*"]
+asyncio_mode = "auto"
markers = [
"order: mark test to run in a specific order",
"slow: marks tests as slow (deselect with '-m \"not slow\"')",
- "integration: marks tests as integration tests"
+ "integration: marks tests as integration tests",
+ "asyncio: mark test as asyncio to run with pytest-asyncio"
]
filterwarnings = [
"error",
@@ -117,4 +121,4 @@ filterwarnings = [
exclude = [
"hololinked/core/properties.py",
"hololinked/param"
-]
+]
\ No newline at end of file
diff --git a/tests/conftest.py b/tests/conftest.py
new file mode 100644
index 00000000..60340fd6
--- /dev/null
+++ b/tests/conftest.py
@@ -0,0 +1,65 @@
+"""pytest configuration and shared fixtures for hololinked tests"""
+
+import asyncio
+import logging
+from dataclasses import dataclass
+from uuid import uuid4
+
+import pytest
+import zmq.asyncio
+
+from hololinked.config import global_config
+from hololinked.logger import setup_logging
+from hololinked.serializers import Serializers
+from hololinked.server import stop
+from hololinked.utils import set_global_event_loop_policy
+
+
+@dataclass
+class AppIDs:
+ """
+ Application related IDs generally used by end-user,
+ like server, client, and thing IDs.
+ """
+
+ server_id: str
+ """RPC server ID"""
+ client_id: str
+ """A client ID"""
+ thing_id: str
+ """A thing ID"""
+
+
+@pytest.fixture(scope="session")
+def event_loop():
+ try:
+ loop = asyncio.get_running_loop()
+ except RuntimeError:
+ loop = asyncio.new_event_loop()
+ yield loop
+ loop.close()
+
+
+@pytest.fixture(autouse=True, scope="module")
+def setup_test_environment():
+ """Automatically setup test environment for each file"""
+ # This fixture runs automatically for every test
+ set_global_event_loop_policy()
+ global_config.ZMQ_CONTEXT = zmq.asyncio.Context()
+ setup_logging(log_level=logging.ERROR + 10)
+ yield
+ stop()
+ # Reset serializers after each test
+ Serializers().reset()
+ global_config.ZMQ_CONTEXT.destroy(linger=0)
+ global_config.ZMQ_CONTEXT.term()
+
+
+@pytest.fixture()
+def app_ids() -> AppIDs:
+ """Generate unique test IDs for server, client, and thing for each test"""
+ return AppIDs(
+ server_id=f"test-server-{uuid4().hex[:8]}",
+ client_id=f"test-client-{uuid4().hex[:8]}",
+ thing_id=f"test-thing-{uuid4().hex[:8]}",
+ )
diff --git a/tests/helper-scripts/run_testthing_basic.py b/tests/helper-scripts/run_testthing_basic.py
deleted file mode 100644
index ef09f147..00000000
--- a/tests/helper-scripts/run_testthing_basic.py
+++ /dev/null
@@ -1,16 +0,0 @@
-# run_testthing_basic.py
-import logging, time, uuid
-from tests.things.test_thing import TestThing
-from hololinked.server.security import BcryptBasicSecurity
-
-thing_id = f"tt-{uuid.uuid4().hex[:6]}"
-port = 60110
-sec = BcryptBasicSecurity(username="cliuser", password="clipass")
-
-thing = TestThing(id=thing_id, log_level=logging.INFO)
-thing.run_with_http_server(forked=True, port=port, config={"allow_cors": True}, security_schemes=[sec])
-
-print(f"TD: http://127.0.0.1:{port}/{thing_id}/resources/wot-td")
-print(f"Prop: http://127.0.0.1:{port}/{thing_id}/base-property")
-while True:
- time.sleep(5)
diff --git a/tests/not working - yet to be integrated/test_09_rpc_broker.py b/tests/not working - yet to be integrated/test_09_rpc_broker.py
deleted file mode 100644
index 4f2e350e..00000000
--- a/tests/not working - yet to be integrated/test_09_rpc_broker.py
+++ /dev/null
@@ -1,1100 +0,0 @@
-import asyncio
-import copy
-import threading
-import typing
-import unittest
-import jsonschema
-import logging
-import random
-import time
-import structlog
-from types import SimpleNamespace
-
-from hololinked.client.abstractions import SSE
-from hololinked.core.actions import BoundAction
-from hololinked.core.property import Property
-from hololinked.core.thing import Thing
-from hololinked.core.zmq.brokers import (
- AsyncEventConsumer,
- AsyncZMQClient,
- EventConsumer,
- EventPublisher,
- SyncZMQClient,
-)
-from hololinked.core.zmq.message import EXIT, RequestMessage
-from hololinked.core.zmq.rpc_server import RPCServer
-from hololinked.server.zmq import ZMQServer
-from hololinked.td.forms import Form
-from hololinked.td.utils import get_zmq_unique_identifier_from_event_affordance
-from hololinked.utils import get_all_sub_things_recusively, get_current_async_loop
-from hololinked.config import global_config
-from hololinked.td import ActionAffordance, PropertyAffordance, EventAffordance
-from hololinked.client.zmq.consumed_interactions import ZMQAction, ZMQProperty, ZMQEvent
-from hololinked.logger import setup_logging
-
-try:
- from .test_05_brokers import TestBrokerMixin
- from .test_06_actions import replace_methods_with_actions
- from .utils import TestRunner, TestCase
- from .things import (
- run_thing_with_zmq_server_forked,
- test_thing_TD as test_thing_original_TD,
- TestThing,
- )
-except ImportError:
- from test_05_brokers import TestBrokerMixin
- from test_06_actions import replace_methods_with_actions
- from utils import TestRunner, TestCase
- from things import (
- run_thing_with_zmq_server_forked,
- test_thing_TD as test_thing_original_TD,
- TestThing,
- )
-
-data_structures = [
- {"key": "value"},
- [1, 2, 3],
- "string",
- 42,
- 3.14,
- True,
- None,
- {"nested": {"key": "value"}},
- [{"list": "of"}, {"dicts": "here"}],
- {"complex": {"nested": {"list": [1, 2, 3]}, "mixed": [1, "two", 3.0, None]}},
- {"array": [1, 2, 3]},
-] # to use for testing
-
-
-# global_config.DEBUG = True
-setup_logging(log_level=logging.ERROR)
-
-
-class InteractionAffordanceMixin(TestBrokerMixin):
- @classmethod
- def setUpClass(cls):
- super().setUpClass()
- cls.setUpActions()
- cls.setUpProperties()
- cls.setUpEvents()
-
- @classmethod
- def setUpActions(cls):
- owner_inst = SimpleNamespace(_noblock_messages={})
- test_thing_TD = copy.deepcopy(test_thing_original_TD)
- test_thing_TD["id"] = cls.thing_id
- cls.action_echo = ZMQAction(
- resource=ActionAffordance.from_TD("action_echo", test_thing_TD),
- sync_client=cls.sync_client,
- async_client=cls.async_client,
- owner_inst=owner_inst,
- logger=structlog.get_logger(),
- invokation_timeout=5,
- execution_timeout=5,
- )
-
- cls.action_get_serialized_data = ZMQAction(
- resource=ActionAffordance.from_TD("get_serialized_data", test_thing_TD),
- sync_client=cls.sync_client,
- async_client=cls.async_client,
- owner_inst=owner_inst,
- logger=structlog.get_logger(),
- invokation_timeout=5,
- execution_timeout=5,
- )
-
- cls.action_sleep = ZMQAction(
- resource=ActionAffordance.from_TD("sleep", test_thing_TD),
- sync_client=cls.sync_client,
- async_client=cls.async_client,
- owner_inst=owner_inst,
- logger=structlog.get_logger(),
- invokation_timeout=5,
- execution_timeout=5,
- )
-
- cls.action_get_mixed_content_data = ZMQAction(
- resource=ActionAffordance.from_TD("get_mixed_content_data", test_thing_TD),
- sync_client=cls.sync_client,
- async_client=cls.async_client,
- owner_inst=owner_inst,
- logger=structlog.get_logger(),
- invokation_timeout=5,
- execution_timeout=5,
- )
- cls.action_push_events = ZMQAction(
- resource=ActionAffordance.from_TD("push_events", test_thing_TD),
- sync_client=cls.sync_client,
- async_client=cls.async_client,
- owner_inst=owner_inst,
- logger=structlog.get_logger(),
- invokation_timeout=5,
- execution_timeout=5,
- )
-
- @classmethod
- def setUpProperties(cls):
- owner_inst = SimpleNamespace(_noblock_messages={})
- test_thing_TD = copy.deepcopy(test_thing_original_TD)
- test_thing_TD["id"] = cls.thing_id
- cls.base_property = ZMQProperty(
- resource=PropertyAffordance.from_TD("base_property", test_thing_TD),
- sync_client=cls.sync_client,
- async_client=cls.async_client,
- owner_inst=owner_inst,
- logger=structlog.get_logger(),
- invokation_timeout=5,
- execution_timeout=5,
- )
- cls.total_number_of_events = ZMQProperty(
- resource=PropertyAffordance.from_TD("total_number_of_events", test_thing_TD),
- sync_client=cls.sync_client,
- async_client=cls.async_client,
- owner_inst=owner_inst,
- logger=structlog.get_logger(),
- invokation_timeout=5,
- execution_timeout=5,
- )
-
- @classmethod
- def setUpEvents(cls):
- owner_inst = SimpleNamespace(_noblock_messages={})
- test_thing_TD = copy.deepcopy(test_thing_original_TD)
- test_thing_TD["id"] = cls.thing_id
- cls.test_event = ZMQEvent(
- resource=EventAffordance.from_TD("test_event", test_thing_TD),
- owner_inst=owner_inst,
- logger=structlog.get_logger(),
- )
-
-
-class TestRPCServerMixin(InteractionAffordanceMixin):
- @classmethod
- def setUpThing(cls):
- replace_methods_with_actions(TestThing)
- super().setUpThing()
-
- @classmethod
- def setUpServer(cls):
- cls.server = RPCServer(id=cls.server_id, things=[cls.thing])
-
- @classmethod
- def setUpClient(cls):
- cls.async_client = AsyncZMQClient(
- id=cls.client_id,
- server_id=cls.server_id,
- access_point="INPROC",
- handshake=False,
- )
- cls.sync_client = SyncZMQClient(
- id=cls.client_id + "-sync",
- server_id=cls.server_id,
- access_point="INPROC",
- handshake=False,
- )
-
- @classmethod
- def startServer(cls):
- cls._server_thread = threading.Thread(
- target=cls.server.run,
- daemon=False, # to test exit daemon must be False
- )
- cls._server_thread.start()
-
- @classmethod
- def setUpClass(cls):
- super().setUpClass()
- print(f"test ZMQ RPC Server {cls.__name__}")
-
- @classmethod
- def tearDownClass(cls):
- cls.server.stop()
- return super().tearDownClass()
-
-
-class TestInprocRPCServer(TestRPCServerMixin):
- def test_1_creation_defaults(self):
- """test server configuration defaults"""
- self.assertTrue(self.server.req_rep_server.socket_address.startswith("inproc://"))
- self.assertTrue(self.server.event_publisher.socket_address.startswith("inproc://"))
-
- self.assertTrue(self.thing.rpc_server, self.server)
- self.assertTrue(self.thing.event_publisher, self.server.event_publisher)
-
- def test_2_handshake(self):
- """test handshake mechanisms"""
- self.sync_client.handshake()
-
- async def async_handshake():
- self.async_client.handshake()
- await self.async_client.handshake_complete()
-
- get_current_async_loop().run_until_complete(async_handshake())
-
- def test_3_action_abstractions(self):
- """ "test if action can be invoked by a client"""
-
- async def test_basic_operations():
- """Test if action can be invoked by a client in basic request/response way, oneway and no block"""
- nonlocal self
- await self.action_echo.async_call("value")
- self.action_echo.oneway(5)
- noblock_msg_id = self.action_echo.noblock(10)
- self.assertEqual(self.action_echo.last_return_value, "value")
- # test the responses for no block call, so read the socket - but, this is usually abstracte in a higher level API
- response = self.action_echo._sync_zmq_client.recv_response(noblock_msg_id)
- self.action_echo._last_zmq_response = response
- self.assertEqual(self.action_echo.last_return_value, 10)
- self.assertEqual(self.action_echo(2), 2)
-
- get_current_async_loop().run_until_complete(test_basic_operations())
- self.sync_client.handshake()
-
- async def test_operations_thorough():
- # Generate 20 random JSON serializable data structures
- nonlocal self
- global data_structures
-
- msg_ids = [None for i in range(len(data_structures))]
- last_call_type = None
- # Randomize calls to self.action_echo
- for index, data in enumerate(data_structures):
- call_type = random.choice(["async_call", "plain_call", "oneway", "noblock"])
- if call_type == "async_call":
- result = await self.action_echo.async_call(data)
- self.assertEqual(result, data)
- elif call_type == "plain_call":
- result = self.action_echo(data)
- self.assertEqual(result, data)
- elif call_type == "oneway":
- self.action_echo.oneway(data)
- self.assertNotEqual(data, self.action_echo.last_return_value)
- elif call_type == "noblock":
- msg_ids[index] = self.action_echo.noblock(data)
- self.assertNotEqual(data, self.action_echo.last_return_value)
-
- # print("last_call_type", last_call_type, "call_type", call_type, "data", data)
- if last_call_type == "noblock":
- response = self.action_echo._sync_zmq_client.recv_response(msg_ids[index - 1])
- self.action_echo._last_zmq_response = response
- self.assertEqual(self.action_echo.last_return_value, data_structures[index - 1])
-
- last_call_type = call_type
-
- get_current_async_loop().run_until_complete(test_operations_thorough())
- self.sync_client.handshake()
-
- def test_4_property_abstractions(self):
- """Test if property can be invoked by a client"""
-
- def test_basic_operations():
- nonlocal self
- self.base_property.set(100)
- self.assertEqual(self.base_property.get(), 100)
- self.base_property.oneway_set(200)
- self.assertEqual(self.base_property.get(), 200)
-
- async def test_async_property_abstractions():
- nonlocal self
- await self.base_property.async_set(300)
- self.assertEqual(self.base_property.get(), 300)
- await self.base_property.async_set(0)
- self.assertEqual(await self.base_property.async_get(), 0)
-
- get_current_async_loop().run_until_complete(test_async_property_abstractions())
-
- test_basic_operations()
- self.sync_client.handshake()
-
- async def test_operations_thorough():
- # Generate 20 random JSON serializable data structures
- nonlocal self
- global data_structures
-
- msg_ids = [None for i in range(len(data_structures))]
- last_call_type = None
- # Randomize calls to self.action_echo
- for index, data in enumerate(data_structures):
- call_type = random.choice(["async_set", "set", "oneway_set", "noblock_get"])
- if call_type == "async_set":
- self.assertIsNone(await self.base_property.async_set(data))
- self.assertEqual(await self.base_property.async_get(), data)
- elif call_type == "set":
- self.assertIsNone(self.base_property.set(data))
- self.assertEqual(self.base_property.get(), data)
- elif call_type == "oneway_set":
- self.assertIsNone(self.base_property.oneway_set(data))
- self.assertNotEqual(data, self.base_property.last_read_value)
- self.assertEqual(data, self.base_property.get())
- # for one way calls as well, get() will return the latest value
- elif call_type == "noblock_get":
- msg_ids[index] = self.base_property.noblock_get()
- self.assertNotEqual(data, self.base_property.last_read_value)
-
- # print("last_call_type", last_call_type, "call_type", call_type, "data", data)
- if last_call_type == "noblock":
- response = self.base_property._sync_zmq_client.recv_response(msg_ids[index - 1])
- self.base_property._last_zmq_response = response
- self.assertEqual(self.base_property.last_read_value, data_structures[index - 1])
-
- last_call_type = call_type
-
- get_current_async_loop().run_until_complete(test_operations_thorough())
- self.sync_client.handshake()
-
- def test_5_thing_execution_context(self):
- """test if thing execution context is used correctly"""
- old_thing_execution_context = self.action_echo._thing_execution_context
- # Only fetch_execution_logs currently supported
- self.action_echo._thing_execution_context = dict(fetch_execution_logs=True)
- get_current_async_loop().run_until_complete(self.action_echo.async_call("value"))
- self.assertIsInstance(self.action_echo.last_return_value, dict)
- self.assertTrue("execution_logs" in self.action_echo.last_return_value.keys())
- self.assertTrue("return_value" in self.action_echo.last_return_value.keys())
- self.assertTrue(len(self.action_echo.last_return_value) == 2)
- self.assertFalse(self.action_echo.last_return_value == "value") # because its a dict now
- self.assertIsInstance(self.action_echo.last_return_value["execution_logs"], list)
- self.assertTrue(self.action_echo.last_return_value["return_value"] == "value")
- self.action_echo._thing_execution_context = old_thing_execution_context
-
- def test_6_server_execution_context(self):
- """test if server execution context is used correctly"""
-
- async def test_execution_timeout():
- try:
- await self.action_sleep.async_call()
- except Exception as ex:
- self.assertIsInstance(ex, TimeoutError)
- self.assertIn("Execution timeout occured", str(ex))
- else:
- self.assertTrue(False) # fail the test if reached here
-
- get_current_async_loop().run_until_complete(test_execution_timeout())
-
- async def test_invokation_timeout():
- try:
- old_timeout = self.action_sleep._invokation_timeout
- self.action_sleep._invokation_timeout = 0.1 # reduce the value to test timeout
- await self.action_sleep.async_call()
- except Exception as ex:
- self.assertIsInstance(ex, TimeoutError)
- self.assertIn("Invokation timeout occured", str(ex))
- else:
- self.assertTrue(False) # fail the test if reached here
- finally:
- self.action_sleep._invokation_timeout = old_timeout
-
- get_current_async_loop().run_until_complete(test_invokation_timeout())
-
- def test_7_binary_payloads(self):
- """test if binary payloads are handled correctly"""
- self.assertEqual(self.action_get_mixed_content_data(), ("foobar", b"foobar"))
- self.assertEqual(self.action_get_serialized_data(), b"foobar")
-
- async def async_call():
- await self.action_get_mixed_content_data.async_call()
- return self.action_get_mixed_content_data.last_return_value
-
- result = get_current_async_loop().run_until_complete(async_call())
- self.assertEqual(result, ("foobar", b"foobar"))
-
- async def async_call():
- await self.action_get_serialized_data.async_call()
- return self.action_get_serialized_data.last_return_value
-
- result = get_current_async_loop().run_until_complete(async_call())
- self.assertEqual(result, b"foobar")
-
- def test_8_stop(self):
- """test if server can be stopped"""
- self.server.stop()
-
-
-class TestRPCServer(TestInprocRPCServer):
- @classmethod
- def setUpServer(cls):
- cls.server = ZMQServer(
- id=cls.server_id,
- things=[cls.thing],
- access_points=["INPROC", "IPC", "tcp://*:59000"],
- )
-
- @classmethod
- def setUpClient(cls):
- super().setUpClient()
- cls.sync_ipc_client = SyncZMQClient(
- id=cls.client_id + "-sync",
- server_id=cls.server_id,
- handshake=False,
- access_point="IPC",
- )
- cls.sync_tcp_client = SyncZMQClient(
- id=cls.client_id + "-sync",
- server_id=cls.server_id,
- handshake=False,
- access_point="tcp://localhost:59000",
- )
- cls.async_ipc_client = AsyncZMQClient(
- id=cls.client_id + "-async",
- server_id=cls.server_id,
- handshake=False,
- access_point="IPC",
- )
- cls.async_tcp_client = AsyncZMQClient(
- id=cls.client_id + "-async",
- server_id=cls.server_id,
- handshake=False,
- access_point="tcp://localhost:59000",
- )
-
- def test_1_creation_defaults(self):
- super().test_1_creation_defaults()
- # check socket creation defaults
- self.assertTrue(self.server.ipc_server.socket_address.startswith("ipc://"))
- self.assertTrue(self.server.tcp_server.socket_address.startswith("tcp://"))
- self.assertTrue(self.server.tcp_server.socket_address.endswith(":59000"))
-
- def test_2_handshake(self):
- super().test_2_handshake()
- self.sync_ipc_client.handshake()
- self.sync_tcp_client.handshake()
-
- async def async_handshake():
- self.async_ipc_client.handshake()
- await self.async_ipc_client.handshake_complete()
- self.async_tcp_client.handshake()
- await self.async_tcp_client.handshake_complete()
-
- get_current_async_loop().run_until_complete(async_handshake())
-
- def test_3_action_abstractions(self):
- old_sync_client = self.action_echo._sync_zmq_client
- old_async_client = self.action_echo._async_zmq_client
- for clients in [
- (self.sync_tcp_client, self.async_tcp_client),
- (self.sync_ipc_client, self.async_ipc_client),
- ]:
- self.action_echo._sync_zmq_client, self.action_echo._async_zmq_client = clients
- super().test_3_action_abstractions()
- self.action_echo._sync_zmq_client = old_sync_client
- self.action_echo._async_zmq_client = old_async_client
-
- def test_4_property_abstractions(self):
- old_sync_client = self.base_property._sync_zmq_client
- old_async_client = self.base_property._async_zmq_client
- for clients in [
- (self.sync_tcp_client, self.async_tcp_client),
- (self.sync_ipc_client, self.async_ipc_client),
- ]:
- (
- self.base_property._sync_zmq_client,
- self.base_property._async_zmq_client,
- ) = clients
- super().test_4_property_abstractions()
- self.base_property._sync_zmq_client = old_sync_client
- self.base_property._async_zmq_client = old_async_client
-
- def test_5_thing_execution_context(self):
- old_sync_client = self.action_echo._sync_zmq_client
- old_async_client = self.action_echo._async_zmq_client
- for clients in [
- (self.sync_tcp_client, self.async_tcp_client),
- (self.sync_ipc_client, self.async_ipc_client),
- ]:
- self.action_echo._sync_zmq_client, self.action_echo._async_zmq_client = clients
- super().test_5_thing_execution_context()
- self.action_echo._sync_zmq_client = old_sync_client
- self.action_echo._async_zmq_client = old_async_client
-
- def test_6_server_execution_context(self):
- old_sync_client = self.action_sleep._sync_zmq_client
- old_async_client = self.action_sleep._async_zmq_client
- for clients in [
- (self.sync_tcp_client, self.async_tcp_client),
- (self.sync_ipc_client, self.async_ipc_client),
- ]:
- self.action_sleep._sync_zmq_client, self.action_sleep._async_zmq_client = clients
- super().test_6_server_execution_context()
- self.action_sleep._sync_zmq_client = old_sync_client
- self.action_sleep._async_zmq_client = old_async_client
-
- def test_7_binary_payloads(self):
- for clients in [
- (self.sync_tcp_client, self.async_tcp_client),
- (self.sync_ipc_client, self.async_ipc_client),
- ]:
- for action in [
- self.action_get_serialized_data,
- self.action_get_mixed_content_data,
- ]:
- action._sync_zmq_client, action._async_zmq_client = clients
- super().test_7_binary_payloads()
-
-
-class TestExposedActions(InteractionAffordanceMixin):
- @classmethod
- def setUpServer(cls):
- pass
-
- @classmethod
- def setUpThing(cls):
- pass
-
- @classmethod
- def startServer(cls):
- run_thing_with_zmq_server_forked(
- thing_cls=TestThing,
- id=cls.server_id,
- log_level=logging.ERROR + 10,
- done_queue=cls.done_queue,
- prerun_callback=replace_methods_with_actions,
- as_process=False,
- )
-
- @classmethod
- def setUpClient(cls):
- super().setUpClient()
- cls.sync_client = SyncZMQClient(
- id=cls.client_id,
- server_id=cls.server_id,
- handshake=False,
- )
- cls.client = cls.sync_client
-
- def test_1_exposed_actions(self):
- """
- Now that actions can be invoked by a client, test different types of actions
- and their behaviors
- """
- replace_methods_with_actions(TestThing)
- thing = TestThing(id=self.server_id)
- # has to match server only because run_thing_with_zmq_server_forked equates server_id and thing_id
- self.sync_client.handshake()
-
- # thing_client = ObjectProxy('test-action', log_level=logging.ERROR) # type: TestThing
- assert isinstance(thing.action_echo, BoundAction) # type definition
- action_echo = ZMQAction(
- resource=thing.action_echo.to_affordance(),
- sync_client=self.client,
- async_client=None,
- logger=structlog.get_logger(),
- owner_inst=None,
- )
- self.assertEqual(action_echo(1), 1)
-
- assert isinstance(thing.action_echo_with_classmethod, BoundAction) # type definition
- action_echo_with_classmethod = ZMQAction(
- resource=thing.action_echo_with_classmethod.to_affordance(),
- sync_client=self.client,
- async_client=None,
- logger=structlog.get_logger(),
- owner_inst=None,
- )
- self.assertEqual(action_echo_with_classmethod(2), 2)
-
- assert isinstance(thing.action_echo_async, BoundAction) # type definition
- action_echo_async = ZMQAction(
- resource=thing.action_echo_async.to_affordance(),
- sync_client=self.client,
- async_client=None,
- logger=structlog.get_logger(),
- owner_inst=None,
- )
- self.assertEqual(action_echo_async("string"), "string")
-
- assert isinstance(thing.action_echo_async_with_classmethod, BoundAction) # type definition
- action_echo_async_with_classmethod = ZMQAction(
- resource=thing.action_echo_async_with_classmethod.to_affordance(),
- sync_client=self.client,
- async_client=None,
- logger=structlog.get_logger(),
- owner_inst=None,
- )
- self.assertEqual(action_echo_async_with_classmethod([1, 2]), [1, 2])
-
- assert isinstance(thing.parameterized_action, BoundAction) # type definition
- parameterized_action = ZMQAction(
- resource=thing.parameterized_action.to_affordance(),
- sync_client=self.client,
- async_client=None,
- logger=structlog.get_logger(),
- owner_inst=None,
- )
- self.assertEqual(
- parameterized_action(arg1=1, arg2="hello", arg3=5),
- [self.server_id, 1, "hello", 5],
- )
-
- assert isinstance(thing.parameterized_action_async, BoundAction) # type definition
- parameterized_action_async = ZMQAction(
- resource=thing.parameterized_action_async.to_affordance(),
- sync_client=self.client,
- async_client=None,
- logger=structlog.get_logger(),
- owner_inst=None,
- )
- self.assertEqual(
- parameterized_action_async(arg1=2.5, arg2="hello", arg3="foo"),
- [self.server_id, 2.5, "hello", "foo"],
- )
-
- assert isinstance(thing.parameterized_action_without_call, BoundAction) # type definition
- parameterized_action_without_call = ZMQAction(
- resource=thing.parameterized_action_without_call.to_affordance(),
- sync_client=self.client,
- async_client=None,
- logger=structlog.get_logger(),
- owner_inst=None,
- )
- with self.assertRaises(NotImplementedError) as ex:
- parameterized_action_without_call(arg1=2, arg2="hello", arg3=5)
- self.assertTrue(str(ex.exception).startswith("Subclasses must implement __call__"))
-
- def test_2_schema_validation(self):
- """Test if schema validation is working correctly"""
- self._test_2_json_schema_validation()
- self._test_2_pydantic_validation()
-
- def _test_2_json_schema_validation(self):
- thing = TestThing(id=self.server_id)
- self.sync_client.handshake()
-
- # JSON schema validation
- assert isinstance(thing.json_schema_validated_action, BoundAction) # type definition
- action_affordance = thing.json_schema_validated_action.to_affordance()
- json_schema_validated_action = ZMQAction(
- resource=action_affordance, sync_client=self.client, async_client=None, owner_inst=None
- )
- # data with invalid schema
- with self.assertRaises(Exception) as ex1:
- json_schema_validated_action(val1="1", val2="hello", val3={"field": "value"}, val4=[])
- self.assertTrue(str(ex1.exception).startswith("'1' is not of type 'integer'"))
- with self.assertRaises(Exception) as ex2:
- json_schema_validated_action("1", val2="hello", val3={"field": "value"}, val4=[])
- self.assertTrue(str(ex2.exception).startswith("'1' is not of type 'integer'"))
- with self.assertRaises(Exception) as ex3:
- json_schema_validated_action(1, 2, val3={"field": "value"}, val4=[])
- self.assertTrue(str(ex3.exception).startswith("2 is not of type 'string'"))
- with self.assertRaises(Exception) as ex4:
- json_schema_validated_action(1, "hello", val3="field", val4=[])
- self.assertTrue(str(ex4.exception).startswith("'field' is not of type 'object'"))
- with self.assertRaises(Exception) as ex5:
- json_schema_validated_action(1, "hello", val3={"field": "value"}, val4="[]")
- self.assertTrue(str(ex5.exception).startswith("'[]' is not of type 'array'"))
- # data with valid schema
- return_value = json_schema_validated_action(val1=1, val2="hello", val3={"field": "value"}, val4=[])
- self.assertEqual(return_value, {"val1": 1, "val3": {"field": "value"}})
- jsonschema.Draft7Validator(action_affordance.output).validate(return_value)
-
- def _test_2_pydantic_validation(self):
- thing = TestThing(id=self.server_id)
- self.sync_client.handshake()
-
- # Pydantic schema validation
- assert isinstance(thing.pydantic_validated_action, BoundAction) # type definition
- action_affordance = thing.pydantic_validated_action.to_affordance()
- pydantic_validated_action = ZMQAction(
- resource=action_affordance, sync_client=self.client, async_client=None, owner_inst=None
- )
- # data with invalid schema
- with self.assertRaises(Exception) as ex1:
- pydantic_validated_action(val1="1", val2="hello", val3={"field": "value"}, val4=[])
- self.assertTrue(
- "validation error for pydantic_validated_action_input" in str(ex1.exception)
- and "val1" in str(ex1.exception)
- and "val2" not in str(ex1.exception)
- and "val3" not in str(ex1.exception)
- and "val4" not in str(ex1.exception)
- ) # {obj.name}_input is the pydantic model name
- with self.assertRaises(Exception) as ex2:
- pydantic_validated_action("1", val2="hello", val3={"field": "value"}, val4=[])
- self.assertTrue(
- "validation error for pydantic_validated_action_input" in str(ex2.exception)
- and "val1" in str(ex2.exception)
- and "val2" not in str(ex2.exception)
- and "val3" not in str(ex2.exception)
- and "val4" not in str(ex2.exception)
- )
- with self.assertRaises(Exception) as ex3:
- pydantic_validated_action(1, 2, val3={"field": "value"}, val4=[])
- self.assertTrue(
- "validation error for pydantic_validated_action_input" in str(ex3.exception)
- and "val1" not in str(ex3.exception)
- and "val2" in str(ex3.exception)
- and "val3" not in str(ex3.exception)
- and "val4" not in str(ex3.exception)
- )
- with self.assertRaises(Exception) as ex4:
- pydantic_validated_action(1, "hello", val3="field", val4=[])
- self.assertTrue(
- "validation error for pydantic_validated_action_input" in str(ex4.exception)
- and "val1" not in str(ex4.exception)
- and "val2" not in str(ex4.exception)
- and "val3" in str(ex4.exception)
- and "val4" not in str(ex4.exception)
- )
- with self.assertRaises(Exception) as ex5:
- pydantic_validated_action(1, "hello", val3={"field": "value"}, val4="[]")
- self.assertTrue(
- "validation error for pydantic_validated_action_input" in str(ex5.exception)
- and "val1" not in str(ex5.exception)
- and "val2" not in str(ex5.exception)
- and "val3" not in str(ex5.exception)
- and "val4" in str(ex5.exception)
- )
- # data with valid schema
- return_value = pydantic_validated_action(val1=1, val2="hello", val3={"field": "value"}, val4=[])
- self.assertEqual(return_value, {"val2": "hello", "val4": []})
-
- def test_3_exit(self):
- """Exit the server"""
- exit_message = RequestMessage.craft_with_message_type(
- sender_id="test-action-client",
- receiver_id=self.server_id,
- message_type=EXIT,
- )
- self.sync_client.socket.send_multipart(exit_message.byte_array)
- self.assertEqual(self.done_queue.get(), self.server_id)
-
-
-class TestExposedProperties(InteractionAffordanceMixin):
- @classmethod
- def setUpThing(cls):
- pass
-
- @classmethod
- def setUpServer(cls):
- pass
-
- @classmethod
- def startServer(cls):
- run_thing_with_zmq_server_forked(
- thing_cls=TestThing,
- id=cls.server_id,
- log_level=logging.ERROR + 10,
- done_queue=cls.done_queue,
- as_process=False,
- )
-
- @classmethod
- def setUpClient(cls):
- super().setUpClient()
- cls.sync_client = SyncZMQClient(
- id=cls.client_id,
- server_id=cls.server_id,
- handshake=False,
- )
- cls.client = cls.sync_client
-
- def test_01_property_abstractions(self):
- thing = TestThing(id=self.server_id)
- self.sync_client.handshake()
-
- descriptor = thing.properties["number_prop"]
- assert isinstance(descriptor, Property) # type definition
- number_prop = ZMQProperty(
- resource=descriptor.to_affordance(thing),
- sync_client=self.client,
- async_client=None,
- logger=structlog.get_logger(),
- owner_inst=None,
- )
- self.assertEqual(number_prop.get(), descriptor.default)
- number_prop.set(100)
- self.assertEqual(number_prop.get(), 100)
- number_prop.oneway_set(200)
- self.assertEqual(number_prop.get(), 200)
-
- async def test_6_async_property_abstractions(self: "TestThing"):
- nonlocal number_prop
- async_client = AsyncZMQClient(
- id="test-property-async-client",
- server_id=self.server_id,
- handshake=False,
- )
- number_prop._async_zmq_client = async_client
- async_client.handshake()
- await async_client.handshake_complete()
- await number_prop.async_set(300)
- self.assertEqual(number_prop.get(), 300)
- await number_prop.async_set(0)
- self.assertEqual(await number_prop.async_get(), 0)
-
- get_current_async_loop().run_until_complete(test_6_async_property_abstractions(self))
-
- def test_02_json_schema_property(self):
- """Test json schema based property"""
- test_thing_TD = copy.deepcopy(test_thing_original_TD)
- test_thing_TD["id"] = self.server_id # thing id should be server id. TODO refactor this
- json_schema_prop = ZMQProperty(
- resource=PropertyAffordance.from_TD("json_schema_prop", test_thing_TD),
- sync_client=self.client,
- async_client=None,
- logger=structlog.get_logger(),
- owner_inst=None,
- )
- json_schema_prop.resource._thing_id = self.server_id
- json_schema_prop.set("hello")
- self.assertEqual(json_schema_prop.get(), "hello")
- json_schema_prop.set("world")
- self.assertEqual(json_schema_prop.get(), "world")
- with self.assertRaises(Exception) as ex:
- json_schema_prop.set("world1")
- self.assertTrue("Failed validating 'pattern' in schema:" in str(ex.exception))
-
- def test_03_pydantic_model_property(self):
- """Test pydantic model based property"""
- test_thing_TD = copy.deepcopy(test_thing_original_TD)
- test_thing_TD["id"] = self.server_id # thing id should be server id. TODO refactor this
- pydantic_prop = ZMQProperty(
- resource=PropertyAffordance.from_TD("pydantic_prop", test_thing_TD),
- sync_client=self.client,
- async_client=None,
- logger=structlog.get_logger(),
- owner_inst=None,
- )
- pydantic_prop.resource._thing_id = self.server_id
-
- valid_value = {"foo": "foo", "bar": 1, "foo_bar": 1.0}
- pydantic_prop.set(valid_value)
- self.assertEqual(pydantic_prop.get(), valid_value)
-
- invalid_value = {"foo": 1, "bar": "1", "foo_bar": 1.0}
- with self.assertRaises(Exception) as ex:
- pydantic_prop.set(invalid_value)
- self.assertTrue("validation error for PydanticProp" in str(ex.exception))
-
- pydantic_simple_prop = ZMQProperty(
- resource=PropertyAffordance.from_TD("pydantic_simple_prop", test_thing_TD),
- sync_client=self.client,
- async_client=None,
- logger=structlog.get_logger(),
- owner_inst=None,
- )
- pydantic_simple_prop.resource._thing_id = self.server_id
- pydantic_simple_prop.set(5)
- self.assertEqual(pydantic_simple_prop.get(), 5)
- with self.assertRaises(Exception) as ex:
- pydantic_simple_prop.set("5str")
- self.assertTrue("validation error for 'int'" in str(ex.exception))
-
- def test_04_exit(self):
- exit_message = RequestMessage.craft_with_message_type(
- sender_id="test-property-client",
- receiver_id=self.server_id,
- message_type=EXIT,
- )
- self.sync_client.socket.send_multipart(exit_message.byte_array)
- self.assertEqual(self.done_queue.get(), self.server_id)
-
-
-class TestExposedEvents(TestRPCServerMixin):
- @classmethod
- def setUpServer(cls):
- cls.server = ZMQServer(
- id=cls.server_id,
- things=[cls.thing],
- access_points=["INPROC", "IPC", "tcp://*:59005"],
- )
-
- @classmethod
- def setUpEvents(cls):
- test_thing_TD = copy.deepcopy(test_thing_original_TD)
- test_thing_TD["id"] = cls.thing_id
- cls.event_names = [
- "test_event",
- "test_binary_payload_event",
- "test_event_with_json_schema",
- ]
- for event_name in cls.event_names:
- event_affordance = EventAffordance.from_TD(event_name, test_thing_TD)
- form = Form()
- form.href = cls.server.event_publisher.socket_address
- form.contentType = "application/json"
- form.op = "subscribeevent"
- form.subprotocol = "sse"
- event_affordance.forms = [form]
- event = ZMQEvent(resource=event_affordance, logger=structlog.get_logger(), owner_inst=None)
- setattr(cls, event_name, event)
-
- def test_1_creation_defaults(self):
- """test server configuration defaults"""
- all_things = get_all_sub_things_recusively(self.thing)
- self.assertTrue(len(all_things) > 1) # run the test only if there are sub things
- for thing in all_things:
- assert isinstance(thing, Thing)
- for name, event in thing.events.values.items():
- self.assertTrue(event.publisher, self.server.event_publisher)
- self.assertIsInstance(event._unique_identifier, str)
- self.assertEqual(event._owner_inst, thing)
-
- def test_2_sync_client_event_stream(self):
- """test if event can be streamed by a synchronous threaded client"""
-
- def test_events(event_name: str, expected_data: typing.Any) -> None:
- event_client = getattr(self, event_name) # type: ZMQEvent
-
- self.assertEqual(
- get_zmq_unique_identifier_from_event_affordance(event_client.resource),
- getattr(self.thing, event_client.resource.name)._unique_identifier, # type: EventDispatcher
- )
- attempts = 100
- results = []
-
- def cb(value: SSE):
- nonlocal results
- results.append(value)
-
- event_client.subscribe(cb)
- time.sleep(5) # calm down for event publisher to connect fully as there is no handshake for events
- self.action_push_events(event_name=event_name, total_number_of_events=attempts)
-
- for i in range(attempts):
- if len(results) == attempts:
- break
- time.sleep(0.1)
- self.assertAlmostEqual(len(results), attempts, delta=3)
- self.assertEqual([res.data for res in results], [expected_data] * len(results))
- event_client.unsubscribe()
-
- for name, data in zip(
- self.event_names,
- [
- "test data",
- b"test data",
- {
- "val1": 1,
- "val2": "test",
- "val3": {"key": "value"},
- "val4": [1, 2, 3],
- },
- ],
- ):
- test_events(name, data)
-
- def test_3_async_client_event_stream(self):
- """test if event can be streamed by an asynchronous client in an async loop"""
-
- async def test_events(event_name: str, expected_data: typing.Any) -> None:
- event_client = getattr(self, event_name) # type: ZMQEvent
- self.assertEqual(
- get_zmq_unique_identifier_from_event_affordance(event_client.resource),
- getattr(self.thing, event_client.resource.name)._unique_identifier, # type: EventDispatcher
- )
- attempts = 100
- results = []
-
- def cb(value: SSE):
- nonlocal results
- # print("event callback", value)
- results.append(value)
-
- event_client.subscribe(cb, asynch=True)
- time.sleep(5) # calm down for event publisher to connect fully as there is no handshake for events
- self.action_push_events(event_name=event_name, total_number_of_events=attempts)
-
- for i in range(attempts):
- if len(results) == attempts:
- break
- await asyncio.sleep(0.1)
- self.assertAlmostEqual(len(results), attempts, delta=3)
- # since we are pushing events in multiple protocols, sometimes the event from the previous test is
- # still lingering on the socket. So the captured event must be at least the number of attempts.
- self.assertEqual([res.data for res in results], [expected_data] * len(results))
- event_client.unsubscribe()
-
- for name, data in zip(
- self.event_names,
- [
- "test data",
- b"test data",
- {
- "val1": 1,
- "val2": "test",
- "val3": {"key": "value"},
- "val4": [1, 2, 3],
- },
- ],
- ):
- get_current_async_loop().run_until_complete(test_events(name, data))
-
- def test_4_other_transports(self):
- test_thing_TD = copy.deepcopy(test_thing_original_TD)
- test_thing_TD["id"] = self.thing_id
- for publisher in [
- self.server.ipc_event_publisher,
- self.server.tcp_event_publisher,
- ]:
- self.assertIsInstance(publisher, EventPublisher)
- self.assertTrue(
- publisher.socket_address.startswith("tcp://") or publisher.socket_address.startswith("ipc://")
- )
- for event_name in self.event_names:
- event_affordance = EventAffordance.from_TD(event_name, test_thing_TD)
- event = getattr(self, event_name) # type: ZMQEvent
- form = Form()
- form.href = publisher.socket_address.replace("*", "localhost")
- form.contentType = "application/json"
- form.op = "subscribeevent"
- form.subprotocol = "sse"
- event_affordance.forms = [form]
- event.resource.forms = event_affordance.forms
- self.test_2_sync_client_event_stream()
- self.test_3_async_client_event_stream()
-
- def test_5_exit(self):
- self.server.stop()
-
-
-class TestThingRunRPCServer(TestBrokerMixin):
- """Finally check if the thing can be run with a ZMQ server directly"""
-
- @classmethod
- def setUpThing(self):
- self.thing = TestThing(id=self.thing_id, remote_accessible_logger=True)
-
- @classmethod
- def startServer(self):
- self.thing.run_with_zmq_server(forked=True)
- self.server = self.thing.rpc_server
- self.sync_client = SyncZMQClient(
- id=self.client_id,
- server_id=self.thing_id,
- handshake=False,
- access_point="INPROC",
- )
- self.async_client = AsyncZMQClient(
- id=self.client_id + "async",
- server_id=self.thing_id,
- handshake=False,
- access_point="INPROC",
- )
- time.sleep(2)
-
- def test_1_setup_zmq_server(self):
- self.assertIsInstance(self.thing.rpc_server, ZMQServer)
- self.assertIsInstance(self.thing.event_publisher, EventPublisher)
-
- def test_2_handshake(self):
- self.sync_client.handshake()
- self.async_client.handshake()
- get_current_async_loop().run_until_complete(self.async_client.handshake_complete())
-
- def test_3_stop(self):
- self.thing.rpc_server.stop()
-
-
-def load_tests(loader, tests, pattern):
- suite = unittest.TestSuite()
- # suite.addTest(unittest.TestLoader().loadTestsFromTestCase(TestInprocRPCServer))
- # suite.addTest(unittest.TestLoader().loadTestsFromTestCase(TestRPCServer))
- # suite.addTest(unittest.TestLoader().loadTestsFromTestCase(TestExposedActions))
- suite.addTest(unittest.TestLoader().loadTestsFromTestCase(TestExposedProperties))
- # suite.addTest(unittest.TestLoader().loadTestsFromTestCase(TestExposedEvents))
- # suite.addTest(unittest.TestLoader().loadTestsFromTestCase(TestThingRunRPCServer))
- return suite
-
-
-if __name__ == "__main__":
- runner = TestRunner()
- runner.run(load_tests(unittest.TestLoader(), None, None))
diff --git a/tests/not working - yet to be integrated/test_13_protocols_http.py b/tests/not working - yet to be integrated/test_13_protocols_http.py
deleted file mode 100644
index 605e44d0..00000000
--- a/tests/not working - yet to be integrated/test_13_protocols_http.py
+++ /dev/null
@@ -1,846 +0,0 @@
-import asyncio
-import base64
-import random
-import uuid
-import unittest
-import time
-import logging
-import requests
-from typing import Any
-from dataclasses import dataclass
-from types import SimpleNamespace
-
-from hololinked.client.abstractions import SSE
-from hololinked.utils import pep8_to_dashed_name
-from hololinked.config import global_config
-from hololinked.constants import ZMQ_TRANSPORTS
-from hololinked.core.zmq.message import (
- ServerExecutionContext,
- ThingExecutionContext,
- default_server_execution_context,
-)
-from hololinked.serializers import JSONSerializer
-from hololinked.serializers.payloads import PreserializedData, SerializableData
-from hololinked.serializers.serializers import (
- MsgpackSerializer,
- PickleSerializer,
- BaseSerializer,
-)
-from hololinked.core.meta import ThingMeta
-from hololinked.core.zmq.rpc_server import (
- RPCServer,
-) # sets loop policy, TODO: move somewhere else
-from hololinked.client.proxy import ObjectProxy
-from hololinked.client.factory import ClientFactory
-from hololinked.server.http import HTTPServer
-from hololinked.server.http.handlers import (
- PropertyHandler,
- RPCHandler,
- ThingDescriptionHandler,
-)
-from hololinked.server.security import Argon2BasicSecurity, BcryptBasicSecurity
-from hololinked.td.security_definitions import SecurityScheme
-
-try:
- from .things import OceanOpticsSpectrometer, TestThing
- from .utils import TestCase, TestRunner, fake
- from .test_11_rpc_e2e import TestRPCEndToEnd
-except ImportError:
- from things import OceanOpticsSpectrometer, TestThing
- from utils import TestCase, TestRunner, fake
- from test_11_rpc_e2e import TestRPCEndToEnd
-
-
-class TestHTTPServer(TestCase):
- def test_01_init_run_and_stop(self):
- """Test basic init, run and stop of the HTTP server."""
- # init, run and stop synchronously
- server = HTTPServer(port=60001)
- server.run(forked=True)
- time.sleep(5)
- server.stop()
- time.sleep(2)
-
- # stop remotely
- server.run(forked=True)
- time.sleep(5)
- response = requests.post(f"http://127.0.0.1:{server.port}/stop")
- self.assertIn(response.status_code, [200, 201, 202, 204])
- time.sleep(2)
-
- def test_02_add_interaction_affordance(self):
- """Test adding an interaction affordance to the HTTP server."""
- server = HTTPServer(log_level=logging.ERROR + 10)
-
- # add an interaction affordance
- server.add_property("/max-intensity", OceanOpticsSpectrometer.max_intensity)
- server.add_action("/connect", OceanOpticsSpectrometer.connect)
- server.add_event("/intensity/event", OceanOpticsSpectrometer.intensity_measurement_event)
-
- self.assertIn("/max-intensity", server.router)
- self.assertIn("/connect", server.router)
- self.assertIn("/intensity/event", server.router)
-
- # replacing interation affordances on an existing URL path causes a warning
- self.assertWarns(
- UserWarning,
- server.add_property,
- "/max-intensity",
- OceanOpticsSpectrometer.last_intensity,
- )
- self.assertWarns(
- UserWarning,
- server.add_action,
- "/connect",
- OceanOpticsSpectrometer.disconnect,
- )
- self.assertWarns(
- UserWarning,
- server.add_event,
- "/intensity/event",
- OceanOpticsSpectrometer.intensity_measurement_event,
- )
-
- def notest_03_add_thing(self):
- """Test adding a Thing object to the HTTP server."""
- # in principle works, but we need to refactor this logic a little bit more for the tests to pass,
- # and also probably refactor the tests themselves
- # add a thing, both class and instance
- server = HTTPServer(log_level=logging.ERROR + 10)
- for thing in [
- OceanOpticsSpectrometer(id="test", log_level=logging.ERROR + 10),
- TestThing(id="test-thing", log_level=logging.ERROR + 10),
- ]:
- old_number_of_rules = len(server.app.wildcard_router.rules) + len(server.router._pending_rules)
- server.add_things(thing)
- # TODO - reinstate rule numbers as they ensure that all routes were added
- # self.assertTrue(
- # len(server.app.wildcard_router.rules) + len(server.router._pending_rules) - old_number_of_rules >=
- # len(thing.properties.remote_objects) + len(thing.actions) + len(thing.events)
- # )
- # server.router.print_rules()
-
- old_number_of_rules = len(server.app.wildcard_router.rules) + len(server.router._pending_rules)
- for thing_meta in [OceanOpticsSpectrometer, TestThing]:
- self.assertRaises(TypeError, server.add_things, thing_meta)
- self.assertTrue(
- len(server.app.wildcard_router.rules) + len(server.router._pending_rules) == old_number_of_rules
- )
-
- # create new server to compute number of rules
- server = HTTPServer(log_level=logging.ERROR + 10)
- thing = OceanOpticsSpectrometer(id="test", log_level=logging.ERROR + 10)
- old_number_of_rules = len(server.app.wildcard_router.rules) + len(server.router._pending_rules)
- # append route with /custom to denote its a custom route
- server.add_property("/max-intensity/custom", OceanOpticsSpectrometer.max_intensity)
- server.add_action("/connect/custom", OceanOpticsSpectrometer.connect)
- server.add_event("/intensity/event/custom", OceanOpticsSpectrometer.intensity_measurement_event)
- server.add_things(thing)
- self.assertIn(f"/{thing.id}/max-intensity/custom", server.router)
- self.assertIn(f"/{thing.id}/connect/custom", server.router)
- self.assertIn(f"/{thing.id}/intensity/event/custom", server.router)
- # check if the affordance was not added twice using the default paths while add_thing was called
- self.assertNotIn(
- f"/{pep8_to_dashed_name(OceanOpticsSpectrometer.max_intensity.name)}",
- server.router,
- )
- self.assertNotIn(
- f"/{pep8_to_dashed_name(OceanOpticsSpectrometer.connect.name)}",
- server.router,
- )
- self.assertNotIn(
- f"/{pep8_to_dashed_name(OceanOpticsSpectrometer.intensity_measurement_event.name)}",
- server.router,
- )
- # TODO - reinstate rule numbers as they ensure that all routes were added
- # self.assertTrue(
- # len(server.app.wildcard_router.rules) + len(server.router._pending_rules) - old_number_of_rules >=
- # len(thing.properties.remote_objects) + len(thing.actions) + len(thing.events)
- # )
- # also check that it does not create duplicate rules
-
- def notest_04_add_thing_over_zmq_server(self):
- """extension of previous two tests to complete adding a thing running over a zmq server"""
- server = HTTPServer(log_level=logging.ERROR + 10)
- old_number_of_rules = len(server.app.wildcard_router.rules) + len(server.router._pending_rules)
-
- thing_id = f"test-add-zmq-{uuid.uuid4().hex[0:8]}"
- thing = OceanOpticsSpectrometer(id=thing_id, log_level=logging.ERROR + 10)
- thing.run_with_zmq_server(ZMQ_TRANSPORTS.INPROC, forked=True)
-
- server.add_property("/max-intensity/custom", OceanOpticsSpectrometer.max_intensity)
- server.add_action("/connect/custom", OceanOpticsSpectrometer.connect)
- server.add_event(
- "/intensity/event/custom",
- OceanOpticsSpectrometer.intensity_measurement_event,
- )
- server.add_things(thing)
-
- # server.router.print_rules()
- # print(thing.properties.remote_objects.keys(), thing.actions.descriptors.keys(), thing.events.descriptors.keys())
- # self.assertTrue(
- # len(server.app.wildcard_router.rules) + len(server.router._pending_rules) - old_number_of_rules >=
- # len(thing.properties.remote_objects) + len(thing.actions) + len(thing.events)
- # )
-
- fake_request = SimpleNamespace(path=f"/{thing_id}/max-intensity/custom")
- self.assertTrue(
- any([rule.matcher.match(fake_request) is not None for rule in server.app.wildcard_router.rules])
- )
- fake_request = SimpleNamespace(path="/non-existing-path-that-i-know-will-not-match")
- self.assertFalse(
- any([rule.matcher.match(fake_request) is not None for rule in server.app.wildcard_router.rules])
- )
- fake_request = SimpleNamespace(path=f"/{thing_id}/connect/custom")
- self.assertTrue(
- any([rule.matcher.match(fake_request) is not None for rule in server.app.wildcard_router.rules])
- )
- fake_request = SimpleNamespace(path=f"/{thing_id}/intensity/event/custom")
- self.assertTrue(
- any([rule.matcher.match(fake_request) is not None for rule in server.app.wildcard_router.rules])
- )
-
- while not thing.rpc_server:
- time.sleep(0.1) # wait for rpc server to be ready
- thing.rpc_server.stop()
-
- def test_05_handlers(self):
- """Test request info and payload decoding in RPC handlers along with content type handling"""
- latest_request_info = None # type: "LatestRequestInfo"
-
- @dataclass
- class LatestRequestInfo:
- server_execution_context: ServerExecutionContext | dict[str, Any]
- thing_execution_context: ThingExecutionContext | dict[str, Any]
- payload: SerializableData
- preserialized_payload: PreserializedData
-
- class TestableRPCHandler(RPCHandler):
- def update_latest_request_info(self) -> None:
- nonlocal latest_request_info
- server_execution_context, thing_execution_context, _, _ = self.get_execution_parameters()
- payload, preserialized_payload = self.get_request_payload()
- latest_request_info = LatestRequestInfo(
- server_execution_context=server_execution_context,
- thing_execution_context=thing_execution_context,
- payload=payload,
- preserialized_payload=preserialized_payload,
- )
-
- async def get(self):
- self.update_latest_request_info()
- self.set_status(200)
- self.finish()
-
- async def put(self):
- self.update_latest_request_info()
- self.set_status(200)
- self.finish()
-
- async def post(self):
- # for exit to go through
- await self.handle_through_thing("invokeaction")
-
- global_config.ALLOW_PICKLE = True # allow pickle serializer for testing
- thing_id = f"test-request-info-{uuid.uuid4().hex[0:8]}"
- port = 60002
-
- thing = OceanOpticsSpectrometer(id=thing_id, log_level=logging.ERROR + 10)
- thing.run_with_http_server(
- port=port,
- forked=True,
- property_handler=TestableRPCHandler,
- action_handler=TestableRPCHandler,
- )
- self.wait_until_server_ready(port=port)
- session = requests.session()
- for serializer in [JSONSerializer(), MsgpackSerializer(), PickleSerializer()]:
- serializer: BaseSerializer
- for method, path, body in [
- # server and thing execution context tests
- ("get", f"/{thing_id}/integration-time", None),
- ("get", f"/{thing_id}/integration-time?fetchExecutionLogs=true", None),
- (
- "get",
- f"/{thing_id}/integration-time?fetchExecutionLogs=true&oneway=true",
- None,
- ),
- (
- "get",
- f"/{thing_id}/integration-time?oneway=true&invokationTimeout=100",
- None,
- ),
- (
- "get",
- f"/{thing_id}/integration-time?invokationTimeout=100&executionTimeout=120&fetchExecutionLogs=true",
- None,
- ),
- # test payloads for JSON content type
- ("put", f"/{thing_id}/integration-time", 1200),
- (
- "put",
- f"/{thing_id}/integration-time?fetchExecutionLogs=true",
- {"a": 1, "b": 2},
- ),
- (
- "put",
- f"/{thing_id}/integration-time?fetchExecutionLogs=true&oneway=true",
- [1, 2, 3],
- ),
- (
- "put",
- f"/{thing_id}/integration-time?oneway=true&invokationTimeout=100",
- "abcd",
- ),
- (
- "put",
- f"/{thing_id}/integration-time?invokationTimeout=100&executionTimeout=120&fetchExecutionLogs=true",
- True,
- ),
- # test payloads for other content types
- ]:
- response = session.request(
- method=method,
- url=f"http://127.0.0.1:{port}{path}",
- data=serializer.dumps(body) if body is not None else None,
- headers={"Content-Type": serializer.content_type},
- )
- self.assertTrue(response.status_code in [200, 201, 202, 204])
- assert isinstance(latest_request_info, LatestRequestInfo)
- # test ThingExecutionContext
- self.assertTrue(
- isinstance(
- latest_request_info.thing_execution_context,
- ThingExecutionContext,
- )
- )
- self.assertTrue(
- ("fetchExecutionLogs" in path and latest_request_info.thing_execution_context.fetchExecutionLogs)
- or not latest_request_info.thing_execution_context.fetchExecutionLogs
- )
- # test ServerExecutionContext
- self.assertTrue(
- isinstance(
- latest_request_info.server_execution_context,
- ServerExecutionContext,
- )
- )
- self.assertTrue(
- ("oneway" in path and latest_request_info.server_execution_context.oneway)
- or not latest_request_info.server_execution_context.oneway
- )
- self.assertTrue(
- (
- "invokationTimeout" in path
- and latest_request_info.server_execution_context.invokationTimeout == 100
- )
- or
- # assume that in all tests where invokation timeout is specified, it will be 100
- latest_request_info.server_execution_context.invokationTimeout
- == default_server_execution_context.invokationTimeout
- )
- self.assertTrue(
- (
- "executionTimeout" in path
- and latest_request_info.server_execution_context.executionTimeout == 120
- )
- or
- # assume that in all tests where execution timeout is specified, it will be 120
- latest_request_info.server_execution_context.executionTimeout
- == default_server_execution_context.executionTimeout
- )
- # test body
- self.assertTrue(latest_request_info.payload.deserialize() == body)
-
- self.stop_server(port=port, thing_ids=[thing_id])
-
- def _test_handlers_end_to_end(self, port: int, thing_id: str, **request_kwargs):
- """
- basic end-to-end test with the HTTP server using handlers.
- Auth & other features not included, only invokation of interaction affordances.
- """
- session = requests.Session()
- logging.getLogger("requests").setLevel(logging.CRITICAL)
- logging.getLogger("urllib3").setLevel(logging.CRITICAL)
- # test end to end
- for method, path, body in self.generate_endpoints_for_thing(OceanOpticsSpectrometer, thing_id):
- # request will go through the Thing object
- response = session.request(
- method=method,
- url=f"http://127.0.0.1:{port}{path}",
- data=JSONSerializer().dumps(body) if body is not None and method != "get" else None,
- **request_kwargs,
- )
- self.assertTrue(response.status_code in [200, 201, 202, 204])
- # check if the response body is as expected
- if body and method != "put":
- self.assertTrue(response.json() == body)
- # check headers
- self.assertIn("Access-Control-Allow-Origin", response.headers)
- self.assertIn("Access-Control-Allow-Credentials", response.headers)
- self.assertIn("Content-Type", response.headers)
-
- # test unsupported HTTP methods
- for method, path, body in self.generate_endpoints_for_thing(OceanOpticsSpectrometer, thing_id):
- response = session.request(
- method="post"
- if method in ["get", "put"]
- else random.choice(["put", "delete"])
- if method == "post"
- else method,
- # get and put become post and post becomes put
- # i.e swap the default HTTP method with an unsupported one to generate 405
- url=f"http://127.0.0.1:{port}{path}",
- data=JSONSerializer().dumps(body) if body is not None and method != "get" else None,
- **request_kwargs,
- )
- self.assertTrue(response.status_code == 405)
-
- # check options for supported HTTP methods
- for method, path, body in self.generate_endpoints_for_thing(OceanOpticsSpectrometer, thing_id):
- response = session.options(f"http://127.0.0.1:{port}{path}", **request_kwargs)
- self.assertTrue(response.status_code in [200, 201, 202, 204])
- self.assertIn("Access-Control-Allow-Origin", response.headers)
- self.assertIn("Access-Control-Allow-Credentials", response.headers)
- self.assertIn("Access-Control-Allow-Headers", response.headers)
- self.assertIn("Access-Control-Allow-Methods", response.headers)
- allow_methods = response.headers.get("Access-Control-Allow-Methods", [])
- self.assertTrue(
- method.upper() in allow_methods,
- f"Method {method} not allowed in {allow_methods}",
- )
-
- def _test_invalid_auth_end_to_end(self, port: int, thing_id: str, wrong_auth_headers: list[str] = None):
- # check wrong credentials
- session = requests.Session()
- for wrong_auth in wrong_auth_headers:
- for method, path, body in self.generate_endpoints_for_thing(OceanOpticsSpectrometer, thing_id):
- response = session.request(
- method=method,
- url=f"http://127.0.0.1:{port}{path}",
- data=JSONSerializer().dumps(body) if body is not None and method != "get" else None,
- headers=wrong_auth,
- )
- self.assertTrue(response.status_code == 401)
-
- def _test_authenticated_end_to_end(
- self,
- port: int,
- security_scheme: SecurityScheme,
- auth_headers: dict[str, str] = None,
- wrong_auth_headers: dict[str, str] = None,
- ):
- """Test end-to-end with authentication"""
- thing_id = f"test-sec-{uuid.uuid4().hex[0:8]}"
- thing = OceanOpticsSpectrometer(id=thing_id, serial_number="simulation", log_level=logging.ERROR + 10)
- thing.run_with_http_server(
- forked=True,
- port=port,
- config={"cors": True},
- security_schemes=[security_scheme],
- )
- self.wait_until_server_ready(port=port)
- self._test_handlers_end_to_end(port=port, thing_id=thing_id, headers=auth_headers)
- self._test_invalid_auth_end_to_end(port=port, thing_id=thing_id, wrong_auth_headers=wrong_auth_headers)
- # reinstate correct credentials to stop
- self.stop_server(port=port, thing_ids=[thing_id], headers=auth_headers)
-
- def test_06_basic_end_to_end(self):
- thing_id = f"test-sec-{uuid.uuid4().hex[0:8]}"
- port = 60004
- thing = OceanOpticsSpectrometer(id=thing_id, serial_number="simulation", log_level=logging.ERROR + 10)
- thing.run_with_http_server(forked=True, port=port, config={"cors": True})
- self.wait_until_server_ready(port=port)
-
- self._test_handlers_end_to_end(port=port, thing_id=thing_id, headers={"Content-Type": "application/json"})
- self.stop_server(port, thing_ids=[thing_id])
-
- def test_07_bcrypt_basic_security_end_to_end(self):
- security_scheme = BcryptBasicSecurity(username="someuser", password="somepassword")
- port = 60005
- self._test_authenticated_end_to_end(
- port=port,
- security_scheme=security_scheme,
- auth_headers={
- "Content-type": "application/json",
- "Authorization": f"Basic {base64.b64encode(b'someuser:somepassword').decode('utf-8')}",
- },
- wrong_auth_headers=[
- {
- "Content-type": "application/json",
- "Authorization": f"Basic {base64.b64encode(b'wronguser:wrongpassword').decode('utf-8')}",
- },
- {
- "Content-type": "application/json",
- "Authorization": f"Basic {base64.b64encode(b'someuser:wrongpassword').decode('utf-8')}",
- },
- {
- "Content-type": "application/json",
- "Authorization": f"Basic {base64.b64encode(b'wronguser:somepassword').decode('utf-8')}",
- },
- ],
- )
-
- def test_08_argon2_basic_security_end_to_end(self):
- security_scheme = Argon2BasicSecurity(username="someuserargon2", password="somepasswordargon2")
- port = 60006
- self._test_authenticated_end_to_end(
- port=port,
- security_scheme=security_scheme,
- auth_headers={
- "Content-type": "application/json",
- "Authorization": f"Basic {base64.b64encode(b'someuserargon2:somepasswordargon2').decode('utf-8')}",
- },
- wrong_auth_headers=[
- {
- "Content-type": "application/json",
- "Authorization": f"Basic {base64.b64encode(b'wronguserargon2:wrongpasswordargon2').decode('utf-8')}",
- },
- {
- "Content-type": "application/json",
- "Authorization": f"Basic {base64.b64encode(b'someuserargon2:wrongpasswordargon2').decode('utf-8')}",
- },
- {
- "Content-type": "application/json",
- "Authorization": f"Basic {base64.b64encode(b'wronguserargon2:somepasswordargon2').decode('utf-8')}",
- },
- ],
- )
-
- def _test_sse_end_to_end(
- self,
- port: int,
- security_scheme: SecurityScheme = None,
- headers: dict[str, str] = None,
- ):
- """
- Test end-to-end with Server-Sent Events (SSE).
- """
- thing_id = f"test-sse-{uuid.uuid4().hex[0:8]}"
- thing = OceanOpticsSpectrometer(id=thing_id, serial_number="simulation", log_level=logging.ERROR + 10)
- thing.run_with_http_server(
- forked=True,
- port=port,
- config={"cors": True},
- security_schemes=[security_scheme] if security_scheme else None,
- )
- self.wait_until_server_ready(port=port)
-
- session = requests.Session()
- response = session.post(f"http://127.0.0.1:{port}/{thing_id}/start-acquisition", headers=headers)
- self.assertEqual(response.status_code, 200)
- sse_gen = self.sse_stream(
- f"http://127.0.0.1:{port}/{thing_id}/intensity-measurement-event",
- headers=headers,
- )
- for i in range(5):
- evt = next(sse_gen)
- self.assertTrue("exception" not in evt)
- response = session.post(f"http://127.0.0.1:{port}/{thing_id}/stop-acquisition", headers=headers)
- self.stop_server(port=port, thing_ids=[thing_id], headers=headers)
-
- def test_09_sse(self):
- """Test Server-Sent Events (SSE)"""
- for security_scheme, port in [
- (None, 60007),
- (BcryptBasicSecurity(username="someuser", password="somepassword"), 60008),
- ]:
- # test SSE with and without security
- if security_scheme:
- headers = {
- "Content-type": "application/json",
- "Authorization": f"Basic {base64.b64encode(b'someuser:somepassword').decode('utf-8')}",
- }
- else:
- headers = dict()
- self._test_sse_end_to_end(port=port, security_scheme=security_scheme, headers=headers)
-
- def test_10_forms_generation(self):
- thing_id = f"test-forms-{uuid.uuid4().hex[0:8]}"
- port = 60009
- thing = OceanOpticsSpectrometer(id=thing_id, serial_number="simulation", log_level=logging.ERROR + 10)
- thing.run_with_http_server(forked=True, port=port, config={"cors": True})
- self.wait_until_server_ready(port=port)
-
- session = requests.Session()
- response = session.get(f"http://127.0.0.1:{port}/{thing_id}/resources/wot-td")
- self.assertEqual(response.status_code, 200)
- td = response.json()
- self.assertIn("properties", td)
- self.assertIn("actions", td)
- self.assertIn("events", td)
- self.assertTrue(len(td["properties"]) >= 0)
- self.assertTrue(len(td["actions"]) >= 0)
- self.assertTrue(len(td["events"]) >= 0)
- for prop in list(td["properties"].values()) + list(td["actions"].values()) + list(td["events"].values()):
- self.assertIn("forms", prop)
- self.assertTrue(len(prop["forms"]) > 0)
- for form in prop["forms"]:
- self.assertIn("href", form)
- self.assertIn("htv:methodName", form)
- self.assertIn("contentType", form)
- self.assertIn("op", form)
- self.stop_server(port=port, thing_ids=[thing_id])
-
- def test_11_object_proxy_basic(self):
- thing_id = f"test-obj-proxy-{uuid.uuid4().hex[0:8]}"
- port = 60010
- thing = OceanOpticsSpectrometer(id=thing_id, serial_number="simulation", log_level=logging.ERROR + 10)
- thing.run_with_http_server(forked=True, port=port, config={"cors": True})
- self.wait_until_server_ready(port=port)
-
- object_proxy = ClientFactory.http(url=f"http://127.0.0.1:{port}/{thing_id}/resources/wot-td")
- self.assertIsInstance(object_proxy, ObjectProxy)
- self.assertEqual(object_proxy.test_echo("Hello World!"), "Hello World!")
- self.assertEqual(
- asyncio.run(object_proxy.async_invoke_action("test_echo", "Hello World!")),
- "Hello World!",
- )
- self.assertEqual(object_proxy.read_property("max_intensity"), 16384)
- self.assertEqual(object_proxy.write_property("integration_time", 1200), None)
- self.assertEqual(object_proxy.read_property("integration_time"), 1200)
- self.stop_server(port=port, thing_ids=[thing_id])
-
- def notest_12_object_proxy_with_basic_auth(self):
- security_scheme = BcryptBasicSecurity(username="cliuser", password="clipass")
- port = 60013
- thing_id = f"test-basic-proxy-{uuid.uuid4().hex[0:8]}"
- thing = OceanOpticsSpectrometer(id=thing_id, serial_number="simulation", log_level=logging.ERROR + 10)
- thing.run_with_http_server(
- forked=True,
- port=port,
- config={"cors": True},
- security_schemes=[security_scheme],
- )
- self.wait_until_server_ready(port=port)
-
- object_proxy = ClientFactory.http(
- url=f"http://127.0.0.1:{port}/{thing_id}/resources/wot-td",
- username="cliuser",
- password="clipass",
- )
- self.assertEqual(object_proxy.read_property("max_intensity"), 16384)
- headers = {}
- token = base64.b64encode("cliuser:clipass".encode("utf-8")).decode("ascii")
- headers["Authorization"] = f"Basic {token}"
- self.stop_server(port=port, thing_ids=[thing_id], headers=headers)
-
- @classmethod
- def stop_server(cls, port, thing_ids: list[str] = [], **request_kwargs):
- session = requests.Session()
- endpoints = [("post", f"/{thing_id}/exit", None) for thing_id in thing_ids]
- endpoints += [("post", "/stop", None)]
- for method, path, body in endpoints:
- response = session.request(method=method, url=f"http://127.0.0.1:{port}{path}", **request_kwargs)
- if response.status_code not in [200, 201, 202, 204]:
- logging.warning(f"Failed to stop server or thing at {path} with status {response.status_code}")
-
- @classmethod
- def wait_until_server_ready(cls, port, tries: int = 10):
- session = requests.Session()
- for i in range(tries):
- try:
- response = session.get(f"http://127.0.0.1:{port}/liveness")
- if response.status_code in [200, 201, 202, 204]:
- response = session.get(f"http://127.0.0.1:{port}/readiness")
- if response.status_code in [200, 201, 202, 204]:
- time.sleep(2)
- return
- except Exception:
- pass
- time.sleep(1)
- raise TimeoutError(f"Server on port {port} not ready after {tries} tries")
-
- @classmethod
- def sse_stream(cls, url, chunk_size=2048, **kwargs):
- """Generator yielding dicts with the fields of each SSE event"""
- with requests.get(url, stream=True, **kwargs) as resp:
- resp.raise_for_status()
- buffer = ""
- for chunk in resp.iter_content(chunk_size=chunk_size, decode_unicode=True):
- buffer += chunk
- # split events on the SSE separator: two newlines
- while "\n\n" in buffer:
- raw_event, buffer = buffer.split("\n\n", 1)
- event = {}
- for line in raw_event.splitlines():
- # skip comments
- if not line or line.startswith(":"):
- continue
- if ":" in line:
- field, value = line.split(":", 1)
- event.setdefault(field, "")
- # strip leading space after colon
- event[field] += value.lstrip()
- yield event
-
- @classmethod
- def generate_endpoints_for_thing(cls, class_: ThingMeta, thing_id: str) -> list[tuple[str, str, Any]]:
- if class_ == OceanOpticsSpectrometer:
- return [
- # read Property
- ("get", f"/{thing_id}/max-intensity", 16384),
- ("get", f"/{thing_id}/serial-number", "simulation"),
- # write Property
- ("put", f"/{thing_id}/integration-time", 1200),
- ("get", f"/{thing_id}/integration-time", 1200),
- # invoke action
- ("post", f"/{thing_id}/disconnect", None),
- ("post", f"/{thing_id}/connect", None),
- ]
- raise NotImplementedError(f"Endpoints for {class_.__name__} not implemented yet")
-
-
-class TestHTTPObjectProxy(TestCase):
- # later create a TestObjtectProxy class that will test ObjectProxy but just overload the setUp and tearDown methods
- # with the different protocol
-
- @classmethod
- def setUpClass(cls):
- super().setUpClass()
- cls.thing_id = f"test-obj-proxy-{uuid.uuid4().hex[0:8]}"
- cls.port = 60011
- cls.thing = OceanOpticsSpectrometer(id=cls.thing_id, serial_number="simulation", log_level=logging.ERROR + 10)
- cls.thing.run_with_http_server(forked=True, port=cls.port, config={"cors": True})
- TestHTTPServer.wait_until_server_ready(port=cls.port)
-
- cls.object_proxy = ClientFactory.http(url=f"http://127.0.0.1:{cls.port}/{cls.thing_id}/resources/wot-td")
-
- @classmethod
- def tearDownClass(cls):
- # stop the thing and server
- TestHTTPServer.stop_server(cls.port, thing_ids=[cls.thing.id])
- cls.object_proxy = None
- super().tearDownClass()
-
- def test_01_invoke_action(self):
- """Test basic functionality of ObjectProxy with HTTP server."""
- self.assertIsInstance(self.object_proxy, ObjectProxy)
- # Test invoke_action method with reply
- self.assertEqual(self.object_proxy.invoke_action("test_echo", "Hello World!"), "Hello World!")
- # Test invoke_action with dot notation
- self.assertEqual(self.object_proxy.test_echo(fake.chrome()), fake.last)
- self.assertEqual(self.object_proxy.test_echo(fake.sha256()), fake.last)
- self.assertEqual(self.object_proxy.test_echo(fake.address()), fake.last)
- # Test invoke_action with no reply
- self.assertEqual(
- self.object_proxy.invoke_action("test_echo", fake.random_number(), oneway=True),
- None,
- )
- # # Test invoke_action in non blocking mode
- noblock_payload = fake.pylist(20, value_types=[int, float, str, bool])
- noblock_msg_id = self.object_proxy.invoke_action("test_echo", noblock_payload, noblock=True)
- self.assertIsInstance(noblock_msg_id, str)
- self.assertEqual(
- self.object_proxy.invoke_action("test_echo", fake.pylist(20, value_types=[int, float, str, bool])),
- fake.last,
- )
- self.assertEqual(
- self.object_proxy.invoke_action("test_echo", fake.pylist(10, value_types=[int, float, str, bool])),
- fake.last,
- )
- self.assertEqual(self.object_proxy.read_reply(noblock_msg_id), noblock_payload)
-
- def test_02_rwd_properties(self):
- # test read and write properties
- self.assertEqual(self.object_proxy.read_property("max_intensity"), 16384)
- self.assertEqual(self.object_proxy.write_property("integration_time", 1200), None)
- self.assertEqual(self.object_proxy.read_property("integration_time"), 1200)
- # test read and write properties with dot notation
- self.assertEqual(self.object_proxy.max_intensity, 16384)
- self.assertEqual(self.object_proxy.integration_time, 1200)
- self.object_proxy.integration_time = 1000
- self.assertEqual(self.object_proxy.integration_time, 1000)
- # test oneway write property
- self.assertEqual(self.object_proxy.write_property("integration_time", 800, oneway=True), None)
- self.assertEqual(self.object_proxy.read_property("integration_time"), 800)
- # test noblock read property
- noblock_msg_id = self.object_proxy.read_property("integration_time", noblock=True)
- self.assertIsInstance(noblock_msg_id, str)
- self.assertEqual(self.object_proxy.read_property("max_intensity"), 16384)
- self.assertEqual(self.object_proxy.write_property("integration_time", 1200), None)
- self.assertEqual(self.object_proxy.read_reply(noblock_msg_id), 800)
-
- def notest_03_rw_multiple_properties(self):
- """Test reading and writing multiple properties at once."""
- # test read multiple properties
- properties = self.object_proxy.read_multiple_properties(["max_intensity", "integration_time"])
- self.assertEqual(properties["max_intensity"], 16384)
- self.assertEqual(properties["integration_time"], 800)
-
- # test write multiple properties
- new_values = {"integration_time": 1200, "max_intensity": 20000}
- self.object_proxy.write_multiple_properties(new_values)
- properties = self.object_proxy.read_multiple_properties(["max_intensity", "integration_time"])
- self.assertEqual(properties["max_intensity"], 20000)
- self.assertEqual(properties["integration_time"], 1200)
-
- def test_04_subscribe_event(self):
- """Test subscribing to an event and receiving updates."""
- event_name = "intensity_measurement_event"
-
- def on_event(data: SSE):
- nonlocal self
- self.assertTrue(isinstance(data.data, dict) and "value" in data.data and "timestamp" in data.data)
-
- self.object_proxy.subscribe_event(event_name, on_event)
- self.object_proxy.start_acquisition()
- time.sleep(2) # wait for some events to be generated
- self.object_proxy.stop_acquisition()
- # check if events are kept alive
- time.sleep(20)
- self.object_proxy.start_acquisition()
- time.sleep(2) # wait for some events to be generated
- self.object_proxy.stop_acquisition()
- self.object_proxy.unsubscribe_event(event_name)
-
-
-class TestHTTPEndToEnd(TestRPCEndToEnd):
- @classmethod
- def setUpClass(cls):
- cls.http_port = 60012
- super().setUpClass()
- print("Test HTTP Object Proxy End to End")
-
- @classmethod
- def setUpThing(cls):
- """Set up the thing for the http object proxy client"""
- cls.thing = TestThing(id=cls.thing_id, log_level=logging.ERROR + 10)
- cls.thing.run_with_http_server(forked=True, port=cls.http_port, config={"cors": True})
- TestHTTPServer.wait_until_server_ready(port=cls.http_port)
-
- cls.thing_model = cls.thing.get_thing_model(ignore_errors=True).json()
-
- @classmethod
- def tearDownClass(cls):
- """Test the stop of the http object proxy client"""
- TestHTTPServer.stop_server(port=cls.http_port, thing_ids=[cls.thing_id])
- super().tearDownClass()
-
- @classmethod
- def get_client(cls):
- try:
- if cls._client is not None:
- return cls._client
- raise AttributeError()
- except AttributeError:
- cls._client = ClientFactory.http(
- url=f"http://127.0.0.1:{cls.http_port}/{cls.thing_id}/resources/wot-td", ignore_TD_errors=True
- )
- return cls._client
-
- def test_04_RW_multiple_properties(self):
- pass
-
-
-def load_tests(loader, tests, pattern):
- suite = unittest.TestSuite()
- suite.addTest(unittest.TestLoader().loadTestsFromTestCase(TestHTTPServer))
- suite.addTest(unittest.TestLoader().loadTestsFromTestCase(TestHTTPObjectProxy))
- suite.addTest(unittest.TestLoader().loadTestsFromTestCase(TestHTTPEndToEnd))
- return suite
-
-
-if __name__ == "__main__":
- runner = TestRunner()
- runner.run(load_tests(unittest.TestLoader(), None, None))
diff --git a/tests/pytests-new/conftest.py b/tests/pytests-new/conftest.py
deleted file mode 100644
index 9da1aed3..00000000
--- a/tests/pytests-new/conftest.py
+++ /dev/null
@@ -1,66 +0,0 @@
-"""
-Pytest configuration and shared fixtures for hololinked tests.
-"""
-import asyncio
-import pytest
-import zmq.asyncio
-from uuid import uuid4
-from faker import Faker
-
-from hololinked.config import global_config
-
-
-@pytest.fixture(scope="session")
-def event_loop():
- """Create an instance of the default event loop for the test session."""
- loop = asyncio.get_event_loop_policy().new_event_loop()
- yield loop
- loop.close()
-
-
-@pytest.fixture(scope="class")
-def zmq_context():
- """Setup ZMQ context for test classes."""
- global_config.ZMQ_CONTEXT = zmq.asyncio.Context()
- yield global_config.ZMQ_CONTEXT
- # Cleanup is handled by the context manager
-
-
-@pytest.fixture(scope="class")
-def test_ids():
- """Generate unique test IDs for each test class."""
- return {
- "server_id": f"test-server-{uuid4().hex[:8]}",
- "client_id": f"test-client-{uuid4().hex[:8]}",
- "thing_id": f"test-thing-{uuid4().hex[:8]}"
- }
-
-
-@pytest.fixture(scope="session")
-def fake():
- """Provide a Faker instance for generating test data."""
- return Faker()
-
-
-@pytest.fixture(autouse=True)
-def setup_test_environment(zmq_context):
- """Automatically setup test environment for each test."""
- # This fixture runs automatically for every test
- pass
-
-
-def pytest_configure(config):
- """Configure pytest with custom settings."""
- config.addinivalue_line(
- "markers", "order: mark test to run in a specific order"
- )
-
-
-def pytest_collection_modifyitems(config, items):
- """Modify test collection to add ordering markers."""
- # Add order markers based on test file names
- for item in items:
- if "test_01_" in item.nodeid:
- item.add_marker(pytest.mark.order(1))
- elif "test_00_" in item.nodeid:
- item.add_marker(pytest.mark.order(0))
diff --git a/tests/pytests-new/test_01_message_pytest.py b/tests/pytests-new/test_01_message_pytest.py
deleted file mode 100644
index 122a38d1..00000000
--- a/tests/pytests-new/test_01_message_pytest.py
+++ /dev/null
@@ -1,353 +0,0 @@
-"""
-Pytest tests for message validation and messaging contract.
-Converted from unittest to pytest format.
-"""
-
-import pytest
-from uuid import UUID, uuid4
-
-from hololinked.core.zmq.message import (
- EXIT,
- OPERATION,
- HANDSHAKE,
- PreserializedData,
- SerializableData,
- RequestHeader,
- EventHeader,
- RequestMessage,
-) # client to server
-from hololinked.core.zmq.message import (
- TIMEOUT,
- INVALID_MESSAGE,
- ERROR,
- REPLY,
- ERROR,
- ResponseMessage,
- ResponseHeader,
- EventMessage,
-) # server to client
-from hololinked.serializers.serializers import Serializers
-
-
-class MessageValidatorMixin:
- """A mixin class to validate request and response messages"""
-
- @pytest.fixture(autouse=True)
- def setup_message_validator(self, test_ids):
- """Setup message validator with test IDs."""
- self.server_id = test_ids["server_id"]
- self.client_id = test_ids["client_id"]
- self.thing_id = test_ids["thing_id"]
-
- def validate_request_message(self, request_message: RequestMessage) -> None:
- """call this method to validate request message"""
-
- # req. 1. check message ID is a UUID
- assert isinstance(request_message.id, UUID) or isinstance(UUID(request_message.id, version=4), UUID)
- # req. 2. generated byte array must confine to predefined length (which is readonly & fixed)
- assert len(request_message.byte_array) == request_message.length
- # req. 3. receiver which must be the server ID
- assert request_message.receiver_id == self.server_id
- # req. 4. sender_id is the client ID
- assert request_message.sender_id == self.client_id
- # req. 5. all indices of byte array are bytes
- for obj in request_message.byte_array:
- assert isinstance(obj, bytes)
- # req. 6. check that header is correct type (RequestHeader dataclass/struct)
- assert isinstance(request_message.header, RequestHeader)
- # req. 7 check that body is correct type (list of SerializableData and PreserializedData)
- assert isinstance(request_message.body, list)
- assert len(request_message.body) == 2
- assert isinstance(request_message.body[0], SerializableData)
- assert isinstance(request_message.body[1], PreserializedData)
-
- def validate_response_message(self, response_message: ResponseMessage) -> None:
- """call this method to validate response message"""
-
- # check message ID is a UUID
- assert isinstance(response_message.id, UUID) or isinstance(UUID(response_message.id, version=4), UUID)
- # check message length
- assert len(response_message.byte_array) == response_message.length
- # check receiver which must be the client
- assert response_message.receiver_id == self.client_id
- # sender_id is not set before sending message on the socket
- assert response_message.sender_id == self.server_id
- # check that all indices are bytes
- for obj in response_message.byte_array:
- assert isinstance(obj, bytes)
- # check that header is correct type
- assert isinstance(response_message.header, ResponseHeader)
- # check that body is correct type
- assert isinstance(response_message.body, list)
- assert len(response_message.body) == 2
- assert isinstance(response_message.body[0], SerializableData)
- assert isinstance(response_message.body[1], PreserializedData)
-
- def validate_event_message(self, event_message: EventMessage) -> None:
- """call this method to validate event message"""
-
- # check message ID is a UUID
- assert isinstance(event_message.id, UUID) or isinstance(UUID(event_message.id, version=4), UUID)
- # check message length
- assert len(event_message.byte_array) == event_message.length
- # no receiver id for event message, only event id
- assert isinstance(event_message.event_id, str)
- # sender_id is not set before sending message on the socket
- assert event_message.sender_id == self.server_id
- # check that all indices are bytes
- for obj in event_message.byte_array:
- assert isinstance(obj, bytes)
- # check that header is correct type
- assert isinstance(event_message.header, EventHeader)
- # check that body is correct type
- assert isinstance(event_message.body, list)
- assert len(event_message.body) == 2
- assert isinstance(event_message.body[0], SerializableData)
- assert isinstance(event_message.body[1], PreserializedData)
-
-
-@pytest.mark.order(1)
-class TestMessagingContract(MessageValidatorMixin):
- """Tests request and response messages"""
-
- def test_1_request_message(self):
- """test the request message"""
-
- # request messages types are OPERATION, HANDSHAKE & EXIT
- request_message = RequestMessage.craft_from_arguments(
- receiver_id=self.server_id,
- sender_id=self.client_id,
- thing_id=self.thing_id,
- objekt="some_prop",
- operation="readproperty",
- )
- self.validate_request_message(request_message)
- # check message type for the above craft_from_arguments method
- assert request_message.type == OPERATION
-
- request_message = RequestMessage.craft_with_message_type(
- receiver_id=self.server_id, sender_id=self.client_id, message_type=HANDSHAKE
- )
- self.validate_request_message(request_message)
- # check message type for the above craft_with_message_type method
- assert request_message.type == HANDSHAKE
-
- request_message = RequestMessage.craft_with_message_type(
- receiver_id=self.server_id, sender_id=self.client_id, message_type=EXIT
- )
- self.validate_request_message(request_message)
- # check message type for the above craft_with_message_type method
- assert request_message.type == EXIT
-
- def test_2_response_message(self):
- """test the response message"""
-
- # response messages types are HANDSHAKE, TIMEOUT, INVALID_MESSAGE, ERROR and REPLY
- response_message = ResponseMessage.craft_from_arguments(
- receiver_id=self.client_id,
- sender_id=self.server_id,
- message_type=HANDSHAKE,
- message_id=uuid4(),
- )
- self.validate_response_message(response_message)
- # check message type for the above craft_with_message_type method
- assert response_message.type == HANDSHAKE
-
- response_message = ResponseMessage.craft_from_arguments(
- receiver_id=self.client_id,
- sender_id=self.server_id,
- message_type=TIMEOUT,
- message_id=uuid4(),
- )
- self.validate_response_message(response_message)
- # check message type for the above craft_with_message_type method
- assert response_message.type == TIMEOUT
-
- response_message = ResponseMessage.craft_from_arguments(
- receiver_id=self.client_id,
- sender_id=self.server_id,
- message_type=INVALID_MESSAGE,
- message_id=uuid4(),
- )
- self.validate_response_message(response_message)
- # check message type for the above craft_with_message_type method
- assert response_message.type == INVALID_MESSAGE
-
- response_message = ResponseMessage.craft_from_arguments(
- receiver_id=self.client_id,
- sender_id=self.server_id,
- message_type=ERROR,
- message_id=uuid4(),
- payload=SerializableData(Exception("test")),
- )
- self.validate_response_message(response_message)
- assert response_message.type == ERROR
- assert isinstance(Serializers.json.loads(response_message._bytes[2]), dict)
-
- request_message = RequestMessage.craft_from_arguments(
- sender_id=self.client_id,
- receiver_id=self.server_id,
- thing_id=self.thing_id,
- objekt="some_prop",
- operation="readProperty",
- )
- request_message._sender_id = self.client_id # will be done by craft_from_self
- response_message = ResponseMessage.craft_reply_from_request(
- request_message=request_message,
- )
- self.validate_response_message(response_message)
- assert response_message.type == REPLY
- assert Serializers.json.loads(response_message._bytes[3]) is None # INDEX_BODY = 3
- assert request_message.id == response_message.id
-
- def test_3_event_message(self):
- """test the event message"""
- event_message = EventMessage.craft_from_arguments(
- event_id="test-event",
- sender_id=self.server_id,
- payload=SerializableData("test"),
- preserialized_payload=PreserializedData(b"test"),
- )
- self.validate_event_message(event_message)
-
-
-# def validate_request_message(request_message, server_id, client_id):
-# """Validate request message contract."""
-# assert isinstance(request_message.id, UUID) or isinstance(UUID(request_message.id, version=4), UUID)
-# assert len(request_message.byte_array) == request_message.length
-# assert request_message.receiver_id == server_id
-# assert request_message.sender_id == client_id
-# for obj in request_message.byte_array:
-# assert isinstance(obj, bytes)
-# assert isinstance(request_message.header, RequestHeader)
-# assert isinstance(request_message.body, list)
-# assert len(request_message.body) == 2
-# assert isinstance(request_message.body[0], SerializableData)
-# assert isinstance(request_message.body[1], PreserializedData)
-
-# def validate_response_message(response_message, server_id, client_id):
-# """Validate response message contract."""
-# assert isinstance(response_message.id, UUID) or isinstance(UUID(response_message.id, version=4), UUID)
-# assert len(response_message.byte_array) == response_message.length
-# assert response_message.receiver_id == client_id
-# assert response_message.sender_id == server_id
-# for obj in response_message.byte_array:
-# assert isinstance(obj, bytes)
-# assert isinstance(response_message.header, ResponseHeader)
-# assert isinstance(response_message.body, list)
-# assert len(response_message.body) == 2
-# assert isinstance(response_message.body[0], SerializableData)
-# assert isinstance(response_message.body[1], PreserializedData)
-
-# def validate_event_message(event_message, server_id):
-# """Validate event message contract."""
-# assert isinstance(event_message.id, UUID) or isinstance(UUID(event_message.id, version=4), UUID)
-# assert len(event_message.byte_array) == event_message.length
-# assert isinstance(event_message.event_id, str)
-# assert event_message.sender_id == server_id
-# for obj in event_message.byte_array:
-# assert isinstance(obj, bytes)
-# assert isinstance(event_message.header, EventHeader)
-# assert isinstance(event_message.body, list)
-# assert len(event_message.body) == 2
-# assert isinstance(event_message.body[0], SerializableData)
-# assert isinstance(event_message.body[1], PreserializedData)
-
-# @pytest.fixture(scope="module")
-# def test_ids():
-# return {
-# "server_id": "server-uuid",
-# "client_id": "client-uuid",
-# "thing_id": "thing-uuid",
-# }
-
-# @pytest.mark.order(1)
-# class TestMessagingContract:
-# """Tests request and response messages"""
-
-# def test_1_request_message(self, test_ids):
-# request_message = RequestMessage.craft_from_arguments(
-# receiver_id=test_ids["server_id"],
-# sender_id=test_ids["client_id"],
-# thing_id=test_ids["thing_id"],
-# objekt="some_prop",
-# operation="readproperty",
-# )
-# validate_request_message(request_message, test_ids["server_id"], test_ids["client_id"])
-# assert request_message.type == OPERATION
-
-# request_message = RequestMessage.craft_with_message_type(
-# receiver_id=test_ids["server_id"], sender_id=test_ids["client_id"], message_type=HANDSHAKE
-# )
-# validate_request_message(request_message, test_ids["server_id"], test_ids["client_id"])
-# assert request_message.type == HANDSHAKE
-
-# request_message = RequestMessage.craft_with_message_type(
-# receiver_id=test_ids["server_id"], sender_id=test_ids["client_id"], message_type=EXIT
-# )
-# validate_request_message(request_message, test_ids["server_id"], test_ids["client_id"])
-# assert request_message.type == EXIT
-
-# def test_2_response_message(self, test_ids):
-# response_message = ResponseMessage.craft_from_arguments(
-# receiver_id=test_ids["client_id"],
-# sender_id=test_ids["server_id"],
-# message_type=HANDSHAKE,
-# message_id=uuid4(),
-# )
-# validate_response_message(response_message, test_ids["server_id"], test_ids["client_id"])
-# assert response_message.type == HANDSHAKE
-
-# response_message = ResponseMessage.craft_from_arguments(
-# receiver_id=test_ids["client_id"],
-# sender_id=test_ids["server_id"],
-# message_type=TIMEOUT,
-# message_id=uuid4(),
-# )
-# validate_response_message(response_message, test_ids["server_id"], test_ids["client_id"])
-# assert response_message.type == TIMEOUT
-
-# response_message = ResponseMessage.craft_from_arguments(
-# receiver_id=test_ids["client_id"],
-# sender_id=test_ids["server_id"],
-# message_type=INVALID_MESSAGE,
-# message_id=uuid4(),
-# )
-# validate_response_message(response_message, test_ids["server_id"], test_ids["client_id"])
-# assert response_message.type == INVALID_MESSAGE
-
-# response_message = ResponseMessage.craft_from_arguments(
-# receiver_id=test_ids["client_id"],
-# sender_id=test_ids["server_id"],
-# message_type=ERROR,
-# message_id=uuid4(),
-# payload=SerializableData(Exception("test")),
-# )
-# validate_response_message(response_message, test_ids["server_id"], test_ids["client_id"])
-# assert response_message.type == ERROR
-# assert isinstance(Serializers.json.loads(response_message._bytes[2]), dict)
-
-# request_message = RequestMessage.craft_from_arguments(
-# sender_id=test_ids["client_id"],
-# receiver_id=test_ids["server_id"],
-# thing_id=test_ids["thing_id"],
-# objekt="some_prop",
-# operation="readProperty",
-# )
-# request_message._sender_id = test_ids["client_id"]
-# response_message = ResponseMessage.craft_reply_from_request(
-# request_message=request_message,
-# )
-# validate_response_message(response_message, test_ids["server_id"], test_ids["client_id"])
-# assert response_message.type == REPLY
-# assert Serializers.json.loads(response_message._bytes[3]) is None
-# assert request_message.id == response_message.id
-
-# def test_3_event_message(self, test_ids):
-# event_message = EventMessage.craft_from_arguments(
-# event_id="test-event",
-# sender_id=test_ids["server_id"],
-# payload=SerializableData("test"),
-# preserialized_payload=PreserializedData(b"test"),
-# )
-# validate_event_message(event_message, test_ids["server_id"])
diff --git a/tests/test_00_utils.py b/tests/test_00_utils.py
index 4c99626c..2471a931 100644
--- a/tests/test_00_utils.py
+++ b/tests/test_00_utils.py
@@ -1,487 +1,361 @@
-import unittest
-import typing
+from typing import Any, Dict, List, Tuple
+
+import pytest
+
from pydantic import BaseModel, ValidationError
-from hololinked.utils import (
- get_input_model_from_signature,
- issubklass,
- pydantic_validate_args_kwargs,
- json_schema_merge_args_to_kwargs,
+from hololinked.utils import get_input_model_from_signature, issubklass, pydantic_validate_args_kwargs
+
+
+def func_without_args():
+ return 1
+
+
+def func_with_annotations(a: int, b: int) -> int:
+ return a + b
+
+
+def func_with_missing_annotations(a: int, b):
+ return a + b
+
+
+def func_with_no_annotations(a, b):
+ return a + b
+
+
+def func_with_kwargs(a: int, b: int, **kwargs):
+ return a + b
+
+
+def func_with_annotated_kwargs(a: int, b: int, **kwargs: dict[str, int]):
+ return a + b
+
+
+def func_with_args(*args):
+ return sum(args)
+
+
+def func_with_annotated_args(*args: list[int]):
+ return sum(args)
+
+
+def func_with_args_and_kwargs(*args, **kwargs):
+ return sum(args) + sum(kwargs.values())
+
+
+def func_with_annotated_args_and_kwargs(*args: list[int], **kwargs: dict[str, int]):
+ return sum(args) + sum(kwargs.values())
+
+
+def test_func_without_args_model_none():
+ model = get_input_model_from_signature(func_without_args)
+ assert model is None
+
+
+def test_01_model_func_with_annotations():
+ model = get_input_model_from_signature(func_with_annotations)
+ assert issubklass(model, BaseModel)
+ assert model.model_fields["a"].annotation is int
+ assert model.model_fields["b"].annotation is int
+ assert len(model.model_fields) == 2
+ assert model.model_config["extra"] == "forbid"
+
+
+@pytest.mark.parametrize(
+ "args,kwargs,raises,exmsg",
+ [
+ (None, {"a": 1, "b": 2}, None, None),
+ (None, {"a": 1, "b": "2"}, ValidationError, None),
+ (None, {"a": 1}, ValidationError, None),
+ (None, {"a": 1, "b": 2, "c": 3}, ValueError, "Unexpected keyword arguments"),
+ ((1, 2), None, None, None),
+ ((1, "2"), None, ValidationError, None),
+ ((1, 2, 3), None, ValueError, "Too many positional arguments"),
+ ((1,), None, ValidationError, None),
+ ((1,), {"b": 2}, None, None),
+ ((1,), {"a": 2}, ValueError, "Multiple values for argument"),
+ ((1, 2), {"c": 3}, ValueError, "Unexpected keyword arguments"),
+ (("1", 2), {"c": 3}, ValueError, "Unexpected keyword arguments"),
+ ],
+)
+def test_01_validation_func_with_annotations(args, kwargs, raises, exmsg):
+ model = get_input_model_from_signature(func_with_annotations)
+ if raises:
+ with pytest.raises(raises) as ex:
+ pydantic_validate_args_kwargs(model, args=args if args else (), kwargs=kwargs if kwargs else {})
+ if exmsg:
+ assert str(ex.value).startswith(exmsg)
+ else:
+ pydantic_validate_args_kwargs(model, args=args if args else (), kwargs=kwargs if kwargs else {})
+
+
+def test_02_model_func_with_missing_annotations():
+ model = get_input_model_from_signature(func_with_missing_annotations)
+ assert issubklass(model, BaseModel)
+ assert model.model_fields["a"].annotation is int
+ assert model.model_fields["b"].annotation is Any
+ assert len(model.model_fields) == 2
+ assert model.model_config["extra"] == "forbid"
+
+
+@pytest.mark.parametrize(
+ "args,kwargs,raises,exmsg",
+ [
+ (None, {"a": 1, "b": 2}, None, None),
+ (None, {"a": 1, "b": "2"}, None, None),
+ (None, {"a": 2, "b": list()}, None, None),
+ (None, {"a": "1", "b": "2"}, ValidationError, None),
+ (None, {"a": list(), "b": dict()}, ValidationError, None),
+ (None, {"a": 1}, ValidationError, None),
+ (None, {"a": 1, "b": 2, "c": 3}, ValueError, "Unexpected keyword arguments"),
+ ((1, 2), None, None, None),
+ ((1, "2"), None, None, None),
+ ((2, list()), None, None, None),
+ (("1", "2"), None, ValidationError, None),
+ ((list(), dict()), None, ValidationError, None),
+ ((1, 2, 3), None, ValueError, "Too many positional arguments"),
+ ((1,), None, ValidationError, None),
+ ((1,), {"b": 2}, None, None),
+ ((1,), {"b": "2"}, None, None),
+ ((2,), {"b": list()}, None, None),
+ ((1,), {"a": 2}, ValueError, "Multiple values for argument"),
+ ((1, 2), {"c": 3}, ValueError, "Unexpected keyword arguments"),
+ (("1", 2), {"c": 3}, ValueError, "Unexpected keyword arguments"),
+ ],
)
+def test_02_validation_func_with_missing_annotations(args, kwargs, raises, exmsg):
+ model = get_input_model_from_signature(func_with_missing_annotations)
+ if raises:
+ with pytest.raises(raises) as ex:
+ pydantic_validate_args_kwargs(model, args=args if args else (), kwargs=kwargs if kwargs else {})
+ if exmsg:
+ assert str(ex.value).startswith(exmsg)
+ else:
+ pydantic_validate_args_kwargs(model, args=args if args else (), kwargs=kwargs if kwargs else {})
+
+
+def test_03_model_func_with_no_annotations():
+ model = get_input_model_from_signature(func_with_no_annotations, model_for_empty_annotations=True)
+ assert issubklass(model, BaseModel)
+ assert model.model_fields["a"].annotation is Any
+ assert model.model_fields["b"].annotation is Any
+ assert len(model.model_fields) == 2
+ assert model.model_config["extra"] == "forbid"
+
+
+@pytest.mark.parametrize(
+ "args,kwargs,raises,exmsg",
+ [
+ (None, {"a": 1, "b": 2}, None, None),
+ (None, {"a": 1.2, "b": "2"}, None, None),
+ (None, {"a": dict(), "b": list()}, None, None),
+ (None, {"a": list()}, ValidationError, None),
+ (None, {"b": dict()}, ValidationError, None),
+ (None, {"a": 1, "b": 2, "c": 3}, ValueError, "Unexpected keyword arguments"),
+ ((1, 2), None, None, None),
+ ((1, "2"), None, None, None),
+ ((dict(), list()), None, None, None),
+ ((1,), {"b": 2}, None, None),
+ ((1, 2, 3), None, ValueError, "Too many positional arguments"),
+ ((dict(), list(), 3), None, ValueError, "Too many positional arguments"),
+ ((1,), None, ValidationError, None),
+ ((dict(),), None, ValidationError, None),
+ ((1,), {"b": 2}, None, None),
+ ((1.1,), {"b": "2"}, None, None),
+ ((dict(),), {"b": list()}, None, None),
+ ((1,), {"a": 2}, ValueError, "Multiple values for argument"),
+ ((1, 2), {"c": 3}, ValueError, "Unexpected keyword arguments"),
+ (("1", 2), {"c": 3}, ValueError, "Unexpected keyword arguments"),
+ ],
+)
+def test_03_validation_func_with_no_annotations(args, kwargs, raises, exmsg):
+ model = get_input_model_from_signature(func_with_no_annotations, model_for_empty_annotations=True)
+ if raises:
+ with pytest.raises(raises) as ex:
+ pydantic_validate_args_kwargs(model, args=args if args else (), kwargs=kwargs if kwargs else {})
+ if exmsg:
+ assert str(ex.value).startswith(exmsg)
+ else:
+ pydantic_validate_args_kwargs(model, args=args if args else (), kwargs=kwargs if kwargs else {})
+
+
+def test_03_no_model_func_with_no_annotations():
+ model = get_input_model_from_signature(func_with_no_annotations)
+ assert model is None
+
+
+def test_04_model_func_with_kwargs():
+ model = get_input_model_from_signature(func_with_kwargs)
+ assert issubklass(model, BaseModel)
+ assert model.model_fields["a"].annotation is int
+ assert model.model_fields["b"].annotation is int
+ assert len(model.model_fields) == 3
+ assert model.model_config["extra"] == "forbid"
+
+
+@pytest.mark.parametrize(
+ "args,kwargs,raises,exmsg",
+ [
+ (None, {"a": 1, "b": 2}, None, None),
+ (None, {"a": 1, "b": 2, "c": 3}, None, None),
+ ((1, 2), {"c": "3"}, None, None),
+ (None, {"a": 1, "b": "2"}, ValidationError, None),
+ (None, {"a": 1, "b": "2", "c": "3"}, ValidationError, None),
+ (None, {"a": 1}, ValidationError, None),
+ (None, {"a": 1, "b": 2, "c": 3, "d": 4}, None, None),
+ ((1, 2), None, None, None),
+ ((1, "2"), None, ValidationError, None),
+ (("1", 2), None, ValidationError, None),
+ ((1, 2, 3), None, ValidationError, None),
+ ((1,), None, ValidationError, None),
+ ((1,), {"b": 2}, None, None),
+ ((1,), {"b": 2, "c": 3}, None, None),
+ ((1,), {"a": 2}, ValueError, "Multiple values for argument"),
+ ((1, 2), {"a": 3}, ValueError, "Multiple values for argument"),
+ ((1, 2), {"b": 3}, ValueError, "Multiple values for argument"),
+ ],
+)
+def test_04_validation_func_with_kwargs(args, kwargs, raises, exmsg):
+ model = get_input_model_from_signature(func_with_kwargs)
+ if raises:
+ with pytest.raises(raises) as ex:
+ pydantic_validate_args_kwargs(model, args=args if args else (), kwargs=kwargs if kwargs else {})
+ if exmsg:
+ assert str(ex.value).startswith(exmsg)
+ else:
+ pydantic_validate_args_kwargs(model, args=args if args else (), kwargs=kwargs if kwargs else {})
+
+
+def test_05_model_func_with_annotated_kwargs():
+ model = get_input_model_from_signature(func_with_annotated_kwargs)
+ assert issubklass(model, BaseModel)
+ assert model.model_fields["a"].annotation is int
+ assert model.model_fields["b"].annotation is int
+ assert model.model_fields["kwargs"].annotation == dict[str, int]
+ assert len(model.model_fields) == 3
+ assert model.model_config["extra"] == "forbid"
+
+
+@pytest.mark.parametrize(
+ "args,kwargs,raises,exmsg",
+ [
+ (None, {"a": 1, "b": 2}, None, None),
+ (None, {"a": 1, "b": 2, "c": 3}, None, None),
+ ((1, 2), {"c": 3}, None, None),
+ (None, {"a": 1, "b": "2"}, ValidationError, None),
+ (None, {"a": 1, "b": 2, "c": "3"}, ValidationError, None),
+ (None, {"a": 1, "b": 2, "c": list()}, ValidationError, None),
+ (None, {"a": 1}, ValidationError, None),
+ ((1, 2), None, None, None),
+ ((1, "2"), None, ValidationError, None),
+ ((dict(), 2), None, ValidationError, None),
+ ((1, 2, 3), None, ValidationError, None),
+ ((1,), None, ValidationError, None),
+ ((1,), {"b": 2}, None, None),
+ ((1,), {"b": 2, "c": 3}, None, None),
+ ((1,), {"a": 2}, ValueError, "Multiple values for argument"),
+ ((1, 2), {"a": 3}, ValueError, "Multiple values for argument"),
+ ((1, 2), {"b": 3}, ValueError, "Multiple values for argument"),
+ ((1, 2), {"a": list(), "c": 3}, ValueError, "Multiple values for argument"),
+ ],
+)
+def test_05_validation_func_with_annotated_kwargs(args, kwargs, raises, exmsg):
+ model = get_input_model_from_signature(func_with_annotated_kwargs)
+ if raises:
+ with pytest.raises(raises) as ex:
+ pydantic_validate_args_kwargs(model, args=args if args else (), kwargs=kwargs if kwargs else {})
+ if exmsg:
+ assert str(ex.value).startswith(exmsg)
+ else:
+ pydantic_validate_args_kwargs(model, args=args if args else (), kwargs=kwargs if kwargs else {})
+
+
+def test_06_model_func_with_args():
+ model = get_input_model_from_signature(func_with_args, model_for_empty_annotations=True)
+ assert issubklass(model, BaseModel)
+ # assert model.model_fields["args"].annotation == tuple or model.model_fields["args"].annotation == Tuple
+ assert len(model.model_fields) == 1
+ assert model.model_config["extra"] == "forbid"
+
+
+@pytest.mark.parametrize(
+ "args,kwargs,raises,exmsg",
+ [
+ ((1, 2), None, None, None),
+ (None, None, None, None),
+ ((dict(),), None, None, None),
+ (None, {"a": 1}, ValueError, "Unexpected keyword arguments"),
+ ((1, 2), None, None, None),
+ ((1,), {"a": 2}, ValueError, "Unexpected keyword arguments"),
+ ((1, 2), {"c": 3}, ValueError, "Unexpected keyword arguments"),
+ ],
+)
+def test_06_validation_func_with_args(args, kwargs, raises, exmsg):
+ model = get_input_model_from_signature(func_with_args, model_for_empty_annotations=True)
+ if raises:
+ with pytest.raises(raises) as ex:
+ pydantic_validate_args_kwargs(model, args=args if args else (), kwargs=kwargs if kwargs else {})
+ if exmsg:
+ assert str(ex.value).startswith(exmsg)
+ else:
+ pydantic_validate_args_kwargs(model, args=args if args else (), kwargs=kwargs if kwargs else {})
+
+
+def test_06_no_model_func_with_args():
+ model = get_input_model_from_signature(func_with_args)
+ assert model is None
+
+
+def test_07_model_func_with_annotated_args():
+ model = get_input_model_from_signature(func_with_annotated_args)
+ assert issubklass(model, BaseModel)
+ # assert model.model_fields["args"].annotation == typing.List[int]
+ assert len(model.model_fields) == 1
+ assert model.model_config["extra"] == "forbid"
+
+
+@pytest.mark.parametrize(
+ "args,kwargs,raises,exmsg",
+ [
+ (None, {"a": 1}, ValueError, "Unexpected keyword arguments"),
+ (None, None, None, None),
+ ((1, 2), None, None, None),
+ ((1, "2"), None, ValidationError, None),
+ ((dict(),), None, ValidationError, None),
+ ],
+)
+def test_07_validation_func_with_annotated_args(args, kwargs, raises, exmsg):
+ model = get_input_model_from_signature(func_with_annotated_args)
+ if raises:
+ with pytest.raises(raises) as ex:
+ pydantic_validate_args_kwargs(model, args=args if args else (), kwargs=kwargs if kwargs else {})
+ if exmsg:
+ assert str(ex.value).startswith(exmsg)
+ else:
+ pydantic_validate_args_kwargs(model, args=args if args else (), kwargs=kwargs if kwargs else {})
+
+
+def test_08_no_model_func_with_args_and_kwargs():
+ model = get_input_model_from_signature(func_with_args_and_kwargs)
+ assert model is None
+
+
+def test_08_model_func_with_args_and_kwargs():
+ model = get_input_model_from_signature(func_with_args_and_kwargs, model_for_empty_annotations=True)
+ assert issubklass(model, BaseModel)
+ assert model.model_fields["args"].annotation == Tuple or model.model_fields["args"].annotation is tuple
+ assert model.model_fields["kwargs"].annotation == Dict[str, Any] or model.model_fields["kwargs"].annotation is dict
+ assert len(model.model_fields) == 2
+ assert model.model_config["extra"] == "forbid"
+
-try:
- from .utils import TestCase, TestRunner
-except ImportError:
- from utils import TestCase, TestRunner
-
-
-class TestUtils(TestCase):
- def test_1_pydantic_function_signature_validation(self):
- def func_without_args():
- return 1
-
- model = get_input_model_from_signature(func_without_args)
- self.assertTrue(model is None)
-
- """
- Test Sequence:
- 1. Create model from function signature
- 2. Check model annotations
- 3. Check model fields length
- 4. Check model config (pydantic's model_config)
- 5. Validation with correction and wrong invokation of function
- 6. Always check exception strings for ValueError
- 7. Use ValidationError if pydantic is supposed to raise the Error
- """
-
- """
- Signatures that we will validate:
- 1. func_with_annotations(a: int, b: int) -> int:
- 2. func_with_missing_annotations(a: int, b):
- 3. func_with_no_annotations(a, b):
- 4. func_with_kwargs(a: int, b: int, **kwargs):
- 5. func_with_annotated_kwargs(a: int, b: int, **kwargs: typing.Dict[str, int]):
- 6. func_with_args(*args):
- 7. func_with_annotated_args(*args: typing.List[int]):
- 8. func_with_args_and_kwargs(*args, **kwargs):
- 9. func_with_annotated_args_and_kwargs(*args: typing.List[int], **kwargs: typing.Dict[str, int]):
- 10. func_with_positional_only_args(a, b, /):
- 11. func_with_keyword_only_args(*, a, b):
- 12. func_with_positional_only_args_and_kwargs(a, *args, b, **kwargs):
- """
-
- ####################
- ##### create model from function signature
- # 1. func_with_annotations(a: int, b: int) -> int:
- def func_with_annotations(a: int, b: int) -> int:
- return a + b
-
- model = get_input_model_from_signature(func_with_annotations)
- self.assertTrue(issubklass(model, BaseModel))
- self.assertEqual(model.model_fields["a"].annotation, int)
- self.assertEqual(model.model_fields["b"].annotation, int)
- self.assertEqual(len(model.model_fields), 2)
- self.assertEqual(model.model_config["extra"], "forbid")
- ##### validate correct usage
- # For all the following cases, see block comment below the test case for details
- # 1. correct usage with keyword arguments
- pydantic_validate_args_kwargs(model, kwargs={"a": 1, "b": 2})
- # 2. incorrect argument types with keyword arguments
- with self.assertRaises(ValidationError):
- pydantic_validate_args_kwargs(model, kwargs={"a": 1, "b": "2"})
- # 3. missing keyword arguments
- with self.assertRaises(ValidationError):
- pydantic_validate_args_kwargs(model, kwargs={"a": 1})
- # 4. too many keyword arguments
- with self.assertRaises(ValueError) as ex:
- pydantic_validate_args_kwargs(model, kwargs={"a": 1, "b": 2, "c": 3})
- self.assertTrue(str(ex.exception).startswith("Unexpected keyword arguments"))
- # 5. correct usage with positional arguments
- pydantic_validate_args_kwargs(model, args=(1, 2))
- # 6. incorrect argument types with positional arguments
- with self.assertRaises(ValidationError):
- pydantic_validate_args_kwargs(model, args=(1, "2"))
- # 7. too many positional arguments
- with self.assertRaises(ValueError) as ex:
- pydantic_validate_args_kwargs(model, args=(1, 2, 3))
- self.assertTrue(str(ex.exception).startswith("Too many positional arguments"))
- # 8. missing positional arguments
- with self.assertRaises(ValidationError) as ex:
- pydantic_validate_args_kwargs(model, args=(1,))
- # 9. correct usage with positional and keyword arguments
- pydantic_validate_args_kwargs(model, args=(1,), kwargs={"b": 2})
- # 10. incorrect ordering with positional and keyword arguments
- with self.assertRaises(ValueError) as ex:
- pydantic_validate_args_kwargs(model, args=(1,), kwargs={"a": 2})
- self.assertTrue(str(ex.exception).startswith("Multiple values for argument"))
- # 11. incorrect usage with both positional and keyword arguments
- with self.assertRaises(ValueError) as ex:
- pydantic_validate_args_kwargs(model, args=(1, 2), kwargs={"c": 3})
- self.assertTrue(str(ex.exception).startswith("Unexpected keyword arguments"))
- with self.assertRaises(ValueError) as ex:
- pydantic_validate_args_kwargs(model, args=("1", 2), kwargs={"c": 3})
- self.assertTrue(str(ex.exception).startswith("Unexpected keyword arguments"))
- ####################
-
- # 1. correct usage with keyword arguments
- # 2. incorrect argument types with keyword arguments
- # 3. missing keyword arguments
- # 4. too many keyword arguments
- # 5. correct usage with positional arguments
- # 6. incorrect argument types with positional arguments
- # 7. too many positional arguments
- # 8. missing positional arguments
- # 9. correct usage with positional and keyword arguments
- # 10. incorrect ordering with positional and keyword arguments
- # 11. additional cases of incorrect usage falling under the same categories
-
- ####################
- ##### create model from function signature
- # 2. func_with_missing_annotations(a: int, b):
- def func_with_missing_annotations(a: int, b):
- return a + b
-
- model = get_input_model_from_signature(func_with_missing_annotations)
- self.assertTrue(issubklass(model, BaseModel))
- self.assertEqual(model.model_fields["a"].annotation, int)
- self.assertEqual(model.model_fields["b"].annotation, typing.Any)
- self.assertEqual(len(model.model_fields), 2)
- self.assertEqual(model.model_config["extra"], "forbid")
- ##### validate correct usage
- # 1. correct usage with keyword arguments
- pydantic_validate_args_kwargs(model, kwargs={"a": 1, "b": 2})
- pydantic_validate_args_kwargs(model, kwargs={"a": 1, "b": "2"})
- pydantic_validate_args_kwargs(model, kwargs={"a": 2, "b": list()})
- # 2. incorrect argument types with keyword arguments
- with self.assertRaises(ValidationError):
- pydantic_validate_args_kwargs(model, kwargs={"a": "1", "b": "2"})
- with self.assertRaises(ValidationError):
- pydantic_validate_args_kwargs(model, kwargs={"a": list(), "b": dict()})
- # 3. missing keyword arguments
- with self.assertRaises(ValidationError):
- pydantic_validate_args_kwargs(model, kwargs={"a": 1})
- # 4. too many keyword arguments
- with self.assertRaises(ValueError) as ex:
- pydantic_validate_args_kwargs(model, kwargs={"a": 1, "b": 2, "c": 3})
- self.assertTrue(str(ex.exception).startswith("Unexpected keyword arguments"))
- # 5. correct positional arguments
- pydantic_validate_args_kwargs(model, args=(1, 2))
- pydantic_validate_args_kwargs(model, args=(1, "2"))
- pydantic_validate_args_kwargs(model, args=(2, list()))
- # 6. incorrect argument types with positional arguments
- with self.assertRaises(ValidationError):
- pydantic_validate_args_kwargs(model, args=("1", "2"))
- with self.assertRaises(ValidationError):
- pydantic_validate_args_kwargs(model, args=(list(), dict()))
- # 7. too many positional arguments
- with self.assertRaises(ValueError) as ex:
- pydantic_validate_args_kwargs(model, args=(1, 2, 3))
- self.assertTrue(str(ex.exception).startswith("Too many positional arguments"))
- # 8. missing positional arguments
- with self.assertRaises(ValidationError) as ex:
- pydantic_validate_args_kwargs(model, args=(1,))
- # 9. correct usage with positional and keyword arguments
- pydantic_validate_args_kwargs(model, args=(1,), kwargs={"b": 2})
- pydantic_validate_args_kwargs(model, args=(1,), kwargs={"b": "2"})
- pydantic_validate_args_kwargs(model, args=(2,), kwargs={"b": list()})
- # 10. incorrect ordering with positional and keyword arguments
- with self.assertRaises(ValueError) as ex:
- pydantic_validate_args_kwargs(model, args=(1,), kwargs={"a": 2})
- self.assertTrue(str(ex.exception).startswith("Multiple values for argument"))
- # 11. incorrect usage with both positional and keyword arguments
- with self.assertRaises(ValueError) as ex:
- pydantic_validate_args_kwargs(model, args=(1, 2), kwargs={"c": 3})
- self.assertTrue(str(ex.exception).startswith("Unexpected keyword arguments"))
- with self.assertRaises(ValueError) as ex:
- pydantic_validate_args_kwargs(model, args=("1", 2), kwargs={"c": 3})
- self.assertTrue(str(ex.exception).startswith("Unexpected keyword arguments"))
- ####################
-
- ####################
- ##### create model from function signature
- # 3. func_with_no_annotations(a, b):
- def func_with_no_annotations(a, b):
- return a + b
-
- model = get_input_model_from_signature(func_with_no_annotations, model_for_empty_annotations=True)
- self.assertTrue(issubklass(model, BaseModel))
- self.assertEqual(model.model_fields["a"].annotation, typing.Any)
- self.assertEqual(model.model_fields["b"].annotation, typing.Any)
- self.assertEqual(len(model.model_fields), 2)
- self.assertEqual(model.model_config["extra"], "forbid")
- ##### validate correct usage
- # 1. correct usage
- pydantic_validate_args_kwargs(model, kwargs={"a": 1, "b": 2})
- pydantic_validate_args_kwargs(model, kwargs={"a": 1.2, "b": "2"})
- pydantic_validate_args_kwargs(model, kwargs={"a": dict(), "b": list()})
- # 2. incorrect argument types
- # typing.Any allows any type, so no ValidationError
- # 3. missing keyword arguments
- with self.assertRaises(ValidationError):
- pydantic_validate_args_kwargs(model, kwargs={"a": list()})
- with self.assertRaises(ValidationError):
- pydantic_validate_args_kwargs(model, kwargs={"b": dict()})
- # 4. too many keyword arguments
- with self.assertRaises(ValueError) as ex:
- pydantic_validate_args_kwargs(model, kwargs={"a": 1, "b": 2, "c": 3})
- self.assertTrue(str(ex.exception).startswith("Unexpected keyword arguments"))
- # 5. correct positional arguments
- pydantic_validate_args_kwargs(model, args=(1, 2))
- pydantic_validate_args_kwargs(model, args=(1, "2"))
- pydantic_validate_args_kwargs(model, args=(dict(), list()))
- pydantic_validate_args_kwargs(model, args=(1,), kwargs={"b": 2})
- # 6. incorrect argument types with positional arguments
- # typing.Any allows any type, so no ValidationError
- # 7. too many positional arguments
- with self.assertRaises(ValueError) as ex:
- pydantic_validate_args_kwargs(model, args=(1, 2, 3))
- self.assertTrue(str(ex.exception).startswith("Too many positional arguments"))
- with self.assertRaises(ValueError) as ex:
- pydantic_validate_args_kwargs(model, args=(dict(), list(), 3))
- self.assertTrue(str(ex.exception).startswith("Too many positional arguments"))
- # 8. missing positional arguments
- with self.assertRaises(ValidationError) as ex:
- pydantic_validate_args_kwargs(model, args=(1,))
- with self.assertRaises(ValidationError) as ex:
- pydantic_validate_args_kwargs(model, args=(dict(),))
- # 9. correct usage with positional and keyword arguments
- pydantic_validate_args_kwargs(model, args=(1,), kwargs={"b": 2})
- pydantic_validate_args_kwargs(model, args=(1.1,), kwargs={"b": "2"})
- pydantic_validate_args_kwargs(model, args=(dict(),), kwargs={"b": list()})
- # 10. incorrect ordering with positional and keyword arguments
- with self.assertRaises(ValueError) as ex:
- pydantic_validate_args_kwargs(model, args=(1,), kwargs={"a": 2})
- self.assertTrue(str(ex.exception).startswith("Multiple values for argument"))
- # 11. incorrect usage with both positional and keyword arguments
- with self.assertRaises(ValueError) as ex:
- pydantic_validate_args_kwargs(model, args=(1, 2), kwargs={"c": 3})
- self.assertTrue(str(ex.exception).startswith("Unexpected keyword arguments"))
- with self.assertRaises(ValueError) as ex:
- pydantic_validate_args_kwargs(model, args=("1", 2), kwargs={"c": 3})
- self.assertTrue(str(ex.exception).startswith("Unexpected keyword arguments"))
-
- model = get_input_model_from_signature(func_with_no_annotations)
- self.assertTrue(model is None)
-
- ####################
- ##### create model from function signature
- # 4. func_with_kwargs(a: int, b: int, **kwargs):
- def func_with_kwargs(a: int, b: int, **kwargs):
- return a + b
-
- model = get_input_model_from_signature(func_with_kwargs)
- self.assertTrue(issubklass(model, BaseModel))
- self.assertEqual(model.model_fields["a"].annotation, int)
- self.assertEqual(model.model_fields["b"].annotation, int)
- self.assertEqual(len(model.model_fields), 3)
- self.assertEqual(model.model_config["extra"], "forbid")
- ##### validate correct usage
- # 1. correct usage
- pydantic_validate_args_kwargs(model, kwargs={"a": 1, "b": 2})
- pydantic_validate_args_kwargs(model, kwargs={"a": 1, "b": 2, "c": 3})
- pydantic_validate_args_kwargs(model, args=(1, 2), kwargs={"c": "3"})
- # 2. incorrect argument types
- with self.assertRaises(ValidationError):
- pydantic_validate_args_kwargs(model, kwargs={"a": 1, "b": "2"})
- with self.assertRaises(ValidationError):
- pydantic_validate_args_kwargs(model, kwargs={"a": 1, "b": "2", "c": "3"})
- # 3. missing keyword arguments
- with self.assertRaises(ValidationError):
- pydantic_validate_args_kwargs(model, kwargs={"a": 1})
- # 4. too many keyword arguments
- pydantic_validate_args_kwargs(model, kwargs={"a": 1, "b": 2, "c": 3, "d": 4}) # OK, not an error
- # 5. correct positional arguments
- pydantic_validate_args_kwargs(model, args=(1, 2))
- # 6. incorrect argument types with positional arguments
- with self.assertRaises(ValidationError):
- pydantic_validate_args_kwargs(model, args=(1, "2"))
- with self.assertRaises(ValidationError):
- pydantic_validate_args_kwargs(model, args=("1", 2))
- # 7. too many positional arguments
- with self.assertRaises(ValidationError) as ex:
- pydantic_validate_args_kwargs(model, args=(1, 2, 3))
- # 8. missing positional arguments
- with self.assertRaises(ValidationError) as ex:
- pydantic_validate_args_kwargs(model, args=(1,))
- # 9. correct usage with positional and keyword arguments
- pydantic_validate_args_kwargs(model, args=(1,), kwargs={"b": 2})
- pydantic_validate_args_kwargs(model, args=(1,), kwargs={"b": 2, "c": 3})
- # 10. incorrect ordering with positional and keyword arguments
- with self.assertRaises(ValueError) as ex:
- pydantic_validate_args_kwargs(model, args=(1,), kwargs={"a": 2})
- self.assertTrue(str(ex.exception).startswith("Multiple values for argument"))
- with self.assertRaises(ValueError) as ex:
- pydantic_validate_args_kwargs(model, args=(1, 2), kwargs={"a": 3})
- self.assertTrue(str(ex.exception).startswith("Multiple values for argument"))
- with self.assertRaises(ValueError) as ex:
- pydantic_validate_args_kwargs(model, args=(1, 2), kwargs={"b": 3})
- self.assertTrue(str(ex.exception).startswith("Multiple values for argument"))
- # 11. incorrect usage with both positional and keyword arguments
- # any extra keyword argument is allowed
-
- ####################
- ##### create model from function signature
- # 5. func_with_annotated_kwargs(a: int, b: int, **kwargs: typing.Dict[str, int]):
- def func_with_annotated_kwargs(a: int, b: int, **kwargs: typing.Dict[str, int]):
- return a + b
-
- model = get_input_model_from_signature(func_with_annotated_kwargs)
- self.assertTrue(issubklass(model, BaseModel))
- self.assertEqual(model.model_fields["a"].annotation, int)
- self.assertEqual(model.model_fields["b"].annotation, int)
- self.assertEqual(model.model_fields["kwargs"].annotation, typing.Dict[str, int])
- self.assertEqual(len(model.model_fields), 3)
- self.assertEqual(model.model_config["extra"], "forbid")
- # 1. correct usage
- pydantic_validate_args_kwargs(model, kwargs={"a": 1, "b": 2})
- pydantic_validate_args_kwargs(model, kwargs={"a": 1, "b": 2, "c": 3})
- pydantic_validate_args_kwargs(model, args=(1, 2), kwargs={"c": 3})
- # 2. incorrect argument types
- with self.assertRaises(ValidationError):
- pydantic_validate_args_kwargs(model, kwargs={"a": 1, "b": "2"})
- with self.assertRaises(ValidationError):
- pydantic_validate_args_kwargs(model, kwargs={"a": 1, "b": 2, "c": "3"})
- with self.assertRaises(ValidationError):
- pydantic_validate_args_kwargs(model, kwargs={"a": 1, "b": 2, "c": list()})
- # 3. missing keyword arguments
- with self.assertRaises(ValidationError):
- pydantic_validate_args_kwargs(model, kwargs={"a": 1})
- # 4. too many keyword arguments
- # OK, not an error
- # 5. correct positional arguments
- pydantic_validate_args_kwargs(model, args=(1, 2))
- # 6. incorrect argument types with positional arguments
- with self.assertRaises(ValidationError):
- pydantic_validate_args_kwargs(model, args=(1, "2"))
- with self.assertRaises(ValidationError):
- pydantic_validate_args_kwargs(model, args=(dict(), 2))
- # 7. too many positional arguments
- with self.assertRaises(ValidationError) as ex:
- pydantic_validate_args_kwargs(model, args=(1, 2, 3))
- # 8. missing positional arguments
- with self.assertRaises(ValidationError) as ex:
- pydantic_validate_args_kwargs(model, args=(1,))
- # 9. correct usage with positional and keyword arguments
- pydantic_validate_args_kwargs(model, args=(1,), kwargs={"b": 2})
- pydantic_validate_args_kwargs(model, args=(1,), kwargs={"b": 2, "c": 3})
- # 10. incorrect ordering with positional and keyword arguments
- with self.assertRaises(ValueError) as ex:
- pydantic_validate_args_kwargs(model, args=(1,), kwargs={"a": 2})
- self.assertTrue(str(ex.exception).startswith("Multiple values for argument"))
- with self.assertRaises(ValueError) as ex:
- pydantic_validate_args_kwargs(model, args=(1, 2), kwargs={"a": 3})
- self.assertTrue(str(ex.exception).startswith("Multiple values for argument"))
- with self.assertRaises(ValueError) as ex:
- pydantic_validate_args_kwargs(model, args=(1, 2), kwargs={"b": 3})
- self.assertTrue(str(ex.exception).startswith("Multiple values for argument"))
- with self.assertRaises(ValueError) as ex:
- pydantic_validate_args_kwargs(model, args=(1, 2), kwargs={"a": list(), "c": 3})
- self.assertTrue(str(ex.exception).startswith("Multiple values for argument"))
- # 11. incorrect usage with both positional and keyword arguments
- # any extra keyword argument is allowed so long it is of type int
-
- # both the following are not allowed in python - its also illogical
- # def func_with_double_args(*args1, *args2):
- # """syntax error"""
- # return
- # def func_with_double_kwargs(**kwargs1, **kwargs2):
- # """syntax error"""
- # return
-
- ####################
- ##### create model from function signature
- # 6. func_with_args(*args):
- def func_with_args(*args):
- return sum(args)
-
- model = get_input_model_from_signature(func_with_args, model_for_empty_annotations=True)
- self.assertTrue(issubklass(model, BaseModel))
- self.assertEqual(model.model_fields["args"].annotation, typing.Tuple)
- self.assertEqual(len(model.model_fields), 1)
- self.assertEqual(model.model_config["extra"], "forbid")
- # 1. correct usage
- pydantic_validate_args_kwargs(model, args=(1, 2))
- pydantic_validate_args_kwargs(model)
- pydantic_validate_args_kwargs(model, args=(dict()))
- # 2. incorrect argument types
- # OK, since args is a tuple of any type
- # 3. missing keyword arguments
- # OK, since args is a tuple
- # 4. too many keyword arguments
- with self.assertRaises(ValueError) as ex:
- pydantic_validate_args_kwargs(model, kwargs={"a": 1})
- self.assertTrue(str(ex.exception).startswith("Unexpected keyword arguments"))
- # 5. correct positional arguments
- pydantic_validate_args_kwargs(model, args=(1, 2))
- # 6. incorrect argument types with positional arguments
- # OK, since args is a tuple of any type
- # 7. too many positional arguments
- # OK, since args is a tuple of any length
- # 8. missing positional arguments
- # OK, since args is a tuple of any length
- # 9. correct usage with positional and keyword arguments
- # no keyword arguments
- # 10. incorrect ordering with positional and keyword arguments
- # OK, since args is a tuple and not keywords, no multiple values
- # 11. incorrect usage with both positional and keyword arguments
- with self.assertRaises(ValueError) as ex:
- pydantic_validate_args_kwargs(model, args=(1,), kwargs={"a": 2})
- self.assertTrue(str(ex.exception).startswith("Unexpected keyword arguments"))
- with self.assertRaises(ValueError) as ex:
- pydantic_validate_args_kwargs(model, args=(1, 2), kwargs={"c": 3})
- self.assertTrue(str(ex.exception).startswith("Unexpected keyword arguments"))
-
- model = get_input_model_from_signature(func_with_args)
- self.assertTrue(model is None)
- ####################
-
- ####################
- ##### create model from function signature
- # 7. func_with_annotated_args(*args: typing.List[int]):
- def func_with_annotated_args(*args: typing.List[int]):
- return sum(args)
-
- model = get_input_model_from_signature(func_with_annotated_args)
- self.assertTrue(issubklass(model, BaseModel))
- self.assertEqual(model.model_fields["args"].annotation, typing.List[int])
- self.assertEqual(len(model.model_fields), 1)
- self.assertEqual(model.model_config["extra"], "forbid")
- # 1. correct usage with keyword arguments
- # not possible, since args is a tuple
- # 2. incorrect argument types with keyword arguments
- # keyword arguments are not allowed
- # 3. missing keyword arguments
- # not possible
- # 4. too many keyword arguments
- with self.assertRaises(ValueError):
- pydantic_validate_args_kwargs(model, kwargs={"a": 1})
- self.assertTrue(str(ex.exception).startswith("Unexpected keyword arguments"))
- # 5. correct usage with positional arguments
- pydantic_validate_args_kwargs(model)
- pydantic_validate_args_kwargs(model, args=(1, 2))
- # 6. incorrect argument types with positional arguments
- with self.assertRaises(ValidationError):
- pydantic_validate_args_kwargs(model, args=(1, "2"))
- with self.assertRaises(ValidationError):
- pydantic_validate_args_kwargs(model, args=(dict(),))
- # 7. too many positional arguments
- # OK, since args is a list of any length
- # 8. missing positional arguments
- # OK, since args is a list of any length
- # 9. correct usage with positional and keyword arguments
- # not possible
- # 10. incorrect ordering with positional and keyword arguments
- # OK, since args is a list and not keywords, no multiple values
- # 11. incorrect usage with both positional and keyword arguments
- # not possible
-
- #####################
- ##### create model from function signature
- # 8. func_with_args_and_kwargs(*args, **kwargs):
- def func_with_args_and_kwargs(*args, **kwargs):
- return sum(args) + sum(kwargs.values())
-
- # no model
- model = get_input_model_from_signature(func_with_args_and_kwargs)
- self.assertTrue(model is None)
- # check model for empty annotations
- model = get_input_model_from_signature(func_with_args_and_kwargs, model_for_empty_annotations=True)
- self.assertTrue(issubklass(model, BaseModel))
- self.assertEqual(model.model_fields["args"].annotation, typing.Tuple)
- self.assertEqual(model.model_fields["kwargs"].annotation, typing.Dict[str, typing.Any])
- self.assertEqual(len(model.model_fields), 2)
- self.assertEqual(model.model_config["extra"], "forbid")
-
- def func_with_annotated_args_and_kwargs(*args: typing.List[int], **kwargs: typing.Dict[str, int]):
- return sum(args) + sum(kwargs.values())
-
- model = get_input_model_from_signature(func_with_annotated_args_and_kwargs)
- self.assertTrue(issubklass(model, BaseModel))
- self.assertEqual(model.model_fields["args"].annotation, typing.List[int])
- self.assertEqual(model.model_fields["kwargs"].annotation, typing.Dict[str, int])
- self.assertEqual(len(model.model_fields), 2)
- self.assertEqual(model.model_config["extra"], "forbid")
-
-
-if __name__ == "__main__":
- unittest.main(testRunner=TestRunner())
+def test_08_model_func_with_annotated_args_and_kwargs_model():
+ model = get_input_model_from_signature(func_with_annotated_args_and_kwargs)
+ assert issubklass(model, BaseModel)
+ assert model.model_fields["args"].annotation == List[int] or model.model_fields["args"].annotation == list[int]
+ assert (
+ model.model_fields["kwargs"].annotation == Dict[str, int]
+ or model.model_fields["kwargs"].annotation == dict[str, int]
+ )
+ assert len(model.model_fields) == 2
+ assert model.model_config["extra"] == "forbid"
diff --git a/tests/test_01_message.py b/tests/test_01_message.py
index dc72c627..5bb1548f 100644
--- a/tests/test_01_message.py
+++ b/tests/test_01_message.py
@@ -1,219 +1,197 @@
-import unittest
from uuid import UUID, uuid4
from hololinked.core.zmq.message import (
+ ERROR,
EXIT,
- OPERATION,
HANDSHAKE,
+ INVALID_MESSAGE,
+ OPERATION,
+ REPLY,
+ TIMEOUT,
+ EventHeader,
+ EventMessage,
PreserializedData,
- SerializableData,
RequestHeader,
- EventHeader,
RequestMessage,
-) # client to server
-from hololinked.core.zmq.message import (
- TIMEOUT,
- INVALID_MESSAGE,
- ERROR,
- REPLY,
- ERROR,
- ResponseMessage,
ResponseHeader,
- EventMessage,
-) # server to client
+ ResponseMessage,
+ SerializableData,
+)
from hololinked.serializers.serializers import Serializers
+
try:
- from .utils import TestCase, TestRunner
+ from .conftest import AppIDs
except ImportError:
- from utils import TestCase, TestRunner
-
-
-class MessageValidatorMixin(TestCase):
- """A mixin class to validate request and response messages"""
-
- @classmethod
- def setUpClass(cls):
- super().setUpClass()
- cls.server_id = f"test-server-{uuid4().hex[:8]}"
- cls.client_id = f"test-client-{uuid4().hex[:8]}"
- cls.thing_id = f"test-thing-{uuid4().hex[:8]}"
-
- def validate_request_message(self, request_message: RequestMessage) -> None:
- """call this method to validate request message"""
-
- # req. 1. check message ID is a UUID
- self.assertTrue(isinstance(request_message.id, UUID) or isinstance(UUID(request_message.id, version=4), UUID))
- # req. 2. generated byte array must confine to predefined length (which is readonly & fixed)
- self.assertEqual(len(request_message.byte_array), request_message.length)
- # req. 3. receiver which must be the server ID
- self.assertEqual(request_message.receiver_id, self.server_id)
- # req. 4. sender_id is the client ID
- self.assertEqual(request_message.sender_id, self.client_id)
- # req. 5. all indices of byte array are bytes
- for obj in request_message.byte_array:
- self.assertIsInstance(obj, bytes)
- # req. 6. check that header is correct type (RequestHeader dataclass/struct)
- self.assertIsInstance(request_message.header, RequestHeader)
- # req. 7 check that body is correct type (list of SerializableData and PreserializedData)
- self.assertIsInstance(request_message.body, list)
- self.assertEqual(len(request_message.body), 2)
- self.assertIsInstance(request_message.body[0], SerializableData)
- self.assertIsInstance(request_message.body[1], PreserializedData)
-
- def validate_response_message(self, response_message: ResponseMessage) -> None:
- """call this method to validate response message"""
-
- # check message ID is a UUID
- self.assertTrue(isinstance(response_message.id, UUID) or isinstance(UUID(response_message.id, version=4), UUID))
- # check message length
- self.assertEqual(len(response_message.byte_array), response_message.length)
- # check receiver which must be the client
- self.assertEqual(response_message.receiver_id, self.client_id)
- # sender_id is not set before sending message on the socket
- self.assertEqual(response_message.sender_id, self.server_id)
- # check that all indices are bytes
- for obj in response_message.byte_array:
- self.assertIsInstance(obj, bytes)
- # check that header is correct type
- self.assertIsInstance(response_message.header, ResponseHeader)
- # check that body is correct type
- self.assertIsInstance(response_message.body, list)
- self.assertEqual(len(response_message.body), 2)
- self.assertIsInstance(response_message.body[0], SerializableData)
- self.assertIsInstance(response_message.body[1], PreserializedData)
-
- def validate_event_message(self, event_message: EventMessage) -> None:
- """call this method to validate event message"""
-
- # check message ID is a UUID
- self.assertTrue(isinstance(event_message.id, UUID) or isinstance(UUID(event_message.id, version=4), UUID))
- # check message length
- self.assertEqual(len(event_message.byte_array), event_message.length)
- # no receiver id for event message, only event id
- self.assertIsInstance(event_message.event_id, str)
- # sender_id is not set before sending message on the socket
- self.assertEqual(event_message.sender_id, self.server_id)
- # check that all indices are bytes
- for obj in event_message.byte_array:
- self.assertIsInstance(obj, bytes)
- # check that header is correct type
- self.assertIsInstance(event_message.header, EventHeader)
- # check that body is correct type
- self.assertIsInstance(event_message.body, list)
- self.assertEqual(len(event_message.body), 2)
- self.assertIsInstance(event_message.body[0], SerializableData)
- self.assertIsInstance(event_message.body[1], PreserializedData)
-
-
-class TestMessagingContract(MessageValidatorMixin):
- """Tests request and response messages"""
-
- @classmethod
- def setUpClass(cls):
- super().setUpClass()
- print(f"test message contract with {cls.__name__}")
-
- def test_1_request_message(self):
- """test the request message"""
-
- # request messages types are OPERATION, HANDSHAKE & EXIT
- request_message = RequestMessage.craft_from_arguments(
- receiver_id=self.server_id,
- sender_id=self.client_id,
- thing_id=self.thing_id,
- objekt="some_prop",
- operation="readproperty",
- )
- self.validate_request_message(request_message)
- # check message type for the above craft_from_arguments method
- self.assertEqual(request_message.type, OPERATION)
-
- request_message = RequestMessage.craft_with_message_type(
- receiver_id=self.server_id, sender_id=self.client_id, message_type=HANDSHAKE
- )
- self.validate_request_message(request_message)
- # check message type for the above craft_with_message_type method
- self.assertEqual(request_message.type, HANDSHAKE)
-
- request_message = RequestMessage.craft_with_message_type(
- receiver_id=self.server_id, sender_id=self.client_id, message_type=EXIT
- )
- self.validate_request_message(request_message)
- # check message type for the above craft_with_message_type method
- self.assertEqual(request_message.type, EXIT)
-
- def test_2_response_message(self):
- """test the response message"""
-
- # response messages types are HANDSHAKE, TIMEOUT, INVALID_MESSAGE, ERROR and REPLY
- response_message = ResponseMessage.craft_from_arguments(
- receiver_id=self.client_id,
- sender_id=self.server_id,
- message_type=HANDSHAKE,
- message_id=uuid4(),
- )
- self.validate_response_message(response_message)
- # check message type for the above craft_with_message_type method
- self.assertEqual(response_message.type, HANDSHAKE)
-
- response_message = ResponseMessage.craft_from_arguments(
- receiver_id=self.client_id,
- sender_id=self.server_id,
- message_type=TIMEOUT,
- message_id=uuid4(),
- )
- self.validate_response_message(response_message)
- # check message type for the above craft_with_message_type method
- self.assertEqual(response_message.type, TIMEOUT)
-
- response_message = ResponseMessage.craft_from_arguments(
- receiver_id=self.client_id,
- sender_id=self.server_id,
- message_type=INVALID_MESSAGE,
- message_id=uuid4(),
- )
- self.validate_response_message(response_message)
- # check message type for the above craft_with_message_type method
- self.assertEqual(response_message.type, INVALID_MESSAGE)
-
- response_message = ResponseMessage.craft_from_arguments(
- receiver_id=self.client_id,
- sender_id=self.server_id,
- message_type=ERROR,
- message_id=uuid4(),
- payload=SerializableData(Exception("test")),
- )
- self.validate_response_message(response_message)
- self.assertEqual(response_message.type, ERROR)
- self.assertIsInstance(Serializers.json.loads(response_message._bytes[2]), dict)
-
- request_message = RequestMessage.craft_from_arguments(
- sender_id=self.client_id,
- receiver_id=self.server_id,
- thing_id=self.thing_id,
- objekt="some_prop",
- operation="readProperty",
- )
- request_message._sender_id = self.client_id # will be done by craft_from_self
- response_message = ResponseMessage.craft_reply_from_request(
- request_message=request_message,
- )
- self.validate_response_message(response_message)
- self.assertEqual(response_message.type, REPLY)
- self.assertEqual(Serializers.json.loads(response_message._bytes[3]), None) # INDEX_BODY = 3
- self.assertEqual(request_message.id, response_message.id)
-
- def test_3_event_message(self):
- """test the event message"""
- event_message = EventMessage.craft_from_arguments(
- event_id="test-event",
- sender_id=self.server_id,
- payload=SerializableData("test"),
- preserialized_payload=PreserializedData(b"test"),
- )
- self.validate_event_message(event_message)
-
-
-if __name__ == "__main__":
- unittest.main(testRunner=TestRunner())
+ from conftest import AppIDs
+
+
+def validate_request_message(request_message: RequestMessage, app_ids: AppIDs) -> None:
+ """call this method to validate request message"""
+ # req. 1. check message ID is a UUID
+ assert isinstance(request_message.id, UUID) or isinstance(UUID(request_message.id, version=4), UUID)
+ # req. 2. generated byte array must confine to predefined length (which is readonly & fixed)
+ assert len(request_message.byte_array) == request_message.length
+ # req. 3. receiver which must be the server ID
+ assert request_message.receiver_id == app_ids.server_id
+ # req. 4. sender_id is the client ID
+ assert request_message.sender_id == app_ids.client_id
+ # req. 5. all indices of byte array are bytes
+ for obj in request_message.byte_array:
+ assert isinstance(obj, bytes)
+ # req. 6. check that header is correct type (RequestHeader dataclass/struct)
+ assert isinstance(request_message.header, RequestHeader)
+ # req. 7 check that body is correct type (list of SerializableData and PreserializedData)
+ assert isinstance(request_message.body, list)
+ assert len(request_message.body) == 2
+ assert isinstance(request_message.body[0], SerializableData)
+ assert isinstance(request_message.body[1], PreserializedData)
+
+
+def validate_response_message(response_message: ResponseMessage, app_ids: AppIDs) -> None:
+ """call this method to validate response message"""
+
+ # check message ID is a UUID
+ assert isinstance(response_message.id, UUID) or isinstance(UUID(response_message.id, version=4), UUID)
+ # check message length
+ assert len(response_message.byte_array) == response_message.length
+ # check receiver which must be the client
+ assert response_message.receiver_id == app_ids.client_id
+ # sender_id is not set before sending message on the socket
+ assert response_message.sender_id == app_ids.server_id
+ # check that all indices are bytes
+ for obj in response_message.byte_array:
+ assert isinstance(obj, bytes)
+ # check that header is correct type
+ assert isinstance(response_message.header, ResponseHeader)
+ # check that body is correct type
+ assert isinstance(response_message.body, list)
+ assert len(response_message.body) == 2
+ assert isinstance(response_message.body[0], SerializableData)
+ assert isinstance(response_message.body[1], PreserializedData)
+
+
+def validate_event_message(event_message: EventMessage, app_ids: AppIDs) -> None:
+ """call this method to validate event message"""
+
+ # check message ID is a UUID
+ assert isinstance(event_message.id, UUID) or isinstance(UUID(event_message.id, version=4), UUID)
+ # check message length
+ assert len(event_message.byte_array) == event_message.length
+ # no receiver id for event message, only event id
+ assert isinstance(event_message.event_id, str)
+ # sender_id is not set before sending message on the socket
+ assert event_message.sender_id == app_ids.server_id
+ # check that all indices are bytes
+ for obj in event_message.byte_array:
+ assert isinstance(obj, bytes)
+ # check that header is correct type
+ assert isinstance(event_message.header, EventHeader)
+ # check that body is correct type
+ assert isinstance(event_message.body, list)
+ assert len(event_message.body) == 2
+ assert isinstance(event_message.body[0], SerializableData)
+ assert isinstance(event_message.body[1], PreserializedData)
+
+
+def test_01_request_message(app_ids: AppIDs) -> None:
+ """test the request message"""
+
+ # request messages types are OPERATION, HANDSHAKE & EXIT
+ request_message = RequestMessage.craft_from_arguments(
+ receiver_id=app_ids.server_id,
+ sender_id=app_ids.client_id,
+ thing_id=app_ids.thing_id,
+ objekt="some_prop",
+ operation="readproperty",
+ )
+ validate_request_message(request_message, app_ids)
+ # check message type for the above craft_from_arguments method
+ assert request_message.type == OPERATION
+
+ request_message = RequestMessage.craft_with_message_type(
+ receiver_id=app_ids.server_id, sender_id=app_ids.client_id, message_type=HANDSHAKE
+ )
+ validate_request_message(request_message, app_ids)
+ # check message type for the above craft_with_message_type method
+ assert request_message.type == HANDSHAKE
+
+ request_message = RequestMessage.craft_with_message_type(
+ receiver_id=app_ids.server_id, sender_id=app_ids.client_id, message_type=EXIT
+ )
+ validate_request_message(request_message, app_ids)
+ # check message type for the above craft_with_message_type method
+ assert request_message.type == EXIT
+
+
+def test_02_response_message(app_ids: AppIDs) -> None:
+ """test the response message"""
+
+ # response messages types are HANDSHAKE, TIMEOUT, INVALID_MESSAGE, ERROR and REPLY
+ response_message = ResponseMessage.craft_from_arguments(
+ receiver_id=app_ids.client_id,
+ sender_id=app_ids.server_id,
+ message_type=HANDSHAKE,
+ message_id=uuid4(),
+ )
+ validate_response_message(response_message, app_ids)
+ # check message type for the above craft_with_message_type method
+ assert response_message.type == HANDSHAKE
+
+ response_message = ResponseMessage.craft_from_arguments(
+ receiver_id=app_ids.client_id,
+ sender_id=app_ids.server_id,
+ message_type=TIMEOUT,
+ message_id=uuid4(),
+ )
+ validate_response_message(response_message, app_ids)
+ # check message type for the above craft_with_message_type method
+ assert response_message.type == TIMEOUT
+
+ response_message = ResponseMessage.craft_from_arguments(
+ receiver_id=app_ids.client_id,
+ sender_id=app_ids.server_id,
+ message_type=INVALID_MESSAGE,
+ message_id=uuid4(),
+ )
+ validate_response_message(response_message, app_ids)
+ # check message type for the above craft_with_message_type method
+ assert response_message.type == INVALID_MESSAGE
+
+ response_message = ResponseMessage.craft_from_arguments(
+ receiver_id=app_ids.client_id,
+ sender_id=app_ids.server_id,
+ message_type=ERROR,
+ message_id=uuid4(),
+ payload=SerializableData(Exception("test")),
+ )
+ validate_response_message(response_message, app_ids)
+ assert response_message.type == ERROR
+ assert isinstance(Serializers.json.loads(response_message._bytes[2]), dict)
+
+ request_message = RequestMessage.craft_from_arguments(
+ sender_id=app_ids.client_id,
+ receiver_id=app_ids.server_id,
+ thing_id=app_ids.thing_id,
+ objekt="some_prop",
+ operation="readProperty",
+ )
+ request_message._sender_id = app_ids.client_id # will be done by craft_from_self
+ response_message = ResponseMessage.craft_reply_from_request(
+ request_message=request_message,
+ )
+ validate_response_message(response_message, app_ids)
+ assert response_message.type == REPLY
+ assert Serializers.json.loads(response_message._bytes[3]) is None # INDEX_BODY = 3
+ assert request_message.id == response_message.id
+
+
+def test_03_event_message(app_ids: AppIDs) -> None:
+ """test the event message"""
+ event_message = EventMessage.craft_from_arguments(
+ event_id="test-event",
+ sender_id=app_ids.server_id,
+ payload=SerializableData("test"),
+ preserialized_payload=PreserializedData(b"test"),
+ )
+ validate_event_message(event_message, app_ids)
diff --git a/tests/test_02_socket.py b/tests/test_02_socket.py
index 7a5c02e9..6dc21e61 100644
--- a/tests/test_02_socket.py
+++ b/tests/test_02_socket.py
@@ -1,251 +1,237 @@
-import unittest
+import pytest
import zmq.asyncio
-from hololinked.core.zmq.brokers import BaseZMQ
+from hololinked.config import global_config
from hololinked.constants import ZMQ_TRANSPORTS
+from hololinked.core.zmq.brokers import BaseZMQ
-try:
- from .utils import TestCase, TestRunner
-except ImportError:
- from utils import TestCase, TestRunner
-
-
-class TestSocket(TestCase):
- @classmethod
- def setUpClass(cls):
- super().setUpClass()
- print(f"test ZMQ socket creation with {cls.__name__}")
-
- def test_1_socket_creation_defaults(self):
- """check the default settings of socket creation - an IPC socket which is a ROUTER and async"""
- socket, socket_address = BaseZMQ.get_socket(
- server_id="test-server",
- socket_id="test-server",
- node_type="server",
- context=zmq.asyncio.Context(),
- )
- self.assertIsInstance(socket, zmq.asyncio.Socket)
- self.assertTrue(socket.getsockopt_string(zmq.IDENTITY) == "test-server")
- self.assertTrue(socket.socket_type == zmq.ROUTER)
- self.assertTrue(socket_address.startswith("ipc://"))
- self.assertTrue(socket_address.endswith(".ipc"))
- socket.close()
-
- def test_2_context_options(self):
- """
- Check that context and socket type are as expected.
- Async context should be used for async socket and sync context for sync socket.
- """
- context = zmq.Context()
- socket, _ = BaseZMQ.get_socket(
- server_id="test-server",
- socket_id="test-server",
- node_type="server",
- context=context,
- )
- self.assertTrue(isinstance(socket, zmq.Socket))
- self.assertTrue(not isinstance(socket, zmq.asyncio.Socket))
- socket.close()
- context.term()
-
- context = zmq.asyncio.Context()
- socket, _ = BaseZMQ.get_socket(
- server_id="test-server",
- socket_id="test-server",
- node_type="server",
- context=context,
- )
- self.assertTrue(isinstance(socket, zmq.Socket))
- self.assertTrue(isinstance(socket, zmq.asyncio.Socket))
- socket.close()
- context.term()
-
- def test_3_transport_options(self):
- """check only three transport options are supported"""
- context = zmq.asyncio.Context()
- socket, socket_address = BaseZMQ.get_socket(
- server_id="test-server",
- socket_id="test-server",
- node_type="server",
- context=context,
- access_point="tcp://*:5555",
- )
- for sock_addr in [socket_address, socket.getsockopt_string(zmq.LAST_ENDPOINT)]:
- self.assertTrue(sock_addr.startswith("tcp://"))
- self.assertTrue(sock_addr.endswith(":5555"))
- socket.close()
-
- socket, socket_address = BaseZMQ.get_socket(
- server_id="test-server",
- socket_id="test-server",
- node_type="server",
- context=context,
- access_point="IPC",
- )
-
- self.assertEqual(socket_address, socket.getsockopt_string(zmq.LAST_ENDPOINT))
- self.assertTrue(socket_address.startswith("ipc://"))
- self.assertTrue(socket_address.endswith(".ipc"))
- socket.close()
-
- socket, socket_address = BaseZMQ.get_socket(
- server_id="test-server",
- socket_id="test-server",
- node_type="server",
- context=context,
- access_point="INPROC",
- )
- self.assertEqual(socket_address, socket.getsockopt_string(zmq.LAST_ENDPOINT))
- self.assertTrue(socket_address.startswith("inproc://"))
- self.assertTrue(socket_address.endswith("test-server"))
- socket.close()
- context.term()
-
- # Specify transport as enum and do the same tests
- context = zmq.Context()
- socket, socket_address = BaseZMQ.get_socket(
- server_id="test-server",
- socket_id="test-server",
- node_type="server",
- context=context,
- access_point=ZMQ_TRANSPORTS.INPROC,
- )
- self.assertTrue(socket_address.startswith("inproc://"))
- self.assertTrue(socket_address.endswith("test-server"))
- socket.close()
-
- socket, socket_address = BaseZMQ.get_socket(
- server_id="test-server",
- socket_id="test-server",
- node_type="server",
- context=context,
- access_point=ZMQ_TRANSPORTS.IPC,
- )
- self.assertTrue(socket_address.startswith("ipc://"))
- self.assertTrue(socket_address.endswith(".ipc"))
- socket.close()
-
- socket, socket_address = BaseZMQ.get_socket(
- server_id="test-server",
- socket_id="test-server",
- node_type="server",
- context=context,
- access_point=ZMQ_TRANSPORTS.TCP,
- )
- self.assertTrue(socket_address.startswith("tcp://"))
- # Strip the port number from TCP address and check if it's a valid port integer
- host, port_str = socket_address.rsplit(":", 1)
- self.assertTrue(port_str.isdigit())
- self.assertTrue(0 < int(port_str) < 65536)
- socket.close()
- context.term()
-
- # check that other transport options raise error
- context = zmq.asyncio.Context()
- self.assertRaises(
- NotImplementedError,
- lambda: BaseZMQ.get_socket(
- server_id="test-server",
- socket_id="test-server",
- node_type="server",
- context=context,
- access_point="PUB",
- ),
- )
- context.term()
-
- def test_4_socket_options(self):
- """check that socket options are as expected"""
- context = zmq.asyncio.Context()
-
- socket, _ = BaseZMQ.get_socket(
- server_id="test-server",
- socket_id="test-server",
- node_type="server",
- context=context,
- socket_type=zmq.ROUTER,
- )
- self.assertTrue(socket.socket_type == zmq.ROUTER)
- self.assertTrue(socket.getsockopt_string(zmq.IDENTITY) == "test-server")
- self.assertTrue(isinstance(socket, zmq.asyncio.Socket))
- socket.close()
-
- socket, _ = BaseZMQ.get_socket(
- server_id="test-server",
- socket_id="test-server",
- node_type="server",
- context=context,
- socket_type=zmq.DEALER,
- )
- self.assertTrue(socket.socket_type == zmq.DEALER)
- self.assertTrue(socket.getsockopt_string(zmq.IDENTITY) == "test-server")
- self.assertTrue(isinstance(socket, zmq.asyncio.Socket))
- socket.close()
-
- socket, _ = BaseZMQ.get_socket(
- server_id="test-server",
- socket_id="test-server",
- node_type="server",
- context=context,
- socket_type=zmq.PUB,
- )
- self.assertTrue(socket.socket_type == zmq.PUB)
- self.assertTrue(socket.getsockopt_string(zmq.IDENTITY) == "test-server")
- self.assertTrue(isinstance(socket, zmq.asyncio.Socket))
- socket.close()
-
- socket, _ = BaseZMQ.get_socket(
- server_id="test-server",
- socket_id="test-server",
- node_type="server",
- context=context,
- socket_type=zmq.SUB,
- )
- self.assertTrue(socket.socket_type == zmq.SUB)
- self.assertTrue(socket.getsockopt_string(zmq.IDENTITY) == "test-server")
- self.assertTrue(isinstance(socket, zmq.asyncio.Socket))
- socket.close()
-
- socket, _ = BaseZMQ.get_socket(
- server_id="test-server",
- socket_id="test-server",
- node_type="server",
- context=context,
- socket_type=zmq.PAIR,
- )
- self.assertTrue(socket.socket_type == zmq.PAIR)
- self.assertTrue(socket.getsockopt_string(zmq.IDENTITY) == "test-server")
- self.assertTrue(isinstance(socket, zmq.asyncio.Socket))
- socket.close()
-
- socket, _ = BaseZMQ.get_socket(
- server_id="test-server",
- socket_id="test-server",
- node_type="server",
- context=context,
- socket_type=zmq.PUSH,
- )
- self.assertTrue(socket.socket_type == zmq.PUSH)
- self.assertTrue(socket.getsockopt_string(zmq.IDENTITY) == "test-server")
- self.assertTrue(isinstance(socket, zmq.asyncio.Socket))
- socket.close()
- socket, _ = BaseZMQ.get_socket(
+def test_01_socket_creation_defaults():
+ """check the default settings of socket creation - an IPC socket which is a ROUTER and async"""
+ socket, socket_address = BaseZMQ.get_socket(
+ server_id="test-server",
+ socket_id="test-server",
+ node_type="server",
+ context=global_config.zmq_context(),
+ )
+ assert isinstance(socket, zmq.asyncio.Socket)
+ assert socket.getsockopt_string(zmq.IDENTITY) == "test-server"
+ assert socket.socket_type == zmq.ROUTER
+ assert socket_address.startswith("ipc://")
+ assert socket_address.endswith(".ipc")
+ socket.close()
+
+
+def test_02_context_options():
+ """
+ Check that context and socket type are as expected.
+ Async context should be used for async socket and sync context for sync socket.
+ """
+ context = zmq.Context()
+ socket, _ = BaseZMQ.get_socket(
+ server_id="test-server",
+ socket_id="test-server",
+ node_type="server",
+ context=context,
+ )
+ assert isinstance(socket, zmq.Socket)
+ assert not isinstance(socket, zmq.asyncio.Socket)
+ socket.close()
+ context.term()
+
+ context = zmq.asyncio.Context()
+ socket, _ = BaseZMQ.get_socket(
+ server_id="test-server",
+ socket_id="test-server",
+ node_type="server",
+ context=context,
+ )
+ assert isinstance(socket, zmq.Socket)
+ assert isinstance(socket, zmq.asyncio.Socket)
+ socket.close()
+ context.term()
+
+
+def test_03_transport_options():
+ """check only three transport options are supported"""
+ context = zmq.asyncio.Context()
+ socket, socket_address = BaseZMQ.get_socket(
+ server_id="test-server",
+ socket_id="test-server",
+ node_type="server",
+ context=context,
+ access_point="tcp://*:5555",
+ )
+ for sock_addr in [socket_address, socket.getsockopt_string(zmq.LAST_ENDPOINT)]:
+ assert sock_addr.startswith("tcp://")
+ assert sock_addr.endswith(":5555")
+ socket.close()
+
+ socket, socket_address = BaseZMQ.get_socket(
+ server_id="test-server",
+ socket_id="test-server",
+ node_type="server",
+ context=context,
+ access_point="IPC",
+ )
+
+ assert socket_address == socket.getsockopt_string(zmq.LAST_ENDPOINT)
+ assert socket_address.startswith("ipc://")
+ assert socket_address.endswith(".ipc")
+ socket.close()
+
+ socket, socket_address = BaseZMQ.get_socket(
+ server_id="test-server",
+ socket_id="test-server",
+ node_type="server",
+ context=context,
+ access_point="INPROC",
+ )
+ assert socket_address == socket.getsockopt_string(zmq.LAST_ENDPOINT)
+ assert socket_address.startswith("inproc://")
+ assert socket_address.endswith("test-server")
+ socket.close()
+ context.term()
+
+ # Specify transport as enum and do the same tests
+ context = zmq.Context()
+ socket, socket_address = BaseZMQ.get_socket(
+ server_id="test-server",
+ socket_id="test-server",
+ node_type="server",
+ context=context,
+ access_point=ZMQ_TRANSPORTS.INPROC,
+ )
+ assert socket_address.startswith("inproc://")
+ assert socket_address.endswith("test-server")
+ socket.close()
+
+ socket, socket_address = BaseZMQ.get_socket(
+ server_id="test-server",
+ socket_id="test-server",
+ node_type="server",
+ context=context,
+ access_point=ZMQ_TRANSPORTS.IPC,
+ )
+ assert socket_address.startswith("ipc://")
+ assert socket_address.endswith(".ipc")
+ socket.close()
+
+ socket, socket_address = BaseZMQ.get_socket(
+ server_id="test-server",
+ socket_id="test-server",
+ node_type="server",
+ context=context,
+ access_point=ZMQ_TRANSPORTS.TCP,
+ )
+ assert socket_address.startswith("tcp://")
+ # Strip the port number from TCP address and check if it's a valid port integer
+ host, port_str = socket_address.rsplit(":", 1)
+ assert port_str.isdigit()
+ assert 0 < int(port_str) < 65536
+ socket.close()
+ context.term()
+
+ # check that other transport options raise error
+ context = zmq.asyncio.Context()
+ with pytest.raises(NotImplementedError):
+ BaseZMQ.get_socket(
server_id="test-server",
socket_id="test-server",
node_type="server",
context=context,
- socket_type=zmq.PULL,
- )
- self.assertTrue(socket.socket_type == zmq.PULL)
- self.assertTrue(socket.getsockopt_string(zmq.IDENTITY) == "test-server")
- self.assertTrue(isinstance(socket, zmq.asyncio.Socket))
- socket.close()
- context.term()
-
-
-if __name__ == "__main__":
- unittest.main(testRunner=TestRunner())
+ access_point="PUB",
+ )
+
+
+def test_04_socket_options():
+ """check that socket options are as expected"""
+ context = zmq.asyncio.Context()
+
+ socket, _ = BaseZMQ.get_socket(
+ server_id="test-server",
+ socket_id="test-server",
+ node_type="server",
+ context=context,
+ socket_type=zmq.ROUTER,
+ )
+ assert socket.socket_type == zmq.ROUTER
+ assert socket.getsockopt_string(zmq.IDENTITY) == "test-server"
+ assert isinstance(socket, zmq.asyncio.Socket)
+ socket.close()
+
+ socket, _ = BaseZMQ.get_socket(
+ server_id="test-server",
+ socket_id="test-server",
+ node_type="server",
+ context=context,
+ socket_type=zmq.DEALER,
+ )
+ assert socket.socket_type == zmq.DEALER
+ assert socket.getsockopt_string(zmq.IDENTITY) == "test-server"
+ assert isinstance(socket, zmq.asyncio.Socket)
+ socket.close()
+
+ socket, _ = BaseZMQ.get_socket(
+ server_id="test-server",
+ socket_id="test-server",
+ node_type="server",
+ context=context,
+ socket_type=zmq.PUB,
+ )
+ assert socket.socket_type == zmq.PUB
+ assert socket.getsockopt_string(zmq.IDENTITY) == "test-server"
+ assert isinstance(socket, zmq.asyncio.Socket)
+ socket.close()
+
+ socket, _ = BaseZMQ.get_socket(
+ server_id="test-server",
+ socket_id="test-server",
+ node_type="server",
+ context=context,
+ socket_type=zmq.SUB,
+ )
+ assert socket.socket_type == zmq.SUB
+ assert socket.getsockopt_string(zmq.IDENTITY) == "test-server"
+ assert isinstance(socket, zmq.asyncio.Socket)
+ socket.close()
+
+ socket, _ = BaseZMQ.get_socket(
+ server_id="test-server",
+ socket_id="test-server",
+ node_type="server",
+ context=context,
+ socket_type=zmq.PAIR,
+ )
+ assert socket.socket_type == zmq.PAIR
+ assert socket.getsockopt_string(zmq.IDENTITY) == "test-server"
+ assert isinstance(socket, zmq.asyncio.Socket)
+ socket.close()
+
+ socket, _ = BaseZMQ.get_socket(
+ server_id="test-server",
+ socket_id="test-server",
+ node_type="server",
+ context=context,
+ socket_type=zmq.PUSH,
+ )
+ assert socket.socket_type == zmq.PUSH
+ assert socket.getsockopt_string(zmq.IDENTITY) == "test-server"
+ assert isinstance(socket, zmq.asyncio.Socket)
+ socket.close()
+
+ socket, _ = BaseZMQ.get_socket(
+ server_id="test-server",
+ socket_id="test-server",
+ node_type="server",
+ context=context,
+ socket_type=zmq.PULL,
+ )
+ assert socket.socket_type == zmq.PULL
+ assert socket.getsockopt_string(zmq.IDENTITY) == "test-server"
+ assert isinstance(socket, zmq.asyncio.Socket)
+ socket.close()
+ context.term()
"""
diff --git a/tests/test_03_serializers.py b/tests/test_03_serializers.py
index 0ff24a96..bbaf350c 100644
--- a/tests/test_03_serializers.py
+++ b/tests/test_03_serializers.py
@@ -1,182 +1,145 @@
-import unittest
+import pytest
+
+from things import TestThing
from hololinked.serializers import Serializers
from hololinked.serializers.serializers import BaseSerializer
-try:
- from .utils import TestRunner, TestCase
- from .things import TestThing
-except ImportError:
- from utils import TestRunner, TestCase
- from things import TestThing
+class YAMLSerializer(BaseSerializer):
+ """just a dummy, does not really serialize to YAML"""
+
+ @property
+ def content_type(self):
+ return "application/yaml"
-class TestSerializer(TestCase):
- """Test the Serializers class"""
+@pytest.fixture(scope="module")
+def yaml_serializer() -> BaseSerializer:
# test register a new serializer with content type
- class YAMLSerializer(BaseSerializer):
- """just a dummy, does not really serialize to YAML"""
-
- @property
- def content_type(self):
- return "application/yaml"
-
- def test_1_singleton(self):
- """Test the singleton nature of the Serializers class."""
-
- serializers = Serializers()
- self.assertEqual(serializers, Serializers())
- self.assertNotEqual(Serializers, Serializers())
- self.assertIsInstance(serializers, Serializers)
- # all are class attributes
- self.assertEqual(serializers.json, Serializers.json)
- self.assertEqual(serializers.pickle, Serializers.pickle)
- self.assertEqual(serializers.msgpack, Serializers.msgpack)
- self.assertEqual(serializers.content_types, Serializers.content_types)
- self.assertEqual(serializers.object_content_type_map, Serializers.object_content_type_map)
- self.assertEqual(serializers.object_serializer_map, Serializers.object_serializer_map)
- self.assertEqual(serializers.protocol_serializer_map, Serializers.protocol_serializer_map)
- # check existing serializers are all instances of BaseSerializer
- for name, serializer in Serializers.content_types.items():
- self.assertIsInstance(serializer, BaseSerializer)
- # check default serializer, given that we know its JSON at least for the current test
- self.assertEqual(serializers.default, Serializers.json)
- self.assertEqual(serializers.default, Serializers.default)
- self.assertEqual(serializers.default, Serializers().json)
- self.assertEqual(serializers.default, Serializers().default)
- # check default content type, given that we know its JSON at least for the current test
- self.assertEqual(serializers.default_content_type, Serializers.json.content_type)
- # change default to pickle and check if it is set correctly
- # serializers.default = serializers.pickle
- # self.assertEqual(serializers.default, Serializers.pickle)
- # self.assertEqual(Serializers().default, Serializers.pickle)
-
- def test_2_protocol_registration(self):
- """i.e. test if a new serializer (protocol) can be registered"""
-
- # get existing number of serializers
- num_serializers = len(Serializers.content_types)
-
- # test register a new serializer
- base_serializer = BaseSerializer()
- # register with name
- self.assertWarns(UserWarning, Serializers.register, base_serializer, "base")
- # user warning because content type property is not defined
- # above is same as Serializers.register(base_serializer, 'base')
-
- # check if name became a class attribute and name can be accessed as an attribute
- self.assertIn("base", Serializers)
- self.assertEqual(Serializers.base, base_serializer)
- self.assertEqual(Serializers().base, base_serializer)
- # we dont support getitem at instance level yet so we cannot test assertIn
-
- # since a content type is not set, it should not be in the content types
- self.assertNotIn(base_serializer, Serializers.content_types.values())
- # so the length of content types should be the same
- self.assertEqual(len(Serializers.content_types), num_serializers)
-
- # instantiate
- yaml_serializer = self.YAMLSerializer()
- # register with name
- Serializers.register(yaml_serializer, "yaml")
- # check if name became a class attribute and name can be accessed as an attribute
- self.assertIn("yaml", Serializers)
- self.assertEqual(Serializers.yaml, yaml_serializer)
- self.assertEqual(Serializers().yaml, yaml_serializer)
- # we dont support getitem at instance level yet
-
- # since a content type is set, it should be in the content types
- self.assertIn(yaml_serializer.content_type, Serializers.content_types.keys())
- self.assertIn(yaml_serializer, Serializers.content_types.values())
- # so the length of content types should have increased by 1
- self.assertEqual(len(Serializers.content_types), num_serializers + 1)
-
- def test_3_registration_for_objects(self):
- """i.e. test if a new serializer can be registered for a specific property, action or event"""
- Serializers.register_content_type_for_object(TestThing.base_property, "application/x-pickle")
- Serializers.register_content_type_for_object(TestThing.action_echo, "application/msgpack")
- Serializers.register_content_type_for_object(TestThing.test_event, "application/yaml")
-
- self.assertEqual(
- Serializers.for_object(None, "TestThing", "action_echo"),
- Serializers.msgpack,
- )
- self.assertEqual(
- Serializers.for_object(None, "TestThing", "base_property"),
- Serializers.pickle,
- )
- self.assertEqual(Serializers.for_object(None, "TestThing", "test_event"), Serializers.yaml)
- self.assertEqual(
- Serializers.for_object(None, "TestThing", "test_unknown_property"),
- Serializers.default,
- )
-
- def test_4_registration_for_objects_by_name(self):
- Serializers.register_content_type_for_object_per_thing_instance(
- "test_thing", "base_property", "application/yaml"
- )
- self.assertIsInstance(
- Serializers.for_object("test_thing", None, "base_property"),
- self.YAMLSerializer,
- )
-
- def test_5_registration_dict(self):
- """test the dictionary where all serializers are stored"""
- # depends on test 3
- self.assertIn("test_thing", Serializers.object_content_type_map)
- self.assertIn("base_property", Serializers.object_content_type_map["test_thing"])
- self.assertEqual(
- Serializers.object_content_type_map["test_thing"]["base_property"],
- "application/yaml",
- )
-
- self.assertIn("action_echo", Serializers.object_content_type_map["TestThing"])
- self.assertEqual(
- Serializers.object_content_type_map["TestThing"]["action_echo"],
- "application/msgpack",
- )
- self.assertIn("test_event", Serializers.object_content_type_map["TestThing"])
- self.assertEqual(
- Serializers.object_content_type_map["TestThing"]["test_event"],
- "application/yaml",
- )
-
- def test_6_retrieval(self):
- # added in previous tests
- self.assertIsInstance(
- Serializers.for_object("test_thing", None, "base_property"),
- self.YAMLSerializer,
- )
- # unknown object should retrieve the default serializer
- self.assertEqual(
- Serializers.for_object("test_thing", None, "test_unknown_property"),
- Serializers.default,
- )
- # unknown thing should retrieve the default serializer
- self.assertEqual(
- Serializers.for_object("test_unknown_thing", None, "base_property"),
- Serializers.default,
- )
-
- def test_7_set_default(self):
- """test setting the default serializer"""
- # get existing default
- old_default = Serializers.default
- # set new default and check if default is set
- Serializers.default = Serializers.yaml
- self.assertEqual(Serializers.default, Serializers.yaml)
- self.test_6_retrieval() # check if retrieval is consistent with default
- # reset default and check if default is reset
- Serializers.default = old_default
- self.assertEqual(Serializers.default, old_default)
- self.assertEqual(Serializers.default, Serializers.json) # because we know its JSON
-
- @classmethod
- def tearDownClass(cls):
- Serializers.reset()
- return super().tearDownClass()
-
-
-if __name__ == "__main__":
- unittest.main(testRunner=TestRunner())
+ return YAMLSerializer()
+
+
+def test_01_singleton():
+ """Test the singleton nature of the Serializers class."""
+
+ serializers = Serializers()
+ assert serializers == Serializers()
+ assert Serializers != Serializers()
+ assert isinstance(serializers, Serializers)
+ # all are class attributes
+ assert serializers.json == Serializers.json
+ assert serializers.pickle == Serializers.pickle
+ assert serializers.msgpack == Serializers.msgpack
+ assert serializers.content_types == Serializers.content_types
+ assert serializers.object_content_type_map == Serializers.object_content_type_map
+ assert serializers.object_serializer_map == Serializers.object_serializer_map
+ assert serializers.protocol_serializer_map == Serializers.protocol_serializer_map
+ # check existing serializers are all instances of BaseSerializer
+ for name, serializer in Serializers.content_types.items():
+ assert isinstance(serializer, BaseSerializer)
+ # check default serializer, given that we know its JSON at least for the current test
+ assert serializers.default == Serializers.json
+ assert serializers.default == Serializers.default
+ assert serializers.default == Serializers().json
+ assert serializers.default == Serializers().default
+ # check default content type, given that we know its JSON at least for the current test
+ assert serializers.default_content_type == Serializers.json.content_type
+ # change default to pickle and check if it is set correctly
+ # serializers.default = serializers.pickle
+ # self.assertEqual(serializers.default, Serializers.pickle)
+ # self.assertEqual(Serializers().default, Serializers.pickle)
+
+
+def test_02_protocol_registration(yaml_serializer: BaseSerializer):
+ """i.e. test if a new serializer (protocol) can be registered"""
+
+ # get existing number of serializers
+ num_serializers = len(Serializers.content_types)
+
+ # test register a new serializer
+ base_serializer = BaseSerializer()
+ # register with name
+ with pytest.warns(UserWarning):
+ Serializers.register(base_serializer, "base")
+ # user warning because content type property is not defined
+ # above is same as Serializers.register(base_serializer, 'base')
+
+ # check if name became a class attribute and name can be accessed as an attribute
+ assert "base" in Serializers
+ assert Serializers.base == base_serializer
+ assert Serializers().base == base_serializer
+ # we dont support getitem at instance level yet so we cannot test assertIn
+
+ # since a content type is not set, it should not be in the content types
+ assert base_serializer not in Serializers.content_types.values()
+ # so the length of content types should be the same
+ assert len(Serializers.content_types) == num_serializers
+
+ # register with name
+ Serializers.register(yaml_serializer, "yaml")
+ # check if name became a class attribute and name can be accessed as an attribute
+ assert "yaml" in Serializers
+ assert Serializers.yaml == yaml_serializer
+ assert Serializers().yaml == yaml_serializer
+ # we dont support getitem at instance level yet
+
+ # since a content type is set, it should be in the content types
+ assert yaml_serializer.content_type in Serializers.content_types.keys()
+ assert yaml_serializer in Serializers.content_types.values()
+ # so the length of content types should have increased by 1
+ assert len(Serializers.content_types) == num_serializers + 1
+
+
+def test_03_registration_for_objects():
+ """i.e. test if a new serializer can be registered for a specific property, action or event"""
+ Serializers.register_content_type_for_object(TestThing.base_property, "application/x-pickle")
+ Serializers.register_content_type_for_object(TestThing.action_echo, "application/msgpack")
+ Serializers.register_content_type_for_object(TestThing.test_event, "application/yaml")
+
+ assert Serializers.for_object(None, "TestThing", "action_echo") == Serializers.msgpack
+ assert Serializers.for_object(None, "TestThing", "base_property") == Serializers.pickle
+ assert Serializers.for_object(None, "TestThing", "test_event") == Serializers.yaml
+ assert Serializers.for_object(None, "TestThing", "test_unknown_property") == Serializers.default
+
+
+def test_04_registration_for_objects_by_name():
+ Serializers.register_content_type_for_object_per_thing_instance("test_thing", "base_property", "application/yaml")
+ assert isinstance(Serializers.for_object("test_thing", None, "base_property"), YAMLSerializer)
+
+
+def test_05_registration_dict():
+ """test the dictionary where all serializers are stored"""
+ # depends on test 3
+ assert "test_thing" in Serializers.object_content_type_map
+ assert "base_property" in Serializers.object_content_type_map["test_thing"]
+ assert Serializers.object_content_type_map["test_thing"]["base_property"] == "application/yaml"
+ assert Serializers.object_content_type_map["test_thing"]["base_property"] == "application/yaml"
+
+ assert "action_echo" in Serializers.object_content_type_map["TestThing"]
+ assert Serializers.object_content_type_map["TestThing"]["action_echo"] == "application/msgpack"
+ assert "test_event" in Serializers.object_content_type_map["TestThing"]
+ assert Serializers.object_content_type_map["TestThing"]["test_event"] == "application/yaml"
+
+
+def test_06_retrieval():
+ # added in previous tests
+ assert isinstance(Serializers.for_object("test_thing", None, "base_property"), YAMLSerializer)
+ # unknown object should retrieve the default serializer
+ assert Serializers.for_object("test_thing", None, "test_unknown_property") == Serializers.default
+ # unknown thing should retrieve the default serializer
+ assert Serializers.for_object("test_unknown_thing", None, "base_property") == Serializers.default
+
+
+def test_07_set_default():
+ """test setting the default serializer"""
+ # get existing default
+ old_default = Serializers.default
+ # set new default and check if default is set
+ Serializers.default = Serializers.yaml
+ assert Serializers.default == Serializers.yaml
+ test_06_retrieval() # check if retrieval is consistent with default
+ # reset default and check if default is reset
+ Serializers.default = old_default
+ assert Serializers.default == old_default
+ assert Serializers.default == Serializers.json # because we know its JSON
diff --git a/tests/test_04_thing_init.py b/tests/test_04_thing_init.py
index 7cbb6ab9..460dfb36 100644
--- a/tests/test_04_thing_init.py
+++ b/tests/test_04_thing_init.py
@@ -1,30 +1,24 @@
-import typing
-import unittest
-import logging
+from typing import Any
+import pytest
+
+from things import OceanOpticsSpectrometer
+
+from hololinked.core import Action, Event, Property, Thing, ThingMeta
from hololinked.core.actions import BoundAction
from hololinked.core.events import EventDispatcher
-from hololinked.core.zmq.brokers import EventPublisher
-from hololinked.core.zmq.rpc_server import RPCServer
-from hololinked.core import Thing, ThingMeta, Action, Event, Property
+from hololinked.core.logger import RemoteAccessHandler
from hololinked.core.meta import (
- DescriptorRegistry, # noqa: F401
- PropertiesRegistry,
ActionsRegistry,
+ DescriptorRegistry, # noqa: F401
EventsRegistry,
+ PropertiesRegistry,
)
-from hololinked.core.properties import Parameter
+from hololinked.core.properties import Parameter # noqa: F401
from hololinked.core.state_machine import BoundFSM
+from hololinked.core.zmq.brokers import EventPublisher
+from hololinked.core.zmq.rpc_server import RPCServer
from hololinked.utils import get_default_logger
-from hololinked.core.logger import RemoteAccessHandler
-from hololinked.logger import setup_logging
-
-try:
- from .things import OceanOpticsSpectrometer
- from .utils import TestCase, TestRunner
-except ImportError:
- from things import OceanOpticsSpectrometer
- from utils import TestCase, TestRunner
"""
@@ -35,789 +29,694 @@
4. Test ActionRegistry class
5. Test EventRegistry class
6. Test PropertiesRegistry class
-"""
-setup_logging(logging.WARN)
-
-
-class TestThingInit(TestCase):
- """Test Thing class which is the bread and butter of this package."""
-
- @classmethod
- def setUpClass(cls):
- super().setUpClass()
- print(f"test Thing instantiation with {cls.__name__}")
- cls.thing_cls = Thing
- # using a variable called thing_cls because same tests are repeated for different thing class
-
- """
- Test sequence is as follows:
- 1. Test id requirements
- 2. Test logger setup
- 3. Test state and state_machine setup
- 4. Test composition of subthings
- 5. Test servers init
- 6. Test thing model generation
- """
-
- def test_1_id(self):
- """Test id property of Thing class"""
- # req. 1. instance name must be a string and cannot be changed after set
- thing = self.thing_cls(id="test_id")
- self.assertEqual(thing.id, "test_id")
- with self.assertRaises(ValueError):
- thing.id = "new_instance"
- with self.assertRaises(NotImplementedError):
- del thing.id
- # req. 2. regex is r'[A-Za-z]+[A-Za-z_0-9\-\/]*', simple URI like
- valid_ids = ["test_id", "A123", "valid_id-123", "another/valid-id"]
- invalid_ids = ["123_invalid", "invalid id", "invalid@id", ""]
- for valid_id in valid_ids:
- thing.properties.descriptors["id"].validate_and_adapt(valid_id)
- for invalid_id in invalid_ids:
- with self.assertRaises(ValueError):
- thing.properties.descriptors["id"].validate_and_adapt(invalid_id)
-
- def notest_2_logger(self):
- """Test logger setup"""
- # This test will no longer work and needs to rewritten
- # req. 1. logger must have remote access handler if remote_accessible_logger is True
- logger = get_default_logger("test_logger")
- thing = self.thing_cls(
- id="test_remote_accessible_logger",
- logger=logger,
- remote_accessible_logger=True,
- )
- self.assertEqual(thing.logger, logger)
- self.assertTrue(any(isinstance(handler, RemoteAccessHandler) for handler in thing.logger.handlers))
- # Therefore also check the false condition
- logger = get_default_logger("test_logger_2")
- thing = self.thing_cls(
- id="test_logger_without_remote_access",
- logger=logger,
- remote_accessible_logger=False,
- )
- self.assertFalse(any(isinstance(handler, RemoteAccessHandler) for handler in thing.logger.handlers))
- # NOTE - logger is modifiable after instantiation
-
- # req. 2. logger is created automatically if not provided
- thing = self.thing_cls(id="test_logger_auto_creation")
- self.assertIsNotNone(thing.logger)
- self.assertFalse(any(isinstance(handler, RemoteAccessHandler) for handler in thing.logger.handlers))
- self.assertNotEqual(thing.logger, logger) # not the above logger that we used.
- # remote accessible only when we ask for it
- thing = self.thing_cls(id="test_logger_auto_creation_2", remote_accessible_logger=True)
- self.assertIsNotNone(thing.logger)
- self.assertTrue(any(isinstance(handler, RemoteAccessHandler) for handler in thing.logger.handlers))
- self.assertNotEqual(thing.logger, logger)
-
- def test_3_state(self):
- """Test state and state_machine setup"""
- # req. 1. state property must be None when no state machine is present
- thing1 = self.thing_cls(id="test_no_state_machine")
- self.assertIsNone(thing1.state)
- self.assertIsNone(thing1.state_machine)
- # detailed checks in another file
-
- def test_4_subthings(self):
- """Test composition"""
- thing = self.thing_cls(id="test_subthings", remote_accessible_logger=True)
- # req. 1. subthings must be a dictionary
- self.assertIsInstance(thing.sub_things, dict)
- self.assertEqual(len(thing.sub_things), 1) # logger
- # req. 2. subthings are always recomputed when accessed (at least thats the way it is right now),
- # so we can add new subthings anytime
- thing.another_thing = OceanOpticsSpectrometer(id="another_thing")
- self.assertIsInstance(thing.sub_things, dict)
- self.assertEqual(len(thing.sub_things), 2)
- # req. 3. subthings must be instances of Thing and have the parent as owner
- for name, subthing in thing.sub_things.items():
- self.assertTrue(thing in subthing._owners)
- self.assertIsInstance(subthing, Thing)
- # req. 4. name of subthing must match name of the attribute
- self.assertTrue(hasattr(thing, name))
-
- def test_5_servers_init(self):
- """Test if servers can be initialized/instantiated"""
- # req. 1. rpc_server and event_publisher must be None when not run()
- thing = self.thing_cls(id="test_servers_init")
- self.assertIsNone(thing.rpc_server)
- self.assertIsNone(thing.event_publisher)
- # req. 2. rpc_server and event_publisher must be instances of their respective classes when run()
- RPCServer(id="test-rpc-server-init", things=[thing], logger=thing.logger) # prepare server class
- self.assertIsInstance(thing.rpc_server, RPCServer)
- self.assertIsInstance(thing.event_publisher, EventPublisher)
- # exit to quit nicely
- thing.rpc_server.exit()
- thing.event_publisher.exit()
-
-
-class TestOceanOpticsSpectrometer(TestThingInit):
- """test Thing subclass example"""
-
- @classmethod
- def setUpClass(cls):
- super().setUpClass()
- cls.thing_cls = OceanOpticsSpectrometer
-
- # check docs of the parent class for the test sequence
-
- def test_3_state(self):
- """Test state and state_machine setup"""
- thing1 = self.thing_cls(id="test_state_machine")
- # req. 1. state and state machine must be present because we create this subclass with a state machine
- self.assertIsNotNone(thing1.state)
- self.assertIsInstance(thing1.state_machine, BoundFSM)
- # req. 2. state and state machine must be different for different instances
- thing2 = self.thing_cls(id="test_state_machine_2")
- # first check if state machine exists
- self.assertIsNotNone(thing2.state)
- self.assertIsInstance(thing2.state_machine, BoundFSM)
- # then check if they are different
- self.assertNotEqual(thing1.state_machine, thing2.state_machine)
- # until state is set, initial state is equal
- self.assertEqual(thing1.state, thing2.state)
- self.assertEqual(thing1.state_machine.initial_state, thing2.state_machine.initial_state)
- # after state is set, they are different
- thing1.state_machine.set_state(thing1.states.ALARM)
- self.assertNotEqual(thing1.state, thing2.state)
- self.assertNotEqual(thing1.state_machine, thing2.state_machine)
- # initial state is still same
- self.assertEqual(thing1.state_machine.initial_state, thing2.state_machine.initial_state)
-
-
-class TestMetaclass(TestCase):
- """Test ThingMeta metaclass which instantiates a Thing (sub-)class"""
-
- @classmethod
- def setUpClass(cls):
- super().setUpClass()
- print(f"test ThingMeta with {cls.__name__}")
-
- """
- Test sequence is as follows:
- 1. Test metaclass of Thing class
- 2. Test registry creation and access which is currently the main purpose of the metaclass
- """
-
- def test_1_metaclass(self):
- """test metaclass of Thing class"""
- # req. 1 metaclass must be ThingMeta of any Thing class
- self.assertEqual(Thing.__class__, ThingMeta)
- self.assertEqual(OceanOpticsSpectrometer.__class__, ThingMeta)
- self.assertEqual(Thing.__class__, OceanOpticsSpectrometer.__class__)
-
- def test_2_registry_creation(self):
- """test registry creation and access which is currently the main purpose of the metaclass"""
- # req. 1. registry attributes must be instances of their respective classes
- self.assertIsInstance(Thing.properties, PropertiesRegistry)
- self.assertIsInstance(Thing.actions, ActionsRegistry)
- self.assertIsInstance(Thing.events, EventsRegistry)
-
- # req. 2. new registries are not created on the fly and are same between accesses
- self.assertEqual(Thing.properties, Thing.properties)
- self.assertEqual(Thing.actions, Thing.actions)
- self.assertEqual(Thing.events, Thing.events)
- # This test is done as the implementation deviates from `param`
-
- # req. 3. different subclasses have different registries
- self.assertNotEqual(Thing.properties, OceanOpticsSpectrometer.properties)
- self.assertNotEqual(Thing.actions, OceanOpticsSpectrometer.actions)
- self.assertNotEqual(Thing.events, OceanOpticsSpectrometer.events)
-
- # create instances for further tests
- thing = Thing(id="test_registry_creation")
- spectrometer = OceanOpticsSpectrometer(id="test_registry_creation_2")
-
- # req. 4. registry attributes must be instances of their respective classes also for instances
- self.assertIsInstance(thing.properties, PropertiesRegistry)
- self.assertIsInstance(thing.actions, ActionsRegistry)
- self.assertIsInstance(thing.events, EventsRegistry)
-
- # req. 5. registries are not created on the fly and are same between accesses also for instances
- self.assertEqual(thing.properties, thing.properties)
- self.assertEqual(thing.actions, thing.actions)
- self.assertEqual(thing.events, thing.events)
-
- # req. 6. registries are not shared between instances
- self.assertNotEqual(thing.properties, spectrometer.properties)
- self.assertNotEqual(thing.actions, spectrometer.actions)
- self.assertNotEqual(thing.events, spectrometer.events)
-
- # req. 7. registries are not shared between instances and their classes
- self.assertNotEqual(thing.properties, Thing.properties)
- self.assertNotEqual(thing.actions, Thing.actions)
- self.assertNotEqual(thing.events, Thing.events)
- self.assertNotEqual(spectrometer.properties, OceanOpticsSpectrometer.properties)
- self.assertNotEqual(spectrometer.actions, OceanOpticsSpectrometer.actions)
- self.assertNotEqual(spectrometer.events, OceanOpticsSpectrometer.events)
-
-
-# Uncomment the following for type hints while coding registry tests,
-# comment it before testing, otherwise tests will fail due to overriding Thing object
-# class Thing(Thing):
-# class_registry: PropertiesRegistry | ActionsRegistry | EventsRegistry
-# instance_registry: PropertiesRegistry | ActionsRegistry | EventsRegistry | None
-# descriptor_object: type[Property | Action | Event]
-
-# class OceanOpticsSpectrometer(OceanOpticsSpectrometer):
-# class_registry: PropertiesRegistry | ActionsRegistry | EventsRegistry
-# instance_registry: PropertiesRegistry | ActionsRegistry | EventsRegistry | None
-# descriptor_object: type[Property | Action | Event]
-
-
-class TestRegistry(TestCase):
- # Read the commented section above before proceeding to this test
-
- @classmethod
- def setUpClass(cls):
- super().setUpClass()
- cls.setUpRegistryObjects()
- cls.setUpRegistryAttributes()
- if cls.is_abstract_test_class:
- return
- print(f"test {cls.registry_cls.__name__} with {cls.__name__}")
-
- @classmethod
- def setUpRegistryObjects(cls):
- cls.registry_cls = None # type: DescriptorRegistry | None
- cls.registry_object = None # type: type[Property | Action | Event]
-
- @property
- def is_abstract_test_class(self):
- # if self.registry_cls is None:
- # print("registry_cls is None")
- # if self.registry_object is None:
- # print("registry_object is None")
- return self.registry_cls is None or self.registry_object is None
-
- @classmethod
- def setUpRegistryAttributes(cls):
- if cls.registry_cls is None or cls.registry_object is None:
- return
-
- # create instances for further tests
- cls.thing = Thing(id=f"test_{cls.registry_object.__name__}_registry")
- cls.spectrometer = OceanOpticsSpectrometer(id=f"test_{cls.registry_object.__name__}_registry")
- if cls.registry_cls == ActionsRegistry:
- Thing.class_registry = Thing.actions
- OceanOpticsSpectrometer.class_registry = OceanOpticsSpectrometer.actions
- cls.thing.instance_registry = cls.thing.actions
- cls.spectrometer.instance_registry = cls.spectrometer.actions
- cls.bound_object = BoundAction
- elif cls.registry_cls == PropertiesRegistry:
- Thing.class_registry = Thing.properties
- OceanOpticsSpectrometer.class_registry = OceanOpticsSpectrometer.properties
- cls.thing.instance_registry = cls.thing.properties
- cls.spectrometer.instance_registry = cls.spectrometer.properties
- cls.bound_object = typing.Any
- elif cls.registry_cls == EventsRegistry:
- Thing.class_registry = Thing.events
- OceanOpticsSpectrometer.class_registry = OceanOpticsSpectrometer.events
- cls.thing.instance_registry = cls.thing.events
- cls.spectrometer.instance_registry = cls.spectrometer.events
- cls.bound_object = EventDispatcher
- else:
- raise NotImplementedError("This registry class is not implemented")
-
- """
- Test action registry first because actions are the easiest to test.
- 1. Test owner attribute
- 2. Test descriptors access
- 3. Test dunders
- """
-
- def test_1_owner(self):
- """Test owner attribute of DescriptorRegistry"""
- if self.is_abstract_test_class:
- return
- # See comment above TestRegistry class to enable type definitions
- # req. 1. owner attribute must be the class itself when accessed as class attribute
- self.assertEqual(Thing.class_registry.owner, Thing)
- self.assertEqual(OceanOpticsSpectrometer.class_registry.owner, OceanOpticsSpectrometer)
- # therefore owner instance must be None
- self.assertIsNone(Thing.class_registry.owner_inst)
- self.assertIsNone(OceanOpticsSpectrometer.class_registry.owner_inst)
-
- # req. 2. owner attribute must be the instance for instance registries (i.e. when accessed as instance attribute)
- self.assertEqual(self.thing.instance_registry.owner, self.thing)
- self.assertEqual(self.spectrometer.instance_registry.owner, self.spectrometer)
- self.assertEqual(self.thing.instance_registry.owner_cls, Thing)
- self.assertEqual(self.spectrometer.instance_registry.owner_cls, OceanOpticsSpectrometer)
-
- # req. 3. descriptor_object must be defined correctly and is a class
- self.assertEqual(Thing.class_registry.descriptor_object, self.registry_object)
- self.assertEqual(
- OceanOpticsSpectrometer.class_registry.descriptor_object,
- self.registry_object,
- )
- self.assertEqual(self.thing.instance_registry.descriptor_object, self.registry_object)
- self.assertEqual(
- self.thing.instance_registry.descriptor_object,
- Thing.class_registry.descriptor_object,
- )
+Test sequence is as follows:
+1. Test id requirements
+2. Test logger setup
+3. Test state and state_machine setup
+4. Test composition of subthings
+5. Test servers init
+6. Test thing model generation
+"""
- def test_2_descriptors(self):
- """Test descriptors access"""
- if self.is_abstract_test_class:
- return
-
- # req. 1. descriptors are instances of the descriptor object - Property | Action | Event
- for name, value in Thing.class_registry.descriptors.items():
- self.assertIsInstance(value, self.registry_object)
- self.assertIsInstance(name, str)
- for name, value in OceanOpticsSpectrometer.class_registry.descriptors.items():
- self.assertIsInstance(value, self.registry_object)
- self.assertIsInstance(name, str)
- # subclass have more descriptors than parent class because our example Thing OceanOpticsSpectrometer
- # has defined its own actions, properties and events
- self.assertTrue(len(OceanOpticsSpectrometer.class_registry.descriptors) > len(Thing.class_registry.descriptors))
- # req. 2. either class level or instance level descriptors are same - not a strict requirement for different
- # use cases, one can always add instance level descriptors
- for name, value in self.thing.instance_registry.descriptors.items():
- self.assertIsInstance(value, self.registry_object)
- self.assertIsInstance(name, str)
- for name, value in self.spectrometer.instance_registry.descriptors.items():
- self.assertIsInstance(value, self.registry_object)
- self.assertIsInstance(name, str)
- # req. 3. because class level and instance level descriptors are same, they are equal
- for (name, value), (name2, value2) in zip(
- Thing.class_registry.descriptors.items(),
- self.thing.instance_registry.descriptors.items(),
- ):
- self.assertEqual(name, name2)
- self.assertEqual(value, value2)
- for (name, value), (name2, value2) in zip(
- OceanOpticsSpectrometer.class_registry.descriptors.items(),
- self.spectrometer.instance_registry.descriptors.items(),
- ):
- self.assertEqual(name, name2)
- self.assertEqual(value, value2)
- # req. 4. descriptors can be cleared
- self.assertTrue(
- hasattr(
- self.thing.instance_registry,
- f"_{self.thing.instance_registry._qualified_prefix}_{self.registry_cls.__name__.lower()}",
- )
- )
- self.thing.instance_registry.clear()
- self.assertTrue(
- not hasattr(
- self.thing.instance_registry,
- f"_{self.thing.instance_registry._qualified_prefix}_{self.registry_cls.__name__.lower()}",
- )
- )
- # clearing again any number of times should not raise error
- self.thing.instance_registry.clear()
- self.thing.instance_registry.clear()
- self.assertTrue(
- not hasattr(
- self.thing.instance_registry,
- f"_{self.thing.instance_registry._qualified_prefix}_{self.registry_cls.__name__.lower()}",
- )
- )
- def test_3_dunders(self):
- """Test dunders of DescriptorRegistry"""
- if self.is_abstract_test_class:
- return
-
- # req. 1. __getitem__ must return the descriptor object
- for name, value in Thing.class_registry.descriptors.items():
- self.assertEqual(Thing.class_registry[name], value)
- # req. 2. __contains__ must return True if the descriptor is present
- self.assertIn(value, Thing.class_registry)
- self.assertIn(name, Thing.class_registry.descriptors.keys())
-
- # req. 2. __iter__ must return an iterator over the descriptors dictionary
- # which in turn iterates over the keys
- self.assertTrue(all(isinstance(descriptor_name, str) for descriptor_name in Thing.class_registry))
- self.assertTrue(
- all(isinstance(descriptor_name, str) for descriptor_name in OceanOpticsSpectrometer.class_registry)
- )
- # __iter__ can also be casted as other iterators like lists
- thing_descriptors = list(self.thing.instance_registry)
- spectrometer_descriptors = list(self.spectrometer.instance_registry)
- self.assertIsInstance(thing_descriptors, list)
- self.assertIsInstance(spectrometer_descriptors, list)
- self.assertTrue(all(isinstance(descriptor_name, str) for descriptor_name in thing_descriptors))
- self.assertTrue(all(isinstance(descriptor_name, str) for descriptor_name in spectrometer_descriptors))
-
- # req. 3. __len__ must return the number of descriptors
- self.assertTrue(len(Thing.class_registry) == len(Thing.class_registry.descriptors))
- self.assertTrue(
- len(OceanOpticsSpectrometer.class_registry) == len(OceanOpticsSpectrometer.class_registry.descriptors)
- )
- self.assertTrue(len(self.thing.instance_registry) == len(self.thing.instance_registry.descriptors))
- self.assertTrue(
- len(self.spectrometer.instance_registry) == len(self.spectrometer.instance_registry.descriptors)
- )
- self.assertTrue(len(self.thing.instance_registry) == len(Thing.class_registry))
- self.assertTrue(len(self.spectrometer.instance_registry) == len(OceanOpticsSpectrometer.class_registry))
-
- # req. 4. registries have their unique hashes
- # NOTE - not sure if this is really a useful feature or just plain stupid
- # The requirement was to be able to generate unique hashes for each registry like foodict[] = Thing.actions
- foodict = {
- Thing.class_registry: 1,
- OceanOpticsSpectrometer.class_registry: 2,
- self.thing.instance_registry: 3,
- self.spectrometer.instance_registry: 4,
- }
- self.assertEqual(foodict[Thing.class_registry], 1)
- self.assertEqual(foodict[OceanOpticsSpectrometer.class_registry], 2)
- self.assertEqual(foodict[self.thing.instance_registry], 3)
- self.assertEqual(foodict[self.spectrometer.instance_registry], 4)
-
- # __dir__ not yet tested
- # __str__ will not be tested
-
- def test_4_bound_objects(self):
- """Test bound objects returned from descriptor access"""
- if self.is_abstract_test_class:
- return
- if self.registry_object not in [Property, Parameter, Action]:
- # Events work a little differently, may need to be tested separately or refactored to same implementation
- return
-
- # req. 1. number of bound objects must be equal to number of descriptors
- # for example, number of bound actions must be equal to number of actions
- self.assertEqual(
- len(self.thing.instance_registry),
- len(self.thing.instance_registry.descriptors),
- )
- self.assertEqual(
- len(self.spectrometer.instance_registry),
- len(self.spectrometer.instance_registry.descriptors),
- )
+@pytest.mark.parametrize("thing_cls", [Thing, OceanOpticsSpectrometer])
+def test_01_id(thing_cls: ThingMeta):
+ """Test id property of Thing class"""
+ # req. 1. instance name must be a string and cannot be changed after set
+ thing = thing_cls(id="test_id") # type: Thing
+ assert thing.id == "test_id"
+ with pytest.raises(ValueError):
+ thing.id = "new_instance"
+ with pytest.raises(NotImplementedError):
+ del thing.id
+ # req. 2. regex is r'[A-Za-z]+[A-Za-z_0-9\-\/]*', simple URI like
+ valid_ids = ["test_id", "A123", "valid_id-123", "another/valid-id"]
+ invalid_ids = ["123_invalid", "invalid id", "invalid@id", ""]
+ for valid_id in valid_ids:
+ thing.properties.descriptors["id"].validate_and_adapt(valid_id)
+ for invalid_id in invalid_ids:
+ with pytest.raises(ValueError):
+ thing.properties.descriptors["id"].validate_and_adapt(invalid_id)
+
+
+@pytest.mark.parametrize("thing_cls", [Thing, OceanOpticsSpectrometer])
+def notest_02_logger(thing_cls: ThingMeta):
+ """Test logger setup"""
+ # req. 1. logger must have remote access handler if remote_accessible_logger is True
+ logger = get_default_logger("test_logger")
+ thing = thing_cls(
+ id="test_remote_accessible_logger",
+ logger=logger,
+ remote_accessible_logger=True,
+ ) # type: Thing
+ assert thing.logger == logger
+ assert any(isinstance(handler, RemoteAccessHandler) for handler in thing.logger.handlers)
+ # Therefore also check the false condition
+ logger = get_default_logger("test_logger_2")
+ thing = thing_cls(
+ id="test_logger_without_remote_access",
+ logger=logger,
+ remote_accessible_logger=False,
+ ) # type: Thing
+ assert not any(isinstance(handler, RemoteAccessHandler) for handler in thing.logger.handlers)
+ # NOTE - logger is modifiable after instantiation
+
+ # req. 2. logger is created automatically if not provided
+ thing = thing_cls(id="test_logger_auto_creation")
+ assert thing.logger is not None
+ assert not any(isinstance(handler, RemoteAccessHandler) for handler in thing.logger.handlers)
+ assert thing.logger != logger # not the above logger that we used.
+ # remote accessible only when we ask for it
+ thing = thing_cls(id="test_logger_auto_creation_2", remote_accessible_logger=True) # type: Thing
+ assert thing.logger is not None
+ assert any(isinstance(handler, RemoteAccessHandler) for handler in thing.logger.handlers)
+ assert thing.logger != logger
+
+
+@pytest.mark.parametrize("thing_cls", [Thing])
+def test_03_has_no_fsm(thing_cls: ThingMeta):
+ """Test state and state_machine setup"""
+ # req. 1. state property must be None when no state machine is present
+ thing = thing_cls(id="test_no_state_machine") # type: Thing
+ if thing.state_machine is None:
+ assert thing.state is None
+ assert thing.state_machine is None
+
+
+@pytest.mark.parametrize("thing_cls", [OceanOpticsSpectrometer])
+def test_04_bound_fsm(thing_cls: ThingMeta):
+ """Test state and state_machine setup"""
+ thing1 = thing_cls(id="test_state_machine") # type: Thing
+ # req. 1. state and state machine must be present because we create this subclass with a state machine
+ assert thing1.state is not None
+ assert isinstance(thing1.state_machine, BoundFSM)
+ # req. 2. state and state machine must be different for different instances
+ thing2 = thing_cls(id="test_state_machine_2") # type: Thing
+ # first check if state machine exists
+ assert thing2.state is not None
+ assert isinstance(thing2.state_machine, BoundFSM)
+ # then check if they are different
+ assert thing1.state_machine != thing2.state_machine
+ # until state is set, initial state is equal
+ assert thing1.state == thing2.state
+ assert thing1.state_machine.initial_state == thing2.state_machine.initial_state
+ # after state is set, they are different
+ thing1.state_machine.set_state(thing1.states.ALARM)
+ assert thing1.state != thing2.state
+ assert thing1.state_machine != thing2.state_machine
+ # initial state is still same
+ assert thing1.state_machine.initial_state == thing2.state_machine.initial_state
+ # detailed checks in another file
+
+
+@pytest.mark.parametrize("thing_cls", [Thing, OceanOpticsSpectrometer])
+def test_05_subthings(thing_cls: ThingMeta):
+ """Test object composition"""
+ thing = thing_cls(id="test_subthings", remote_accessible_logger=True) # type: Thing
+ # req. 1. subthings must be a dictionary
+ assert isinstance(thing.sub_things, dict)
+ assert len(thing.sub_things) == 1 # logger
+ # req. 2. subthings are always recomputed when accessed (at least thats the way it is right now),
+ # so we can add new subthings anytime
+ thing.another_thing = OceanOpticsSpectrometer(id="another_thing")
+ assert isinstance(thing.sub_things, dict)
+ assert len(thing.sub_things) == 2 # logger + another_thing
+ # req. 3. subthings must be instances of Thing and have the parent as owner
+ for name, subthing in thing.sub_things.items():
+ assert thing in subthing._owners # type: ignore[attr-defined]
+ assert isinstance(subthing, Thing)
+ # req. 4. name of subthing must match name of the attribute
+ assert hasattr(thing, name)
+
+
+@pytest.mark.parametrize("thing_cls", [Thing, OceanOpticsSpectrometer])
+def test_06_servers_init(thing_cls: ThingMeta):
+ """Test if servers can be initialized/instantiated"""
+ # req. 1. rpc_server and event_publisher must be None when not run()
+ thing = thing_cls(id="test_servers_init") # type: Thing
+ assert thing.rpc_server is None
+ assert thing.event_publisher is None
+ # req. 2. rpc_server and event_publisher must be instances of their respective classes when run()
+ RPCServer(id="test-rpc-server-init", things=[thing], logger=thing.logger) # prepare server class
+ assert isinstance(thing.rpc_server, RPCServer)
+ assert isinstance(thing.event_publisher, EventPublisher)
+ # exit to quit nicely
+ thing.rpc_server.exit()
+ thing.event_publisher.exit()
- # req. 2. bound objects must be instances of bound instances
- for name, value in self.thing.instance_registry.values.items():
- if self.bound_object != typing.Any:
- self.assertIsInstance(value, self.bound_object)
- self.assertIsInstance(name, str)
- for name, value in self.spectrometer.instance_registry.values.items():
- if self.bound_object != typing.Any:
- self.assertIsInstance(value, self.bound_object)
- self.assertIsInstance(name, str)
-
-
-class TestActionRegistry(TestRegistry):
- """Test ActionRegistry class"""
-
- @classmethod
- def setUpRegistryObjects(cls):
- cls.registry_cls = ActionsRegistry
- cls.registry_object = Action
-
-
-class TestEventRegistry(TestRegistry):
- @classmethod
- def setUpRegistryObjects(cls):
- cls.registry_cls = EventsRegistry
- cls.registry_object = Event
-
- def test_2_descriptors(self):
- if self.is_abstract_test_class:
- return
-
- super().test_2_descriptors()
-
- # req. 5. observables and change events are also descriptors
- for name, value in self.thing.events.observables.items():
- self.assertIsInstance(value, Property)
- self.assertIsInstance(name, str)
- for name, value in self.thing.events.change_events.items():
- self.assertIsInstance(value, Event)
- self.assertIsInstance(name, str)
- # req. 4. descriptors can be cleared
- self.assertTrue(
- hasattr(
- self.thing.events,
- f"_{self.thing.events._qualified_prefix}_{EventsRegistry.__name__.lower()}",
- )
- )
- self.assertTrue(
- hasattr(
- self.thing.events,
- f"_{self.thing.events._qualified_prefix}_{EventsRegistry.__name__.lower()}_change_events",
- )
- )
- self.assertTrue(
- hasattr(
- self.thing.events,
- f"_{self.thing.events._qualified_prefix}_{EventsRegistry.__name__.lower()}_observables",
- )
- )
- self.thing.events.clear()
- self.assertTrue(
- not hasattr(
- self.thing.events,
- f"_{self.thing.events._qualified_prefix}_{EventsRegistry.__name__.lower()}",
- )
- )
- self.assertTrue(
- not hasattr(
- self.thing.events,
- f"_{self.thing.events._qualified_prefix}_{EventsRegistry.__name__.lower()}_change_events",
- )
- )
- self.assertTrue(
- not hasattr(
- self.thing.events,
- f"_{self.thing.events._qualified_prefix}_{EventsRegistry.__name__.lower()}_observables",
- )
- )
- self.thing.events.clear()
- self.thing.events.clear()
- self.assertTrue(
- not hasattr(
- self.thing.events,
- f"_{self.thing.events._qualified_prefix}_{EventsRegistry.__name__.lower()}",
- )
- )
- self.assertTrue(
- not hasattr(
- self.thing.events,
- f"_{self.thing.events._qualified_prefix}_{EventsRegistry.__name__.lower()}_change_events",
- )
- )
- self.assertTrue(
- not hasattr(
- self.thing.events,
- f"_{self.thing.events._qualified_prefix}_{EventsRegistry.__name__.lower()}_observables",
- )
- )
+"""
+Test sequence is as follows:
+1. Test metaclass of Thing class
+2. Test registry creation and access which is currently the main purpose of the metaclass
+"""
-class TestPropertiesRegistry(TestRegistry):
- @classmethod
- def setUpRegistryObjects(cls):
- cls.registry_cls = PropertiesRegistry
- cls.registry_object = Parameter
-
- def test_2_descriptors(self):
- if self.is_abstract_test_class:
- return
-
- super().test_2_descriptors()
-
- # req. 5. parameters that are subclass of Property are usually remote objects
- for name, value in self.thing.properties.remote_objects.items():
- self.assertIsInstance(value, Property)
- self.assertIsInstance(name, str)
- for name, value in self.spectrometer.properties.remote_objects.items():
- self.assertIsInstance(value, Property)
- self.assertIsInstance(name, str)
- # req. 6. db_objects, db_init_objects, db_persisting_objects, db_commit_objects are also descriptors
- for name, value in self.thing.properties.db_objects.items():
- self.assertIsInstance(value, Property)
- self.assertIsInstance(name, str)
- self.assertTrue(value.db_init or value.db_persist or value.db_commit)
- for name, value in self.thing.properties.db_init_objects.items():
- self.assertIsInstance(value, Property)
- self.assertIsInstance(name, str)
- self.assertTrue(value.db_init or value.db_persist)
- self.assertFalse(value.db_commit)
- for name, value in self.thing.properties.db_commit_objects.items():
- self.assertIsInstance(value, Property)
- self.assertIsInstance(name, str)
- self.assertTrue(value.db_commit or value.db_persist)
- self.assertFalse(value.db_init)
- for name, value in self.thing.properties.db_persisting_objects.items():
- self.assertIsInstance(value, Property)
- self.assertIsInstance(name, str)
- self.assertTrue(value.db_persist)
- self.assertFalse(value.db_init) # in user given cases, this could be true, this is not strict requirement
- self.assertFalse(value.db_commit) # in user given cases, this could be true, this is not strict requirement
-
- # req. 4. descriptors can be cleared
- self.assertTrue(
- hasattr(
- self.thing.properties,
- f"_{self.thing.properties._qualified_prefix}_{PropertiesRegistry.__name__.lower()}",
- )
- )
- self.thing.properties.clear()
- self.assertTrue(
- not hasattr(
- self.thing.properties,
- f"_{self.thing.properties._qualified_prefix}_{PropertiesRegistry.__name__.lower()}",
- )
- )
- self.thing.properties.clear()
- self.thing.properties.clear()
- self.assertTrue(
- not hasattr(
- self.thing.properties,
- f"_{self.thing.properties._qualified_prefix}_{PropertiesRegistry.__name__.lower()}",
- )
- )
- def test_5_bulk_read_write(self):
- """Test bulk read and write operations for properties"""
-
- # req. 1. test read in bulk for readAllProperties
- prop_values = self.spectrometer.properties.get()
- # read value is a dictionary
- self.assertIsInstance(prop_values, dict)
- self.assertTrue(len(prop_values) > 0)
- # all properties are read at instance level and get only reads remote objects
- self.assertTrue(len(prop_values) == len(self.spectrometer.properties.remote_objects))
- # read values are not descriptors themselves
- for name, value in prop_values.items():
- self.assertIsInstance(name, str)
- self.assertNotIsInstance(value, Parameter) # descriptor has been read
-
- # req. 2. properties can be read with new names
- prop_values = self.spectrometer.properties.get(
- integration_time="integrationTime",
- state="State",
- trigger_mode="triggerMode",
- )
- self.assertIsInstance(prop_values, dict)
- self.assertTrue(len(prop_values) == 3)
- for name, value in prop_values.items():
- self.assertIsInstance(name, str)
- self.assertTrue(name in ["integrationTime", "triggerMode", "State"])
- self.assertNotIsInstance(value, Parameter)
-
- # req. 3. read in bulk for readMultipleProperties
- prop_values = self.spectrometer.properties.get(
- names=["integration_time", "trigger_mode", "state", "last_intensity"]
- )
- # read value is a dictionary
- self.assertIsInstance(prop_values, dict)
- self.assertTrue(len(prop_values) == 4)
- # read values are not descriptors themselves
- for name, value in prop_values.items():
- self.assertIsInstance(name, str)
- self.assertTrue(name in ["integration_time", "trigger_mode", "state", "last_intensity"])
- self.assertNotIsInstance(value, Parameter)
-
- # req. 4. read a property that is not present raises AttributeError
- with self.assertRaises(AttributeError) as ex:
- prop_values = self.spectrometer.properties.get(
- names=[
- "integration_time",
- "trigger_mode",
- "non_existent_property",
- "last_intensity",
- ]
- )
- self.assertTrue("property non_existent_property does not exist" in str(ex.exception))
-
- # req. 5. write in bulk
- prop_values = self.spectrometer.properties.get()
- self.spectrometer.properties.set(integration_time=10, trigger_mode=1)
- self.assertNotEqual(prop_values["integration_time"], self.spectrometer.integration_time)
- self.assertNotEqual(prop_values["trigger_mode"], self.spectrometer.trigger_mode)
-
- # req. 6. writing a non existent property raises RuntimeError
- with self.assertRaises(RuntimeError) as ex:
- self.spectrometer.properties.set(integration_time=120, trigger_mode=2, non_existent_property=10)
- self.assertTrue("Some properties could not be set due to errors" in str(ex.exception))
- self.assertTrue("non_existent_property" in str(ex.exception.__notes__))
- # but those that exist will still be written
- self.assertEqual(self.spectrometer.integration_time, 120)
- self.assertEqual(self.spectrometer.trigger_mode, 2)
-
- def test_6_db_properties(self):
- """Test db operations for properties"""
-
- # req. 1. db operations are supported only at instance level
- with self.assertRaises(AttributeError) as ex:
- Thing.properties.load_from_DB()
- self.assertTrue("database operations are only supported at instance level" in str(ex.exception))
- with self.assertRaises(AttributeError) as ex:
- Thing.properties.get_from_DB()
- self.assertTrue("database operations are only supported at instance level" in str(ex.exception))
-
-
-def load_tests(loader, tests, pattern):
- suite = unittest.TestSuite()
- suite.addTest(unittest.TestLoader().loadTestsFromTestCase(TestThingInit))
- suite.addTest(unittest.TestLoader().loadTestsFromTestCase(TestOceanOpticsSpectrometer))
- suite.addTest(unittest.TestLoader().loadTestsFromTestCase(TestMetaclass))
- suite.addTest(unittest.TestLoader().loadTestsFromTestCase(TestActionRegistry))
- suite.addTest(unittest.TestLoader().loadTestsFromTestCase(TestPropertiesRegistry))
- suite.addTest(unittest.TestLoader().loadTestsFromTestCase(TestEventRegistry))
- return suite
-
-
-if __name__ == "__main__":
- runner = TestRunner()
- runner.run(load_tests(unittest.TestLoader(), None, None))
+@pytest.mark.parametrize("thing_cls", [Thing, OceanOpticsSpectrometer])
+def test_07_metaclass_assigned(thing_cls: ThingMeta):
+ """test metaclass of Thing class"""
+ # req. 1 metaclass must be ThingMeta of any Thing class
+ assert thing_cls.__class__ == ThingMeta
+ assert OceanOpticsSpectrometer.__class__ == ThingMeta
+ assert Thing.__class__ == OceanOpticsSpectrometer.__class__
+
+
+def test_08_registry_creation():
+ """test registry creation and access which is currently the main purpose of the metaclass"""
+ # req. 1. registry attributes must be instances of their respective classes
+ assert isinstance(Thing.properties, PropertiesRegistry)
+ assert isinstance(Thing.actions, ActionsRegistry)
+ assert isinstance(Thing.events, EventsRegistry)
+
+ # req. 2. new registries are not created on the fly and are same between accesses
+ assert Thing.properties == Thing.properties
+ assert Thing.actions == Thing.actions
+ assert Thing.events == Thing.events
+ # This test is done as the implementation deviates from `param`
+
+ # req. 3. different subclasses have different registries
+ assert Thing.properties != OceanOpticsSpectrometer.properties
+ assert Thing.actions != OceanOpticsSpectrometer.actions
+ assert Thing.events != OceanOpticsSpectrometer.events
+
+ # create instances for further tests
+ thing = Thing(id="test_registry_creation")
+ spectrometer = OceanOpticsSpectrometer(id="test_registry_creation_2")
+
+ # req. 4. registry attributes must be instances of their respective classes also for instances
+ assert isinstance(thing.properties, PropertiesRegistry)
+ assert isinstance(thing.actions, ActionsRegistry)
+ assert isinstance(thing.events, EventsRegistry)
+
+ # req. 5. registries are not created on the fly and are same between accesses also for instances
+ assert thing.properties == thing.properties
+ assert thing.actions == thing.actions
+ assert thing.events == thing.events
+
+ # req. 6. registries are not shared between instances
+ assert thing.properties != spectrometer.properties
+ assert thing.actions != spectrometer.actions
+ assert thing.events != spectrometer.events
+
+ # req. 7. registries are not shared between instances and their classes
+ assert thing.properties != Thing.properties
+ assert thing.actions != Thing.actions
+ assert thing.events != Thing.events
+ assert spectrometer.properties != OceanOpticsSpectrometer.properties
+ assert spectrometer.actions != OceanOpticsSpectrometer.actions
+ assert spectrometer.events != OceanOpticsSpectrometer.events
"""
-# Summary of tests and requirements:
-
-TestThing class:
-1. Test id requirements:
- - Instance name must be a string and cannot be changed after set.
- - Valid and invalid IDs based on regex (r'[A-Za-z]+[A-Za-z_0-9\\-\\/]*').
-2. Test logger setup:
- - Logger must have remote access handler if remote_accessible_logger is True.
- - Logger is created automatically if not provided.
-3. Test state and state_machine setup:
- - State property must be None when no state machine is present.
-4. Test composition of subthings:
- - Subthings must be a dictionary.
- - Subthings are recomputed when accessed.
- - Subthings must be instances of Thing and have the parent as owner.
- - Name of subthing must match name of the attribute.
-5. Test servers init:
- - rpc_server and event_publisher must be None when not run().
- - rpc_server and event_publisher must be instances of their respective classes when run().
-6. Test thing model generation:
- - Basic test to ensure nothing is fundamentally wrong.
-
-TestOceanOpticsSpectrometer class:
-1. Test state and state_machine setup:
- - State and state machine must be present because subclass has a state machine.
- - State and state machine must be different for different instances.
-
-TestMetaclass class:
-1. Test metaclass of Thing class:
- - Metaclass must be ThingMeta for any Thing class.
-2. Test registry creation and access:
- - Registry attributes must be instances of their respective classes.
- - New registries are not created on the fly and are same between accesses.
- - Different subclasses have different registries.
- - Registry attributes must be instances of their respective classes also for instances.
- - Registries are not created on the fly and are same between accesses also for instances.
- - Registries are not shared between instances.
- - Registries are not shared between instances and their classes.
-
-TestRegistry class:
-1. Test owner attribute:
- - Owner attribute must be the class itself when accessed as class attribute.
- - Owner attribute must be the instance for instance registries.
- - Descriptor_object must be defined correctly and is a class.
-2. Test descriptors access:
- - Descriptors are instances of the descriptor object.
- - Class level or instance level descriptors are same.
- - Descriptors can be cleared.
-3. Test dunders:
- - __getitem__ must return the descriptor object.
- - __contains__ must return True if the descriptor is present.
- - __iter__ must return an iterator over the descriptors dictionary.
- - __len__ must return the number of descriptors.
- - Registries have their unique hashes.
-4. Test bound objects:
- - Number of bound objects must be equal to number of descriptors.
- - Bound objects must be instances of bound instances.
-
-TestActionRegistry class:
-- Inherits tests from TestRegistry.
-
-TestEventRegistry class:
-- Inherits tests from TestRegistry.
-- Observables and change events are also descriptors.
-
-TestPropertiesRegistry class:
-- Inherits tests from TestRegistry.
-- Parameters that are subclass of Property are usually remote objects.
-- DB operations are supported only at instance level.
+Test action registry first because actions are the easiest to test.
+1. Test owner attribute
+2. Test descriptors access
+3. Test dunders
"""
+
+
+class Registry:
+ """Class to hold registry class and object for parameterized tests"""
+
+ cls: type[PropertiesRegistry | ActionsRegistry | EventsRegistry]
+ cls_object: PropertiesRegistry | ActionsRegistry | EventsRegistry
+ inst_object: PropertiesRegistry | ActionsRegistry | EventsRegistry | None
+ obj: type[Property | Action | Event]
+ bound_object: type[BoundAction | EventDispatcher] | Any # any is for property value
+ thing_cls: ThingMeta
+ thing_inst: Thing
+
+ def __init__(self) -> None:
+ pass
+
+
+@pytest.fixture(
+ params=[
+ pytest.param((Thing, PropertiesRegistry), id="Thing-PropertiesRegistry"),
+ pytest.param((Thing, ActionsRegistry), id="Thing-ActionsRegistry"),
+ pytest.param((Thing, EventsRegistry), id="Thing-EventsRegistry"),
+ pytest.param((OceanOpticsSpectrometer, PropertiesRegistry), id="OceanOpticsSpectrometer-PropertiesRegistry"),
+ pytest.param((OceanOpticsSpectrometer, ActionsRegistry), id="OceanOpticsSpectrometer-ActionsRegistry"),
+ pytest.param((OceanOpticsSpectrometer, EventsRegistry), id="OceanOpticsSpectrometer-EventsRegistry"),
+ ],
+)
+def registry(request) -> Registry:
+ # create instances for further tests
+ cls, registry_cls = request.param
+ thing = cls(id=f"test_{registry_cls.__name__}_registry")
+ registry = Registry()
+ registry.thing_cls = cls
+ registry.thing_inst = thing
+ registry.cls = registry_cls
+ if registry_cls == ActionsRegistry:
+ registry.cls_object = cls.actions
+ registry.inst_object = thing.actions
+ registry.obj = Action
+ registry.bound_object = BoundAction
+ elif registry_cls == PropertiesRegistry:
+ registry.cls_object = cls.properties
+ registry.inst_object = thing.properties
+ registry.obj = Parameter
+ registry.bound_object = Any
+ elif registry_cls == EventsRegistry:
+ registry.cls_object = cls.events
+ registry.inst_object = thing.events
+ registry.obj = Event
+ registry.bound_object = EventDispatcher
+ else:
+ raise NotImplementedError("This registry class is not implemented")
+ return registry
+
+
+def test_09_registry_owner(registry: Registry):
+ """Test owner attribute of DescriptorRegistry"""
+ # See comment above TestRegistry class to enable type definitions
+ # req. 1. owner attribute must be the class itself when accessed as class attribute
+ assert registry.cls_object.owner == registry.thing_cls
+ # therefore owner instance must be None
+ assert registry.cls_object.owner_inst is None
+
+ # req. 2. owner attribute must be the instance for instance registries (i.e. when accessed as instance attribute)
+ assert registry.inst_object.owner == registry.thing_inst
+ assert registry.inst_object.owner_cls == registry.thing_cls
+
+ # req. 3. descriptor_object must be defined correctly and is a class
+ assert registry.cls_object.descriptor_object == registry.obj
+ assert registry.inst_object.descriptor_object == registry.obj
+ assert registry.cls_object.descriptor_object == registry.inst_object.descriptor_object
+
+
+def test_10_descriptors_access(registry: Registry):
+ """Test descriptors access"""
+
+ # req. 1. descriptors are instances of the descriptor object - Property | Action | Event
+ for name, value in registry.cls_object.descriptors.items():
+ assert isinstance(value, registry.obj)
+ assert isinstance(name, str)
+
+ # req. 2. either class level or instance level descriptors are same - not a strict requirement for different
+ # use cases, one can always add instance level descriptors
+ for name, value in registry.inst_object.descriptors.items():
+ assert isinstance(value, registry.obj)
+ assert isinstance(name, str)
+
+ # req. 3. because class level and instance level descriptors are same, they are equal
+ for (name, value), (name2, value2) in zip(
+ registry.cls_object.descriptors.items(),
+ registry.inst_object.descriptors.items(),
+ ):
+ assert name == name2
+ assert value == value2
+
+ # req. 4. descriptors can be cleared
+ assert hasattr(
+ registry.inst_object,
+ f"_{registry.inst_object._qualified_prefix}_{registry.cls.__name__.lower()}",
+ )
+ registry.inst_object.clear()
+ assert not hasattr(
+ registry.inst_object,
+ f"_{registry.inst_object._qualified_prefix}_{registry.cls.__name__.lower()}",
+ )
+ # clearing again any number of times should not raise error
+ registry.inst_object.clear()
+ registry.inst_object.clear()
+ assert not hasattr(
+ registry.inst_object,
+ f"_{registry.inst_object._qualified_prefix}_{registry.cls.__name__.lower()}",
+ )
+
+
+def test_11_registry_dunders(registry: Registry):
+ """Test dunders of DescriptorRegistry"""
+
+ # req. 1. __getitem__ must return the descriptor object
+ for name, value in registry.cls_object.descriptors.items():
+ assert registry.cls_object[name] == value
+ # req. 2. __contains__ must return True if the descriptor is present
+ assert value in registry.cls_object
+ assert name in registry.cls_object.descriptors.keys()
+
+ # req. 2. __iter__ must return an iterator over the descriptors dictionary
+ # which in turn iterates over the keys
+ assert all(isinstance(descriptor_name, str) for descriptor_name in registry.cls_object)
+ assert all(isinstance(descriptor_name, str) for descriptor_name in registry.inst_object)
+ # __iter__ can also be casted as other iterators like lists
+ descriptors = list(registry.inst_object)
+ assert isinstance(descriptors, list)
+ assert all(isinstance(descriptor_name, str) for descriptor_name in descriptors)
+
+ # req. 3. __len__ must return the number of descriptors
+ assert len(registry.cls_object) == len(registry.cls_object.descriptors)
+ assert len(registry.inst_object) == len(registry.inst_object.descriptors)
+ assert len(registry.inst_object) == len(registry.cls_object)
+
+ # req. 4. registries have their unique hashes
+ # NOTE - not sure if this is really a useful feature or just plain stupid
+ # The requirement was to be able to generate unique hashes for each registry like foodict[] = Thing.actions
+ foodict = {
+ registry.cls_object: 1,
+ registry.inst_object: 3,
+ }
+ assert foodict[registry.cls_object] == 1
+ assert foodict[registry.inst_object] == 3
+
+ # __dir__ not yet tested
+ # __str__ will not be tested
+
+
+def test_12_bound_objects(registry: Registry):
+ """Test bound objects returned from descriptor access"""
+ # req. 1. number of bound objects must be equal to number of descriptors
+ # for example, number of bound actions must be equal to number of actions
+ assert len(registry.inst_object) == len(registry.inst_object.descriptors)
+
+ # req. 2. bound objects must be instances of bound instances
+ for name, value in registry.inst_object.values.items():
+ if registry.bound_object != Any:
+ assert isinstance(value, registry.bound_object)
+ assert isinstance(name, str)
+
+
+@pytest.fixture(
+ params=[
+ pytest.param((Thing, EventsRegistry), id="Thing-EventsRegistry"),
+ pytest.param((OceanOpticsSpectrometer, EventsRegistry), id="OceanOpticsSpectrometer-EventsRegistry"),
+ ],
+)
+def event_registry(request) -> Registry:
+ cls, registry_cls = request.param
+ thing = cls(id=f"test_{registry_cls.__name__}_registry")
+ registry = Registry()
+ registry.thing_cls = cls
+ registry.thing_inst = thing
+ registry.cls = registry_cls
+ registry.cls_object = cls.events
+ registry.inst_object = thing.events
+ registry.obj = Event
+ registry.bound_object = EventDispatcher
+ return registry
+
+
+def test_13_descriptors_access_events(event_registry: Registry):
+ registry = event_registry
+ # req. 5. observables and change events are also descriptors
+ for name, value in registry.inst_object.observables.items():
+ assert isinstance(value, Property)
+ assert isinstance(name, str)
+ for name, value in registry.inst_object.change_events.items():
+ assert isinstance(value, Event)
+ assert isinstance(name, str)
+ # req. 4. descriptors can be cleared
+ assert hasattr(
+ registry.inst_object,
+ f"_{registry.inst_object._qualified_prefix}_{EventsRegistry.__name__.lower()}",
+ )
+ assert hasattr(
+ registry.inst_object,
+ f"_{registry.inst_object._qualified_prefix}_{EventsRegistry.__name__.lower()}_change_events",
+ )
+ assert hasattr(
+ registry.inst_object,
+ f"_{registry.inst_object._qualified_prefix}_{EventsRegistry.__name__.lower()}_observables",
+ )
+ registry.inst_object.clear()
+ assert not hasattr(
+ registry.inst_object,
+ f"_{registry.inst_object._qualified_prefix}_{EventsRegistry.__name__.lower()}",
+ )
+ assert not hasattr(
+ registry.inst_object,
+ f"_{registry.inst_object._qualified_prefix}_{EventsRegistry.__name__.lower()}_change_events",
+ )
+ assert not hasattr(
+ registry.inst_object,
+ f"_{registry.inst_object._qualified_prefix}_{EventsRegistry.__name__.lower()}_observables",
+ )
+ registry.inst_object.clear()
+ registry.inst_object.clear()
+ assert not hasattr(
+ registry.inst_object,
+ f"_{registry.inst_object._qualified_prefix}_{EventsRegistry.__name__.lower()}",
+ )
+ assert not hasattr(
+ registry.inst_object,
+ f"_{registry.inst_object._qualified_prefix}_{EventsRegistry.__name__.lower()}_change_events",
+ )
+ assert not hasattr(
+ registry.inst_object,
+ f"_{registry.inst_object._qualified_prefix}_{EventsRegistry.__name__.lower()}_observables",
+ )
+
+
+@pytest.fixture(
+ params=[
+ pytest.param((Thing, PropertiesRegistry), id="Thing-PropertiesRegistry"),
+ pytest.param((OceanOpticsSpectrometer, PropertiesRegistry), id="OceanOpticsSpectrometer-PropertiesRegistry"),
+ ],
+)
+def properties_registry(request) -> Registry:
+ cls, registry_cls = request.param
+ thing = cls(id=f"test_{registry_cls.__name__}_registry")
+ registry = Registry()
+ registry.thing_cls = cls
+ registry.thing_inst = thing
+ registry.cls = registry_cls
+ registry.cls_object = cls.properties
+ registry.inst_object = thing.properties
+ registry.obj = Property
+ registry.bound_object = Any
+ return registry
+
+
+def test_14_descriptors_access_properties(properties_registry: Registry):
+ registry = properties_registry
+
+ # req. 5. parameters that are subclass of Property are usually remote objects
+ for name, value in registry.thing_inst.properties.remote_objects.items():
+ assert isinstance(value, Property)
+ assert isinstance(name, str)
+
+ # req. 6. db_objects, db_init_objects, db_persisting_objects, db_commit_objects are also descriptors
+ for name, value in registry.thing_inst.properties.db_objects.items():
+ assert isinstance(value, Property)
+ assert isinstance(name, str)
+ assert value.db_init or value.db_persist or value.db_commit
+ for name, value in registry.thing_inst.properties.db_init_objects.items():
+ assert isinstance(value, Property)
+ assert isinstance(name, str)
+ assert value.db_init or value.db_persist
+ assert not value.db_commit
+ for name, value in registry.thing_inst.properties.db_commit_objects.items():
+ assert isinstance(value, Property)
+ assert isinstance(name, str)
+ assert value.db_commit or value.db_persist
+ assert not value.db_init
+ for name, value in registry.thing_inst.properties.db_persisting_objects.items():
+ assert isinstance(value, Property)
+ assert isinstance(name, str)
+ assert value.db_persist
+ assert not value.db_init # in user given cases, this could be true, this is not strict requirement
+ assert not value.db_commit # in user given cases, this could be true, this is not strict requirement
+
+ # req. 4. descriptors can be cleared
+ assert hasattr(
+ registry.thing_inst.properties,
+ f"_{registry.thing_inst.properties._qualified_prefix}_{PropertiesRegistry.__name__.lower()}",
+ )
+
+ registry.thing_inst.properties.clear()
+ assert not hasattr(
+ registry.thing_inst.properties,
+ f"_{registry.thing_inst.properties._qualified_prefix}_{PropertiesRegistry.__name__.lower()}",
+ )
+
+ registry.thing_inst.properties.clear()
+ registry.thing_inst.properties.clear()
+ assert not hasattr(
+ registry.thing_inst.properties,
+ f"_{registry.thing_inst.properties._qualified_prefix}_{PropertiesRegistry.__name__.lower()}",
+ )
+
+
+@pytest.fixture(
+ params=[
+ pytest.param((OceanOpticsSpectrometer, PropertiesRegistry), id="OceanOpticsSpectrometer-PropertiesRegistry"),
+ ],
+)
+def spectrometer_registry(request) -> Registry:
+ cls, registry_cls = request.param
+ thing = cls(id=f"test_{registry_cls.__name__}_registry")
+ registry = Registry()
+ registry.thing_cls = cls
+ registry.thing_inst = thing
+ registry.cls = registry_cls
+ registry.cls_object = cls.properties
+ registry.inst_object = thing.properties
+ registry.obj = Property
+ registry.bound_object = Any
+ return registry
+
+
+def test_15_bulk_read_write_properties(spectrometer_registry: Registry):
+ """Test bulk read and write operations for properties"""
+ registry = spectrometer_registry
+
+ # req. 1. test read in bulk for readAllProperties
+ prop_values = registry.thing_inst.properties.get()
+ # read value is a dictionary
+ assert isinstance(prop_values, dict)
+ assert len(prop_values) > 0
+ # all properties are read at instance level and get only reads remote objects
+ assert len(prop_values) == len(registry.thing_inst.properties.remote_objects)
+ # read values are not descriptors themselves
+ for name, value in prop_values.items():
+ assert isinstance(name, str)
+ assert not isinstance(value, Parameter) # descriptor has been read
+
+ # req. 2. properties can be read with new names
+ prop_values = registry.thing_inst.properties.get(
+ integration_time="integrationTime",
+ state="State",
+ trigger_mode="triggerMode",
+ )
+ assert isinstance(prop_values, dict)
+ assert len(prop_values) == 3
+ for name, value in prop_values.items():
+ assert isinstance(name, str)
+ assert name in ["integrationTime", "triggerMode", "State"]
+ assert not isinstance(value, Parameter)
+
+ # req. 3. read in bulk for readMultipleProperties
+ prop_values = registry.thing_inst.properties.get(
+ names=["integration_time", "trigger_mode", "state", "last_intensity"]
+ )
+ # read value is a dictionary
+ assert isinstance(prop_values, dict)
+ assert len(prop_values) == 4
+ # read values are not descriptors themselves
+ for name, value in prop_values.items():
+ assert isinstance(name, str)
+ assert name in ["integration_time", "trigger_mode", "state", "last_intensity"]
+ assert not isinstance(value, Parameter)
+
+ # req. 4. read a property that is not present raises AttributeError
+ with pytest.raises(AttributeError) as ex:
+ prop_values = registry.thing_inst.properties.get(
+ names=[
+ "integration_time",
+ "trigger_mode",
+ "non_existent_property",
+ "last_intensity",
+ ]
+ )
+ assert "property non_existent_property does not exist" in str(ex.value)
+
+ # req. 5. write in bulk
+ prop_values = registry.thing_inst.properties.get()
+ registry.thing_inst.properties.set(integration_time=10, trigger_mode=1)
+ assert prop_values["integration_time"] != registry.thing_inst.integration_time
+ assert prop_values["trigger_mode"] != registry.thing_inst.trigger_mode
+
+ # req. 6. writing a non existent property raises RuntimeError
+ with pytest.raises(RuntimeError) as ex:
+ registry.thing_inst.properties.set(integration_time=120, trigger_mode=2, non_existent_property=10)
+ assert "Some properties could not be set due to errors" in str(ex.value)
+ # __notes__ is not standard in pytest exceptions, so we skip that assertion
+ # but those that exist will still be written
+ assert registry.thing_inst.integration_time == 120
+ assert registry.thing_inst.trigger_mode == 2
+
+
+def test_16_db_properties():
+ """Test db operations for properties"""
+ # req. 1. db operations are supported only at instance level
+ with pytest.raises(AttributeError) as ex:
+ Thing.properties.load_from_DB()
+ assert "database operations are only supported at instance level" in str(ex.value)
+ with pytest.raises(AttributeError) as ex:
+ Thing.properties.get_from_DB()
+ assert "database operations are only supported at instance level" in str(ex.value)
+
+
+def test_17_inheritance_of_registries():
+ """Test that registries are inherited properly"""
+ # req. 1. subclass have more descriptors than parent class because our example Thing OceanOpticsSpectrometer
+ # has defined its own actions, properties and events
+ assert len(OceanOpticsSpectrometer.properties.descriptors) > len(Thing.properties.descriptors)
+ assert len(OceanOpticsSpectrometer.actions.descriptors) > len(Thing.actions.descriptors)
+ assert len(OceanOpticsSpectrometer.events.descriptors) > len(Thing.events.descriptors)
+
+
+# """
+# # Summary of tests and requirements:
+
+# TestThing class:
+# 1. Test id requirements:
+# - Instance name must be a string and cannot be changed after set.
+# - Valid and invalid IDs based on regex (r'[A-Za-z]+[A-Za-z_0-9\\-\\/]*').
+# 2. Test logger setup:
+# - Logger must have remote access handler if remote_accessible_logger is True.
+# - Logger is created automatically if not provided.
+# 3. Test state and state_machine setup:
+# - State property must be None when no state machine is present.
+# 4. Test composition of subthings:
+# - Subthings must be a dictionary.
+# - Subthings are recomputed when accessed.
+# - Subthings must be instances of Thing and have the parent as owner.
+# - Name of subthing must match name of the attribute.
+# 5. Test servers init:
+# - rpc_server and event_publisher must be None when not run().
+# - rpc_server and event_publisher must be instances of their respective classes when run().
+# 6. Test thing model generation:
+# - Basic test to ensure nothing is fundamentally wrong.
+
+# TestOceanOpticsSpectrometer class:
+# 1. Test state and state_machine setup:
+# - State and state machine must be present because subclass has a state machine.
+# - State and state machine must be different for different instances.
+
+# TestMetaclass class:
+# 1. Test metaclass of Thing class:
+# - Metaclass must be ThingMeta for any Thing class.
+# 2. Test registry creation and access:
+# - Registry attributes must be instances of their respective classes.
+# - New registries are not created on the fly and are same between accesses.
+# - Different subclasses have different registries.
+# - Registry attributes must be instances of their respective classes also for instances.
+# - Registries are not created on the fly and are same between accesses also for instances.
+# - Registries are not shared between instances.
+# - Registries are not shared between instances and their classes.
+
+# TestRegistry class:
+# 1. Test owner attribute:
+# - Owner attribute must be the class itself when accessed as class attribute.
+# - Owner attribute must be the instance for instance registries.
+# - Descriptor_object must be defined correctly and is a class.
+# 2. Test descriptors access:
+# - Descriptors are instances of the descriptor object.
+# - Class level or instance level descriptors are same.
+# - Descriptors can be cleared.
+# 3. Test dunders:
+# - __getitem__ must return the descriptor object.
+# - __contains__ must return True if the descriptor is present.
+# - __iter__ must return an iterator over the descriptors dictionary.
+# - __len__ must return the number of descriptors.
+# - Registries have their unique hashes.
+# 4. Test bound objects:
+# - Number of bound objects must be equal to number of descriptors.
+# - Bound objects must be instances of bound instances.
+
+# TestActionRegistry class:
+# - Inherits tests from TestRegistry.
+
+# TestEventRegistry class:
+# - Inherits tests from TestRegistry.
+# - Observables and change events are also descriptors.
+
+# TestPropertiesRegistry class:
+# - Inherits tests from TestRegistry.
+# - Parameters that are subclass of Property are usually remote objects.
+# - DB operations are supported only at instance level.
+# """
diff --git a/tests/test_05_brokers.py b/tests/test_05_brokers.py
index b98960d6..32209feb 100644
--- a/tests/test_05_brokers.py
+++ b/tests/test_05_brokers.py
@@ -1,530 +1,361 @@
-import threading
import asyncio
-import logging
import multiprocessing
-import unittest
+import threading
+
+from dataclasses import dataclass
+from typing import Generator
+
+import pytest
+from hololinked.core.zmq.brokers import (
+ AsyncZMQClient,
+ AsyncZMQServer,
+ MessageMappedZMQClientPool,
+ SyncZMQClient,
+)
from hololinked.core.zmq.message import (
ERROR,
EXIT,
- OPERATION,
HANDSHAKE,
+ INVALID_MESSAGE,
REPLY,
- PreserializedData,
- RequestHeader,
- RequestMessage,
- SerializableData,
-) # client to server
-from hololinked.core.zmq.message import (
TIMEOUT,
- INVALID_MESSAGE,
- ERROR,
+ RequestMessage,
ResponseMessage,
- ResponseHeader,
-) # server to client
-from hololinked.core.zmq.brokers import (
- AsyncZMQServer,
- MessageMappedZMQClientPool,
- SyncZMQClient,
- AsyncZMQClient,
+ SerializableData,
)
-from hololinked.utils import get_current_async_loop
-from hololinked.logger import setup_logging
+from hololinked.exceptions import BreakLoop
+from hololinked.utils import get_current_async_loop, uuid_hex
+
try:
- from .utils import TestRunner
- from .test_01_message import MessageValidatorMixin
- from .things.starter import run_zmq_server
- from .things import TestThing
+ from .conftest import AppIDs as MessageAppIDs
+ from .test_01_message import validate_response_message
except ImportError:
- from utils import TestRunner
- from test_01_message import MessageValidatorMixin
- from things.starter import run_zmq_server
- from things import TestThing
-
-
-setup_logging(logging.WARN)
-
-
-class TestBrokerMixin(MessageValidatorMixin):
- """Tests Individual ZMQ Server"""
+ from conftest import AppIDs as MessageAppIDs
+ from test_01_message import validate_response_message
- @classmethod
- def setUpServer(cls):
- cls.server = AsyncZMQServer(id=cls.server_id)
+@dataclass
+class AppIDs:
"""
- Base class: BaseZMQ, BaseAsyncZMQ, BaseSyncZMQ
- Servers: BaseZMQServer, AsyncZMQServer, ZMQServerPool
- Clients: BaseZMQClient, SyncZMQClient, AsyncZMQClient, MessageMappedZMQClientPool
+ Application related IDs generally used by end-user,
+ like server, client, and thing IDs.
"""
- @classmethod
- def setUpClient(cls):
- cls.sync_client = None
- cls.async_client = None
-
- @classmethod
- def setUpThing(cls):
- cls.thing = TestThing(id=cls.thing_id, remote_accessible_logger=True)
-
- @classmethod
- def startServer(cls):
- cls._server_thread = threading.Thread(
- target=run_zmq_server, args=(cls.server, cls, cls.done_queue), daemon=True
- )
- cls._server_thread.start()
-
- @classmethod
- def setUpClass(cls):
- super().setUpClass()
- print(f"test ZMQ message brokers {cls.__name__}")
- cls.done_queue = multiprocessing.Queue()
- cls.last_server_message = None
- cls.setUpThing()
- cls.setUpServer()
- cls.setUpClient()
- cls.startServer()
-
-
-class TestBasicServerAndClient(TestBrokerMixin):
- @classmethod
- def setUpClient(cls):
- super().setUpClient()
- cls.sync_client = SyncZMQClient(
- id=cls.client_id,
- server_id=cls.server_id,
- handshake=False,
- )
- cls.client = cls.sync_client
-
- def test_1_handshake_complete(self):
- """
- Test handshake so that client can connect to server. Once client connects to server,
- verify a ZMQ internal monitoring socket is available.
- """
- self.client.handshake()
- self.assertTrue(self.client._monitor_socket is not None)
- self.assertTrue(self.client._monitor_socket in self.client.poller)
- # both directions
- # HANDSHAKE = 'HANDSHAKE' # 1 - find out if the server is alive
-
- def test_2_message_contract_types(self):
- """
- Once composition is checked, check different message types
- """
- # message types
- request_message = RequestMessage.craft_from_arguments(
- receiver_id=self.server_id,
- sender_id=self.client_id,
- thing_id=self.thing_id,
- objekt="some_prop",
- operation="readProperty",
- )
-
- async def handle_message_types_server():
- # server to client
- # REPLY = b'REPLY' # 4 - response for operation
- # TIMEOUT = b'TIMEOUT' # 5 - timeout message, operation could not be completed
- # EXCEPTION = b'EXCEPTION' # 6 - exception occurred while executing operation
- # INVALID_MESSAGE = b'INVALID_MESSAGE' # 7 - invalid message
- await self.server._handle_timeout(request_message, timeout_type="execution") # 5
- await self.server._handle_invalid_message(request_message, SerializableData(Exception("test"))) # 7
- await self.server._handshake(request_message) # 1
- await self.server._handle_error_message(request_message, Exception("test")) # 6
- await self.server.async_send_response(request_message) # 4
- await self.server.async_send_response_with_message_type(
- request_message, ERROR, SerializableData(Exception("test"))
- ) # 6
-
- get_current_async_loop().run_until_complete(handle_message_types_server())
-
- """
- message types
-
- both directions
- HANDSHAKE = b'HANDSHAKE' # 1 - taken care by test_1...
-
- client to server
- OPERATION = b'OPERATION' 2 - taken care by test_2_... # operation request from client to server
- EXIT = b'EXIT' # 3 - taken care by test_7... # exit the server
-
- server to client
- REPLY = b'REPLY' # 4 - response for operation
- TIMEOUT = b'TIMEOUT' # 5 - timeout message, operation could not be completed
- EXCEPTION = b'EXCEPTION' # 6 - exception occurred while executing operation
- INVALID_MESSAGE = b'INVALID_MESSAGE' # 7 - invalid message
- SERVER_DISCONNECTED = 'EVENT_DISCONNECTED' not yet tested # socket died - zmq's builtin event
-
- peer to peer
- INTERRUPT = b'INTERRUPT' not yet tested # interrupt a socket while polling
- """
-
- msg = self.client.recv_response(request_message.id)
- self.assertEqual(msg.type, TIMEOUT)
- self.validate_response_message(msg)
-
- msg = self.client.recv_response(request_message.id)
- self.assertEqual(msg.type, INVALID_MESSAGE)
- self.validate_response_message(msg)
-
- msg = self.client.socket.recv_multipart() # handshake dont come as response
- response_message = ResponseMessage(msg)
- self.assertEqual(response_message.type, HANDSHAKE)
- self.validate_response_message(response_message)
-
- msg = self.client.recv_response(request_message.id)
- self.assertEqual(msg.type, ERROR)
- self.validate_response_message(msg)
-
- msg = self.client.recv_response(request_message.id)
- self.assertEqual(msg.type, REPLY)
- self.validate_response_message(msg)
-
- msg = self.client.recv_response(request_message.id)
- # custom crafted explicitly to be ERROR
- self.assertEqual(msg.type, ERROR)
- self.validate_response_message(msg)
-
- self.client.handshake()
-
- def test_3_verify_polling(self):
- """
- Test if polling may be stopped and started again
- """
-
- async def verify_poll_stopped(self: TestBasicServerAndClient) -> None:
- await self.server.poll_requests()
- self.server.poll_timeout = 1000
- await self.server.poll_requests()
- self.done_queue.put(True)
-
- async def stop_poll(self: TestBasicServerAndClient) -> None:
- await asyncio.sleep(0.1)
- self.server.stop_polling()
- await asyncio.sleep(0.1)
- self.server.stop_polling()
-
- # When the above two functions running,
- # we dont send a message as the thread is also running
- get_current_async_loop().run_until_complete(asyncio.gather(*[verify_poll_stopped(self), stop_poll(self)]))
-
- self.assertTrue(self.done_queue.get())
- self.assertEqual(self.server.poll_timeout, 1000)
- self.client.handshake()
-
- @classmethod
- def tearDownClass(cls):
- """
- Test if exit reaches to server
- """
- # EXIT = b'EXIT' # 7 - exit the server
- request_message = RequestMessage.craft_with_message_type(
- receiver_id=cls.server_id, sender_id=cls.client_id, message_type=EXIT
- )
- cls.client.socket.send_multipart(request_message.byte_array)
-
- # TODO - fix the following, somehow socket is not closing fully,
- # although we have previously tested this and its known to work.
- # try:
- # cls.client.recv_response(message_id=b'not-necessary')
- # assert False, "Expected ConnectionAbortedError"
- # except ConnectionAbortedError as ex:
- # assert str(ex).startswith(f"server disconnected for {cls.client_id}"), f"Unexpected error message: {str(ex)}"
-
- done = cls.done_queue.get(timeout=3)
- if done:
- cls._server_thread.join()
- else:
- print("Server did not properly process exit request")
- super().tearDownClass()
-
- # TODO
- # peer to peer
- # INTERRUPT = b'INTERRUPT' # interrupt a socket while polling
- # first test the length
-
-
-class TestAsyncZMQClient(TestBrokerMixin):
- @classmethod
- def setUpClient(cls):
- cls.async_client = AsyncZMQClient(
- id=cls.client_id,
- server_id=cls.server_id,
- handshake=False,
- )
- cls.client = cls.async_client
-
- @classmethod
- def setUpClass(cls):
- super().setUpClass()
-
- def test_1_handshake_complete(self):
- """
- Test handshake so that client can connect to server. Once client connects to server,
- verify a ZMQ internal monitoring socket is available.
- """
-
- async def test():
- self.client.handshake()
- await self.client.handshake_complete()
- self.assertTrue(self.client._monitor_socket is not None)
- self.assertTrue(self.client._monitor_socket in self.client.poller)
-
- get_current_async_loop().run_until_complete(test())
- # both directions
- # HANDSHAKE = 'HANDSHAKE' # 1 - find out if the server is alive
-
- def test_2_message_contract_types(self):
- """
- Once composition is checked, check different message types
- """
- # message types
- request_message = RequestMessage.craft_from_arguments(
- receiver_id=self.server_id,
- sender_id=self.client_id,
- thing_id=self.thing_id,
- objekt="some_prop",
- operation="readProperty",
- )
-
- async def handle_message_types_server():
- # server to client
- # REPLY = b'REPLY' # 4 - response for operation
- # TIMEOUT = b'TIMEOUT' # 5 - timeout message, operation could not be completed
- # EXCEPTION = b'EXCEPTION' # 6 - exception occurred while executing operation
- # INVALID_MESSAGE = b'INVALID_MESSAGE' # 7 - invalid message
- await self.server._handle_timeout(request_message, timeout_type="invokation") # 5
- await self.server._handle_invalid_message(request_message, SerializableData(Exception("test1")))
- await self.server._handshake(request_message)
- await self.server._handle_error_message(request_message, Exception("test2"))
- await self.server.async_send_response(request_message)
- await self.server.async_send_response_with_message_type(
- request_message, ERROR, SerializableData(Exception("test3"))
- )
-
- async def handle_message_types_client():
- """
- message types
- both directions
- HANDSHAKE = b'HANDSHAKE' # 1 - taken care by test_1...
-
- client to server
- OPERATION = b'OPERATION' 2 - taken care by test_2_... # operation request from client to server
- EXIT = b'EXIT' # 3 - taken care by test_7... # exit the server
-
- server to client
- REPLY = b'REPLY' # 4 - response for operation
- TIMEOUT = b'TIMEOUT' # 5 - timeout message, operation could not be completed
- EXCEPTION = b'EXCEPTION' # 6 - exception occurred while executing operation
- INVALID_MESSAGE = b'INVALID_MESSAGE' # 7 - invalid message
- SERVER_DISCONNECTED = 'EVENT_DISCONNECTED' not yet tested # socket died - zmq's builtin event
-
- peer to peer
- INTERRUPT = b'INTERRUPT' not yet tested # interrupt a socket while polling
- """
- msg = await self.client.async_recv_response(request_message.id)
- self.assertEqual(msg.type, TIMEOUT)
- self.validate_response_message(msg)
-
- msg = await self.client.async_recv_response(request_message.id)
- self.assertEqual(msg.type, INVALID_MESSAGE)
- self.validate_response_message(msg)
-
- msg = await self.client.socket.recv_multipart() # handshake don't come as response
- response_message = ResponseMessage(msg)
- self.assertEqual(response_message.type, HANDSHAKE)
- self.validate_response_message(response_message)
-
- msg = await self.client.async_recv_response(request_message.id)
- self.assertEqual(msg.type, ERROR)
- self.validate_response_message(msg)
-
- msg = await self.client.async_recv_response(request_message.id)
- self.assertEqual(msg.type, REPLY)
- self.validate_response_message(msg)
-
- msg = await self.client.async_recv_response(request_message.id)
- self.assertEqual(msg.type, ERROR)
- self.validate_response_message(msg)
-
- # exit checked separately at the end
- get_current_async_loop().run_until_complete(
- asyncio.gather(*[handle_message_types_server(), handle_message_types_client()])
- )
-
- @classmethod
- def tearDownClass(cls):
- """
- Test if exit reaches to server
- """
- # EXIT = b'EXIT' # 7 - exit the server
- request_message = RequestMessage.craft_with_message_type(
- receiver_id=cls.server_id, sender_id=cls.client_id, message_type=EXIT
- )
- cls.client.socket.send_multipart(request_message.byte_array)
- done = cls.done_queue.get(timeout=3)
-
- # TODO - check server disconnected like previous test
-
- if done:
- cls._server_thread.join()
- else:
- print("Server did not properly process exit request")
- super().tearDownClass()
-
-
-class TestMessageMappedClientPool(TestBrokerMixin):
- @classmethod
- def setUpClient(cls):
- cls.client = MessageMappedZMQClientPool(
- id="client-pool",
- client_ids=[cls.client_id],
- server_ids=[cls.server_id],
- handshake=False,
- )
- cls.client._client_to_thing_map[cls.client_id] = cls.thing_id
- cls.client._thing_to_client_map[cls.thing_id] = cls.client_id
-
- def test_1_handshake_complete(self):
- """
- Test handshake so that client can connect to server. Once client connects to server,
- verify a ZMQ internal monitoring socket is available.
- """
-
- async def test():
- self.client.handshake()
- await self.client.handshake_complete()
- for client in self.client.pool.values():
- self.assertTrue(client._monitor_socket is not None)
- self.assertTrue(client._monitor_socket in self.client.poller)
-
- get_current_async_loop().run_until_complete(test())
- # both directions
- # HANDSHAKE = 'HANDSHAKE' # 1 - find out if the server is alive
-
- def test_2_message_contract_types(self):
- """
- Once composition is checked, check different message types
- """
- # message types
- request_message = RequestMessage.craft_from_arguments(
- receiver_id=self.server_id,
- sender_id=self.client_id,
- thing_id=self.thing_id,
- objekt="some_prop",
- operation="readProperty",
- )
-
- async def handle_message_types():
- """
- message types
- both directions
- HANDSHAKE = b'HANDSHAKE' # 1 - taken care by test_1...
-
- client to server
- OPERATION = b'OPERATION' 2 - taken care by test_2_... # operation request from client to server
- EXIT = b'EXIT' # 3 - taken care by test_7... # exit the server
-
- server to client
- REPLY = b'REPLY' # 4 - response for operation
- TIMEOUT = b'TIMEOUT' # 5 - timeout message, operation could not be completed
- EXCEPTION = b'EXCEPTION' # 6 - exception occurred while executing operation
- INVALID_MESSAGE = b'INVALID_MESSAGE' # 7 - invalid message
- SERVER_DISCONNECTED = 'EVENT_DISCONNECTED' not yet tested # socket died - zmq's builtin event
-
- peer to peer
- INTERRUPT = b'INTERRUPT' not yet tested # interrupt a socket while polling
- """
- self.client.start_polling()
-
- self.client.events_map[request_message.id] = self.client.event_pool.pop()
- await self.server._handle_timeout(request_message, timeout_type="invokation") # 5
- msg = await self.client.async_recv_response(self.thing_id, request_message.id)
- self.assertEqual(msg.type, TIMEOUT)
- self.validate_response_message(msg)
-
- self.client.events_map[request_message.id] = self.client.event_pool.pop()
- await self.server._handle_invalid_message(request_message, SerializableData(Exception("test")))
- msg = await self.client.async_recv_response(self.thing_id, request_message.id)
- self.assertEqual(msg.type, INVALID_MESSAGE)
- self.validate_response_message(msg)
-
- self.client.events_map[request_message.id] = self.client.event_pool.pop()
- await self.server._handshake(request_message)
- msg = await self.client.pool[self.client_id].socket.recv_multipart() # handshake don't come as response
- response_message = ResponseMessage(msg)
- self.assertEqual(response_message.type, HANDSHAKE)
- self.validate_response_message(response_message)
-
- self.client.events_map[request_message.id] = self.client.event_pool.pop()
- await self.server.async_send_response(request_message)
- msg = await self.client.async_recv_response(self.thing_id, request_message.id)
- self.assertEqual(msg.type, REPLY)
- self.validate_response_message(msg)
-
- self.client.events_map[request_message.id] = self.client.event_pool.pop()
- await self.server.async_send_response_with_message_type(
- request_message, ERROR, SerializableData(Exception("test"))
- )
- msg = await self.client.async_recv_response(self.thing_id, request_message.id)
- self.assertEqual(msg.type, ERROR)
- self.validate_response_message(msg)
-
- self.client.stop_polling()
-
- # exit checked separately at the end
- get_current_async_loop().run_until_complete(asyncio.gather(*[handle_message_types()]))
-
- def test_3_verify_polling(self):
- """
- Test if polling may be stopped and started again
- """
-
- async def verify_poll_stopped(self: "TestMessageMappedClientPool") -> None:
- await self.client.poll_responses()
- self.client.poll_timeout = 1000
- await self.client.poll_responses()
- self.done_queue.put(True)
-
- async def stop_poll(self: "TestMessageMappedClientPool") -> None:
- await asyncio.sleep(0.1)
- self.client.stop_polling()
- await asyncio.sleep(0.1)
- self.client.stop_polling()
-
- # When the above two functions running,
- # we dont send a message as the thread is also running
- get_current_async_loop().run_until_complete(asyncio.gather(*[verify_poll_stopped(self), stop_poll(self)]))
- self.assertTrue(self.done_queue.get())
- self.assertEqual(self.client.poll_timeout, 1000)
-
- @classmethod
- def tearDownClass(cls):
- """
- Test if exit reaches to server
- """
- # EXIT = b'EXIT' # 7 - exit the server
- request_message = RequestMessage.craft_with_message_type(
- receiver_id=cls.server_id, sender_id=cls.client_id, message_type=EXIT
- )
- cls.client[cls.client_id].socket.send_multipart(request_message.byte_array)
- done = cls.done_queue.get(timeout=3)
- if done:
- cls._server_thread.join()
- else:
- print("Server did not process exit message correctly")
- super().tearDownClass()
-
-
-def load_tests(loader, tests, pattern):
- suite = unittest.TestSuite()
- suite.addTest(unittest.TestLoader().loadTestsFromTestCase(TestBasicServerAndClient))
- suite.addTest(unittest.TestLoader().loadTestsFromTestCase(TestAsyncZMQClient))
- suite.addTest(unittest.TestLoader().loadTestsFromTestCase(TestMessageMappedClientPool))
- return suite
-
-
-if __name__ == "__main__":
- runner = TestRunner()
- runner.run(load_tests(unittest.TestLoader(), None, None))
+ server_id: str
+ """RPC server ID"""
+ thing_id: str
+ """A thing ID"""
+ sync_client_id: str
+ """A synchronous client ID"""
+ async_client_id: str
+ """An asynchronous client ID"""
+ msg_mapped_async_client_id: str
+ """A message-mapped asynchronous client ID"""
+
+
+@pytest.fixture(scope="module")
+def app_ids() -> AppIDs:
+ """Generate unique test IDs for server, client, and thing for each test"""
+ return AppIDs(
+ server_id=f"test-server-{uuid_hex()}",
+ thing_id=f"test-thing-{uuid_hex()}",
+ sync_client_id=f"test-sync-client-{uuid_hex()}",
+ async_client_id=f"test-async-client-{uuid_hex()}",
+ msg_mapped_async_client_id=f"test-mapped-async-client-{uuid_hex()}",
+ )
+
+
+@pytest.fixture(scope="module")
+def server(app_ids: AppIDs) -> Generator[AsyncZMQServer, None, None]:
+ server = AsyncZMQServer(id=app_ids.server_id)
+ yield server
+ # exit written in thread
+ # server.exit()
+
+
+@pytest.fixture(scope="module")
+def sync_client(app_ids: AppIDs) -> Generator[SyncZMQClient, None, None]:
+ client = SyncZMQClient(id=app_ids.sync_client_id, server_id=app_ids.server_id, handshake=False)
+ yield client
+ client.exit()
+
+
+@pytest.fixture(scope="module")
+def async_client(app_ids: AppIDs) -> Generator[AsyncZMQClient, None, None]:
+ client = AsyncZMQClient(id=app_ids.async_client_id, server_id=app_ids.server_id, handshake=False)
+ yield client
+ client.exit()
+
+
+@pytest.fixture(scope="module")
+def message_mapped_client(app_ids: AppIDs) -> Generator[MessageMappedZMQClientPool, None, None]:
+ client = MessageMappedZMQClientPool(
+ id="client-pool",
+ client_ids=[app_ids.msg_mapped_async_client_id],
+ server_ids=[app_ids.server_id],
+ handshake=False,
+ )
+ client._client_to_thing_map[app_ids.msg_mapped_async_client_id] = app_ids.thing_id
+ client._thing_to_client_map[app_ids.thing_id] = app_ids.msg_mapped_async_client_id
+ yield client
+ client.exit()
+
+
+def run_zmq_server(server: AsyncZMQServer, done_queue: multiprocessing.Queue) -> None:
+ event_loop = get_current_async_loop()
+
+ async def run():
+ while True:
+ try:
+ messages = await server.async_recv_requests()
+ for message in messages:
+ if message.type == EXIT:
+ server.exit()
+ break
+ await asyncio.sleep(0.01)
+ except BreakLoop:
+ break
+
+ event_loop.run_until_complete(run())
+ event_loop.run_until_complete(asyncio.gather(*asyncio.all_tasks(event_loop)))
+ if done_queue:
+ done_queue.put(True)
+
+
+@pytest.fixture(scope="module", autouse=True)
+def start_server(server: AsyncZMQServer, sync_client: SyncZMQClient, app_ids: AppIDs):
+ done_queue = multiprocessing.Queue()
+ thread = threading.Thread(target=run_zmq_server, args=(server, done_queue), daemon=True)
+ thread.start()
+ yield thread
+ request_message = RequestMessage.craft_with_message_type(
+ receiver_id=app_ids.server_id,
+ sender_id=app_ids.sync_client_id,
+ message_type=EXIT,
+ )
+ sync_client.socket.send_multipart(request_message.byte_array)
+ done = done_queue.get(timeout=3)
+ if done:
+ thread.join()
+ else:
+ print("Server did not properly process exit request")
+
+
+def test_01_01_sync_client_handshake_complete(sync_client: SyncZMQClient):
+ sync_client.handshake()
+ assert sync_client._monitor_socket is not None
+ assert sync_client._monitor_socket in sync_client.poller
+
+
+async def test_01_02_sync_client_basic_message_contract_types(
+ sync_client: SyncZMQClient,
+ server: AsyncZMQServer,
+ app_ids: AppIDs,
+) -> None:
+ active_app_ids = MessageAppIDs(
+ server_id=app_ids.server_id, thing_id=app_ids.thing_id, client_id=app_ids.sync_client_id
+ )
+ request_message = RequestMessage.craft_from_arguments(
+ receiver_id=app_ids.server_id,
+ sender_id=app_ids.sync_client_id,
+ thing_id=app_ids.thing_id,
+ objekt="some_prop",
+ operation="readproperty",
+ )
+
+ await server._handle_timeout(request_message, timeout_type="execution")
+ await server._handle_invalid_message(request_message, SerializableData(Exception("test")))
+ await server._handshake(request_message)
+ await server._handle_error_message(request_message, Exception("test"))
+ await server.async_send_response(request_message)
+ await server.async_send_response_with_message_type(request_message, ERROR, SerializableData(Exception("test")))
+
+ msg = sync_client.recv_response(request_message.id)
+ assert msg.type == TIMEOUT
+ validate_response_message(msg, app_ids=active_app_ids)
+
+ msg = sync_client.recv_response(request_message.id)
+ assert msg.type == INVALID_MESSAGE
+ validate_response_message(msg, app_ids=active_app_ids)
+
+ msg = sync_client.socket.recv_multipart()
+ response_message = ResponseMessage(msg)
+ assert response_message.type == HANDSHAKE
+ validate_response_message(response_message, app_ids=active_app_ids)
+
+ msg = sync_client.recv_response(request_message.id)
+ assert msg.type == ERROR
+ validate_response_message(msg, app_ids=active_app_ids)
+
+ msg = sync_client.recv_response(request_message.id)
+ assert msg.type == REPLY
+ validate_response_message(msg, app_ids=active_app_ids)
+
+ msg = sync_client.recv_response(request_message.id)
+ assert msg.type == ERROR
+ validate_response_message(msg, app_ids=active_app_ids)
+ sync_client.handshake()
+
+
+async def test_01_03_sync_client_polling(sync_client: SyncZMQClient, server: AsyncZMQServer):
+ done = asyncio.Future()
+
+ async def verify_poll_stopped():
+ await server.poll_requests()
+ server.poll_timeout = 1000
+ await server.poll_requests()
+ done.set_result(True)
+
+ async def stop_poll():
+ await asyncio.sleep(0.1)
+ server.stop_polling()
+ await asyncio.sleep(0.1)
+ server.stop_polling()
+
+ await asyncio.gather(verify_poll_stopped(), stop_poll())
+ await done
+ assert server.poll_timeout == 1000
+ sync_client.handshake()
+
+
+async def test_async_client_handshake_complete(async_client: AsyncZMQClient):
+ async_client.handshake()
+ await async_client.handshake_complete()
+ assert async_client._monitor_socket is not None
+ assert async_client._monitor_socket in async_client.poller
+
+
+async def test_02_01_async_client_message_contract_types(
+ async_client: AsyncZMQClient,
+ server: AsyncZMQServer,
+ app_ids: AppIDs,
+) -> None:
+ active_app_ids = MessageAppIDs(
+ server_id=app_ids.server_id,
+ thing_id=app_ids.thing_id,
+ client_id=app_ids.async_client_id,
+ )
+
+ request_message = RequestMessage.craft_from_arguments(
+ receiver_id=app_ids.server_id,
+ sender_id=app_ids.async_client_id,
+ thing_id=app_ids.thing_id,
+ objekt="some_prop",
+ operation="readproperty",
+ )
+
+ await server._handle_timeout(request_message, timeout_type="invokation")
+ await server._handle_invalid_message(request_message, SerializableData(Exception("test1")))
+ await server._handshake(request_message)
+ await server._handle_error_message(request_message, Exception("test2"))
+ await server.async_send_response(request_message)
+ await server.async_send_response_with_message_type(request_message, ERROR, SerializableData(Exception("test3")))
+
+ msg = await async_client.async_recv_response(request_message.id)
+ assert msg.type == TIMEOUT
+ validate_response_message(msg, app_ids=active_app_ids)
+
+ msg = await async_client.async_recv_response(request_message.id)
+ assert msg.type == INVALID_MESSAGE
+ validate_response_message(msg, app_ids=active_app_ids)
+
+ msg = await async_client.socket.recv_multipart()
+ response_message = ResponseMessage(msg)
+ assert response_message.type == HANDSHAKE
+ validate_response_message(response_message, app_ids=active_app_ids)
+
+ msg = await async_client.async_recv_response(request_message.id)
+ assert msg.type == ERROR
+ validate_response_message(msg, app_ids=active_app_ids)
+
+ msg = await async_client.async_recv_response(request_message.id)
+ assert msg.type == REPLY
+ validate_response_message(msg, app_ids=active_app_ids)
+
+ msg = await async_client.async_recv_response(request_message.id)
+ assert msg.type == ERROR
+ validate_response_message(msg, app_ids=active_app_ids)
+
+
+async def test_03_01_mapped_handshake_complete(message_mapped_client: MessageMappedZMQClientPool):
+ message_mapped_client.handshake()
+ await message_mapped_client.handshake_complete()
+ for client in message_mapped_client.pool.values():
+ assert client._monitor_socket is not None
+ assert client._monitor_socket in message_mapped_client.poller
+
+
+async def test_mapped_message_contract_types(
+ message_mapped_client: MessageMappedZMQClientPool,
+ server: AsyncZMQServer,
+ app_ids: AppIDs,
+) -> None:
+ active_app_ids = MessageAppIDs(
+ server_id=app_ids.server_id,
+ thing_id=app_ids.thing_id,
+ client_id=app_ids.msg_mapped_async_client_id,
+ )
+ request_message = RequestMessage.craft_from_arguments(
+ receiver_id=app_ids.server_id,
+ sender_id=app_ids.msg_mapped_async_client_id,
+ thing_id=app_ids.thing_id,
+ objekt="some_prop",
+ operation="readproperty",
+ )
+
+ message_mapped_client.start_polling()
+
+ message_mapped_client.events_map[request_message.id] = message_mapped_client.event_pool.pop()
+ await server._handle_timeout(request_message, timeout_type="invokation")
+ msg = await message_mapped_client.async_recv_response(app_ids.thing_id, request_message.id)
+ assert msg.type == TIMEOUT
+ validate_response_message(msg, app_ids=active_app_ids)
+
+ message_mapped_client.events_map[request_message.id] = message_mapped_client.event_pool.pop()
+ await server._handle_invalid_message(request_message, SerializableData(Exception("test")))
+ msg = await message_mapped_client.async_recv_response(app_ids.thing_id, request_message.id)
+ assert msg.type == INVALID_MESSAGE
+ validate_response_message(msg, app_ids=active_app_ids)
+
+ message_mapped_client.events_map[request_message.id] = message_mapped_client.event_pool.pop()
+ await server._handshake(request_message)
+ msg = await message_mapped_client.pool[app_ids.msg_mapped_async_client_id].socket.recv_multipart()
+ response_message = ResponseMessage(msg)
+ assert response_message.type == HANDSHAKE
+ validate_response_message(response_message, app_ids=active_app_ids)
+
+ message_mapped_client.events_map[request_message.id] = message_mapped_client.event_pool.pop()
+ await server.async_send_response(request_message)
+ msg = await message_mapped_client.async_recv_response(app_ids.thing_id, request_message.id)
+ assert msg.type == REPLY
+ validate_response_message(msg, app_ids=active_app_ids)
+
+ message_mapped_client.events_map[request_message.id] = message_mapped_client.event_pool.pop()
+ await server.async_send_response_with_message_type(request_message, ERROR, SerializableData(Exception("test")))
+ msg = await message_mapped_client.async_recv_response(app_ids.thing_id, request_message.id)
+ assert msg.type == ERROR
+ validate_response_message(msg, app_ids=active_app_ids)
+
+ message_mapped_client.stop_polling()
+
+
+async def test_03_02_mapped_verify_polling(message_mapped_client: MessageMappedZMQClientPool):
+ done = asyncio.Future()
+
+ async def verify_poll_stopped():
+ await message_mapped_client.poll_responses()
+ message_mapped_client.poll_timeout = 1000
+ await message_mapped_client.poll_responses()
+ done.set_result(True)
+
+ async def stop_poll():
+ await asyncio.sleep(0.1)
+ message_mapped_client.stop_polling()
+ await asyncio.sleep(0.1)
+ message_mapped_client.stop_polling()
+
+ await asyncio.gather(verify_poll_stopped(), stop_poll())
+ await done
+ assert message_mapped_client.poll_timeout == 1000
diff --git a/tests/test_06_actions.py b/tests/test_06_actions.py
index 6b04997e..5d7da13f 100644
--- a/tests/test_06_actions.py
+++ b/tests/test_06_actions.py
@@ -1,523 +1,455 @@
import asyncio
-import unittest
-import logging
-from hololinked.utils import isclassmethod
+from copy import deepcopy
+
+import pytest
+
from hololinked.core.actions import (
Action,
BoundAction,
- BoundSyncAction,
BoundAsyncAction,
+ BoundSyncAction,
)
from hololinked.core.dataklasses import ActionInfoValidator
-from hololinked.core.thing import Thing, action
-from hololinked.td.interaction_affordance import ActionAffordance
+from hololinked.core.thing import action
from hololinked.schema_validators import JSONSchemaValidator
-from hololinked.logger import setup_logging
+from hololinked.td.interaction_affordance import ActionAffordance
+from hololinked.utils import isclassmethod
+
try:
- from .utils import TestCase, TestRunner
from .things import TestThing
from .things.test_thing import replace_methods_with_actions
except ImportError:
- from utils import TestCase, TestRunner
from things import TestThing
from things.test_thing import replace_methods_with_actions
-setup_logging(log_level=logging.ERROR)
-
-
-class TestAction(TestCase):
- @classmethod
- def setUpClass(cls):
- super().setUpClass()
- print(f"test action with {cls.__name__}")
-
- def test_1_allowed_actions(self):
- """Test if methods can be decorated with action"""
- # 1. instance method can be decorated with action
- self.assertEqual(TestThing.action_echo, action()(TestThing.action_echo.obj)) # already predecorated as action
- # 2. classmethod can be decorated with action
- self.assertEqual(
- Action(TestThing.action_echo_with_classmethod),
- action()(TestThing.action_echo_with_classmethod),
- )
- self.assertTrue(isclassmethod(TestThing.action_echo_with_classmethod))
- # 3. async methods can be decorated with action
- self.assertEqual(Action(TestThing.action_echo_async), action()(TestThing.action_echo_async))
- # 4. async classmethods can be decorated with action
- self.assertEqual(
- Action(TestThing.action_echo_async_with_classmethod),
- action()(TestThing.action_echo_async_with_classmethod),
- )
- self.assertTrue(isclassmethod(TestThing.action_echo_async_with_classmethod))
- # 5. parameterized function can be decorated with action
- self.assertEqual(
- Action(TestThing.parameterized_action),
- action(safe=True)(TestThing.parameterized_action),
- )
- self.assertEqual(
- Action(TestThing.parameterized_action_without_call),
- action(idempotent=True)(TestThing.parameterized_action_without_call),
- )
- self.assertEqual(
- Action(TestThing.parameterized_action_async),
- action(synchronous=True)(TestThing.parameterized_action_async),
- )
- # 6. actions with input and output schema
- self.assertEqual(
- Action(TestThing.json_schema_validated_action),
- action(
- input_schema={
- "val1": "integer",
- "val2": "string",
- "val3": "object",
- "val4": "array",
- },
- output_schema={"val1": "int", "val3": "dict"},
- )(TestThing.json_schema_validated_action),
- )
- self.assertEqual(
- Action(TestThing.pydantic_validated_action),
- action()(TestThing.pydantic_validated_action),
- )
-
- def test_2_bound_method(self):
- """Test if methods decorated with action are correctly bound"""
- thing = TestThing(id="test-action")
- replace_methods_with_actions(thing_cls=TestThing)
-
- # 1. instance method can be decorated with action
- self.assertIsInstance(thing.action_echo, BoundAction)
- self.assertIsInstance(thing.action_echo, BoundSyncAction)
- self.assertNotIsInstance(thing.action_echo, BoundAsyncAction)
- self.assertIsInstance(TestThing.action_echo, Action)
- self.assertNotIsInstance(TestThing.action_echo, BoundAction)
- # associated attributes of BoundAction
- assert isinstance(thing.action_echo, BoundAction) # type definition
- self.assertEqual(thing.action_echo.name, "action_echo")
- self.assertEqual(thing.action_echo.owner_inst, thing)
- self.assertEqual(thing.action_echo.owner, TestThing)
- self.assertEqual(thing.action_echo.execution_info, TestThing.action_echo.execution_info)
- self.assertEqual(
- str(thing.action_echo),
- f"",
- )
- self.assertNotEqual(thing.action_echo, TestThing.action_echo)
- self.assertEqual(thing.action_echo.bound_obj, thing)
-
- # 2. classmethod can be decorated with action
- self.assertIsInstance(thing.action_echo_with_classmethod, BoundAction)
- self.assertIsInstance(thing.action_echo_with_classmethod, BoundSyncAction)
- self.assertNotIsInstance(thing.action_echo_with_classmethod, BoundAsyncAction)
- self.assertIsInstance(TestThing.action_echo_with_classmethod, BoundAction)
- self.assertIsInstance(TestThing.action_echo_with_classmethod, BoundSyncAction)
- self.assertNotIsInstance(TestThing.action_echo_with_classmethod, Action)
- # associated attributes of BoundAction
- assert isinstance(thing.action_echo_with_classmethod, BoundAction)
- self.assertEqual(thing.action_echo_with_classmethod.name, "action_echo_with_classmethod")
- self.assertEqual(thing.action_echo_with_classmethod.owner_inst, thing)
- self.assertEqual(thing.action_echo_with_classmethod.owner, TestThing)
- self.assertEqual(
- thing.action_echo_with_classmethod.execution_info,
- TestThing.action_echo_with_classmethod.execution_info,
- )
- self.assertEqual(
- str(thing.action_echo_with_classmethod),
- f"",
- )
- self.assertEqual(thing.action_echo_with_classmethod, TestThing.action_echo_with_classmethod)
- self.assertEqual(thing.action_echo_with_classmethod.bound_obj, TestThing)
-
- # 3. async methods can be decorated with action
- self.assertIsInstance(thing.action_echo_async, BoundAction)
- self.assertNotIsInstance(thing.action_echo_async, BoundSyncAction)
- self.assertIsInstance(thing.action_echo_async, BoundAsyncAction)
- self.assertIsInstance(TestThing.action_echo_async, Action)
- self.assertNotIsInstance(TestThing.action_echo_async, BoundAction)
- # associated attributes of BoundAction
- assert isinstance(thing.action_echo_async, BoundAction)
- self.assertEqual(thing.action_echo_async.name, "action_echo_async")
- self.assertEqual(thing.action_echo_async.owner_inst, thing)
- self.assertEqual(thing.action_echo_async.owner, TestThing)
- self.assertEqual(
- thing.action_echo_async.execution_info,
- TestThing.action_echo_async.execution_info,
- )
- self.assertEqual(
- str(thing.action_echo_async),
- f"",
- )
- self.assertNotEqual(thing.action_echo_async, TestThing.action_echo_async)
- self.assertEqual(thing.action_echo_async.bound_obj, thing)
-
- # 4. async classmethods can be decorated with action
- self.assertIsInstance(thing.action_echo_async_with_classmethod, BoundAction)
- self.assertNotIsInstance(thing.action_echo_async_with_classmethod, BoundSyncAction)
- self.assertIsInstance(thing.action_echo_async_with_classmethod, BoundAsyncAction)
- self.assertIsInstance(TestThing.action_echo_async_with_classmethod, BoundAction)
- self.assertIsInstance(TestThing.action_echo_async_with_classmethod, BoundAsyncAction)
- self.assertNotIsInstance(TestThing.action_echo_async_with_classmethod, Action)
- # associated attributes of BoundAction
- assert isinstance(thing.action_echo_async_with_classmethod, BoundAction)
- self.assertEqual(
- thing.action_echo_async_with_classmethod.name,
- "action_echo_async_with_classmethod",
- )
- self.assertEqual(thing.action_echo_async_with_classmethod.owner_inst, thing)
- self.assertEqual(thing.action_echo_async_with_classmethod.owner, TestThing)
- self.assertEqual(
- thing.action_echo_async_with_classmethod.execution_info,
- TestThing.action_echo_async_with_classmethod.execution_info,
- )
- self.assertEqual(
- str(thing.action_echo_async_with_classmethod),
- f"",
- )
- self.assertEqual(
- thing.action_echo_async_with_classmethod,
- TestThing.action_echo_async_with_classmethod,
- )
- self.assertEqual(thing.action_echo_async_with_classmethod.bound_obj, TestThing)
-
- # 5. parameterized function can be decorated with action
- self.assertIsInstance(thing.parameterized_action, BoundAction)
- self.assertIsInstance(thing.parameterized_action, BoundSyncAction)
- self.assertNotIsInstance(thing.parameterized_action, BoundAsyncAction)
- self.assertIsInstance(TestThing.parameterized_action, Action)
- self.assertNotIsInstance(TestThing.parameterized_action, BoundAction)
- # associated attributes of BoundAction
- assert isinstance(thing.parameterized_action, BoundAction)
- self.assertEqual(thing.parameterized_action.name, "parameterized_action")
- self.assertEqual(thing.parameterized_action.owner_inst, thing)
- self.assertEqual(thing.parameterized_action.owner, TestThing)
- self.assertEqual(
- thing.parameterized_action.execution_info,
- TestThing.parameterized_action.execution_info,
- )
- self.assertEqual(
- str(thing.parameterized_action),
- f"",
- )
- self.assertNotEqual(thing.parameterized_action, TestThing.parameterized_action)
- self.assertEqual(thing.parameterized_action.bound_obj, thing)
-
- # 6. parameterized function can be decorated with action
- self.assertIsInstance(thing.parameterized_action_without_call, BoundAction)
- self.assertIsInstance(thing.parameterized_action_without_call, BoundSyncAction)
- self.assertNotIsInstance(thing.parameterized_action_without_call, BoundAsyncAction)
- self.assertIsInstance(TestThing.parameterized_action_without_call, Action)
- self.assertNotIsInstance(TestThing.parameterized_action_without_call, BoundAction)
- # associated attributes of BoundAction
- assert isinstance(thing.parameterized_action_without_call, BoundAction)
- self.assertEqual(
- thing.parameterized_action_without_call.name,
- "parameterized_action_without_call",
- )
- self.assertEqual(thing.parameterized_action_without_call.owner_inst, thing)
- self.assertEqual(thing.parameterized_action_without_call.owner, TestThing)
- self.assertEqual(
- thing.parameterized_action_without_call.execution_info,
- TestThing.parameterized_action_without_call.execution_info,
- )
- self.assertEqual(
- str(thing.parameterized_action_without_call),
- f"",
- )
- self.assertNotEqual(
- thing.parameterized_action_without_call,
- TestThing.parameterized_action_without_call,
- )
- self.assertEqual(thing.parameterized_action_without_call.bound_obj, thing)
-
- # 7. parameterized function can be decorated with action
- self.assertIsInstance(thing.parameterized_action_async, BoundAction)
- self.assertNotIsInstance(thing.parameterized_action_async, BoundSyncAction)
- self.assertIsInstance(thing.parameterized_action_async, BoundAsyncAction)
- self.assertIsInstance(TestThing.parameterized_action_async, Action)
- self.assertNotIsInstance(TestThing.parameterized_action_async, BoundAction)
- # associated attributes of BoundAction
- assert isinstance(thing.parameterized_action_async, BoundAction)
- self.assertEqual(thing.parameterized_action_async.name, "parameterized_action_async")
- self.assertEqual(thing.parameterized_action_async.owner_inst, thing)
- self.assertEqual(thing.parameterized_action_async.owner, TestThing)
- self.assertEqual(
- thing.parameterized_action_async.execution_info,
- TestThing.parameterized_action_async.execution_info,
- )
- self.assertEqual(
- str(thing.parameterized_action_async),
- f"",
- )
- self.assertNotEqual(thing.parameterized_action_async, TestThing.parameterized_action_async)
- self.assertEqual(thing.parameterized_action_async.bound_obj, thing)
-
- # 8. actions with input and output schema
- self.assertIsInstance(thing.json_schema_validated_action, BoundAction)
- self.assertIsInstance(thing.json_schema_validated_action, BoundSyncAction)
- self.assertNotIsInstance(thing.json_schema_validated_action, BoundAsyncAction)
- self.assertIsInstance(TestThing.json_schema_validated_action, Action)
- self.assertNotIsInstance(TestThing.json_schema_validated_action, BoundAction)
- # associated attributes of BoundAction
- assert isinstance(thing.json_schema_validated_action, BoundAction)
- self.assertEqual(thing.json_schema_validated_action.name, "json_schema_validated_action")
- self.assertEqual(thing.json_schema_validated_action.owner_inst, thing)
- self.assertEqual(thing.json_schema_validated_action.owner, TestThing)
- self.assertEqual(
- thing.json_schema_validated_action.execution_info,
- TestThing.json_schema_validated_action.execution_info,
- )
- self.assertEqual(
- str(thing.json_schema_validated_action),
- f"",
- )
- self.assertNotEqual(thing.json_schema_validated_action, TestThing.json_schema_validated_action)
- self.assertEqual(thing.json_schema_validated_action.bound_obj, thing)
-
- def test_3_remote_info(self):
- """Test if the validator is working correctly, on which the logic of the action is based"""
- # basic check if the remote_info is correct, although this test is not necessary, not recommended and
- # neither particularly useful
- remote_info = TestThing.action_echo.execution_info
- self.assertIsInstance(remote_info, ActionInfoValidator)
- assert isinstance(remote_info, ActionInfoValidator) # type definition
- self.assertTrue(remote_info.isaction)
- self.assertFalse(remote_info.isproperty)
- self.assertFalse(remote_info.isparameterized)
- self.assertFalse(remote_info.iscoroutine)
- self.assertFalse(remote_info.safe)
- self.assertFalse(remote_info.idempotent)
- self.assertTrue(remote_info.synchronous)
-
- remote_info = TestThing.action_echo_async.execution_info
- self.assertIsInstance(remote_info, ActionInfoValidator)
- assert isinstance(remote_info, ActionInfoValidator) # type definition
- self.assertTrue(remote_info.isaction)
- self.assertTrue(remote_info.iscoroutine)
- self.assertFalse(remote_info.isproperty)
- self.assertFalse(remote_info.isparameterized)
- self.assertFalse(remote_info.safe)
- self.assertFalse(remote_info.idempotent)
- self.assertTrue(remote_info.synchronous)
-
- remote_info = TestThing.action_echo_with_classmethod.execution_info
- self.assertIsInstance(remote_info, ActionInfoValidator)
- assert isinstance(remote_info, ActionInfoValidator) # type definition
- self.assertTrue(remote_info.isaction)
- self.assertFalse(remote_info.iscoroutine)
- self.assertFalse(remote_info.isproperty)
- self.assertFalse(remote_info.isparameterized)
- self.assertFalse(remote_info.safe)
- self.assertFalse(remote_info.idempotent)
- self.assertTrue(remote_info.synchronous)
-
- remote_info = TestThing.parameterized_action.execution_info
- self.assertIsInstance(remote_info, ActionInfoValidator)
- assert isinstance(remote_info, ActionInfoValidator)
- self.assertTrue(remote_info.isaction)
- self.assertFalse(remote_info.iscoroutine)
- self.assertFalse(remote_info.isproperty)
- self.assertTrue(remote_info.isparameterized)
- self.assertTrue(remote_info.safe)
- self.assertFalse(remote_info.idempotent)
- self.assertTrue(remote_info.synchronous)
-
- remote_info = TestThing.parameterized_action_without_call.execution_info
- self.assertIsInstance(remote_info, ActionInfoValidator)
- assert isinstance(remote_info, ActionInfoValidator)
- self.assertTrue(remote_info.isaction)
- self.assertFalse(remote_info.iscoroutine)
- self.assertFalse(remote_info.isproperty)
- self.assertTrue(remote_info.isparameterized)
- self.assertFalse(remote_info.safe)
- self.assertTrue(remote_info.idempotent)
- self.assertTrue(remote_info.synchronous)
-
- remote_info = TestThing.parameterized_action_async.execution_info
- self.assertIsInstance(remote_info, ActionInfoValidator)
- assert isinstance(remote_info, ActionInfoValidator)
- self.assertTrue(remote_info.isaction)
- self.assertTrue(remote_info.iscoroutine)
- self.assertFalse(remote_info.isproperty)
- self.assertTrue(remote_info.isparameterized)
- self.assertFalse(remote_info.safe)
- self.assertFalse(remote_info.idempotent)
- self.assertTrue(remote_info.synchronous)
-
- remote_info = TestThing.json_schema_validated_action.execution_info
- self.assertIsInstance(remote_info, ActionInfoValidator)
- assert isinstance(remote_info, ActionInfoValidator)
- self.assertTrue(remote_info.isaction)
- self.assertFalse(remote_info.iscoroutine)
- self.assertFalse(remote_info.isproperty)
- self.assertFalse(remote_info.isparameterized)
- self.assertFalse(remote_info.safe)
- self.assertFalse(remote_info.idempotent)
- self.assertTrue(remote_info.synchronous)
- self.assertIsInstance(remote_info.schema_validator, JSONSchemaValidator)
-
- def test_4_api_and_invalid_actions(self):
- """Test if action prevents invalid objects from being named as actions and raises neat errors"""
- # done allow action decorator to be terminated without '()' on a method
- with self.assertRaises(TypeError) as ex:
- action(TestThing.incorrectly_decorated_method)
- self.assertTrue(
- str(ex.exception).startswith(
- "input schema should be a JSON or pydantic BaseModel, not a function/method, did you decorate your action wrongly?"
- )
- )
-
- # dunder methods cannot be decorated with action
- with self.assertRaises(ValueError) as ex:
- action()(TestThing.__internal__)
- self.assertTrue(str(ex.exception).startswith("dunder objects cannot become remote"))
-
- # only functions and methods can be decorated with action
- for obj in [
- TestThing,
- str,
- 1,
- 1.0,
- "Str",
- True,
- None,
- object(),
- type,
- property,
- ]:
- with self.assertRaises(TypeError) as ex:
- action()(obj) # not an action
- self.assertTrue(str(ex.exception).startswith("target for action or is not a function/method."))
-
- with self.assertRaises(ValueError) as ex:
- action(safe=True, some_kw=1)
- self.assertTrue(str(ex.exception).startswith("Only 'safe', 'idempotent', 'synchronous' are allowed"))
-
- # TODO - rename this test
- def test_5_thing_cls_actions(self):
- """Test class and instance level action access"""
- thing = TestThing(id="test-action")
- # class level
- for name, action in TestThing.actions.descriptors.items():
- self.assertIsInstance(action, Action)
- for name in replace_methods_with_actions._exposed_actions:
- self.assertTrue(name in TestThing.actions)
- # instance level
- for name, action in thing.actions.values.items():
- self.assertIsInstance(action, BoundAction)
- for name in replace_methods_with_actions._exposed_actions:
- self.assertTrue(name in thing.actions)
- # cannot call an instance bound action at class level
- self.assertRaises(NotImplementedError, lambda: TestThing.action_echo(thing, 1))
- # but can call instance bound action with instance
- self.assertEqual(1, thing.action_echo(1))
- # can also call classmethods as usual
- self.assertEqual(2, TestThing.action_echo_with_classmethod(2))
- self.assertEqual(3, thing.action_echo_with_classmethod(3))
- # async methods behave similarly
- self.assertEqual(4, asyncio.run(thing.action_echo_async(4)))
- self.assertEqual(5, asyncio.run(TestThing.action_echo_async_with_classmethod(5)))
- self.assertRaises(NotImplementedError, lambda: asyncio.run(TestThing.action_echo(7)))
- # parameterized actions behave similarly
- self.assertEqual(
- ("test-action", 1, "hello1", 1.1),
- thing.parameterized_action(1, "hello1", 1.1),
- )
- self.assertEqual(
- ("test-action", 2, "hello2", "foo2"),
- asyncio.run(thing.parameterized_action_async(2, "hello2", "foo2")),
- )
- self.assertRaises(NotImplementedError, lambda: TestThing.parameterized_action(3, "hello3", 5))
- self.assertRaises(
- NotImplementedError,
- lambda: asyncio.run(TestThing.parameterized_action_async(4, "hello4", 5)),
- )
-
- def test_6_action_affordance(self):
- """Test if action affordance is correctly created"""
- thing = TestThing(id="test-action")
-
- assert isinstance(thing.action_echo, BoundAction) # type definition
- affordance = thing.action_echo.to_affordance()
- self.assertIsInstance(affordance, ActionAffordance)
- self.assertIsNone(affordance.idempotent) # by default, not idempotent
- self.assertTrue(affordance.synchronous) # by default, not synchronous
- self.assertIsNone(affordance.safe) # by default, not safe
- self.assertIsNone(affordance.input) # no input schema
- self.assertIsNone(affordance.output) # no output schema
- self.assertIsNone(affordance.description) # no doc
-
- assert isinstance(thing.action_echo_with_classmethod, BoundAction) # type definition
- affordance = thing.action_echo_with_classmethod.to_affordance()
- self.assertIsInstance(affordance, ActionAffordance)
- self.assertIsNone(affordance.idempotent) # by default, not idempotent
- self.assertTrue(affordance.synchronous) # by default, synchronous
- self.assertIsNone(affordance.safe) # by default, not safe
- self.assertIsNone(affordance.input) # no input schema
- self.assertIsNone(affordance.output) # no output schema
- self.assertIsNone(affordance.description) # no doc
-
- assert isinstance(thing.action_echo_async, BoundAction) # type definition
- affordance = thing.action_echo_async.to_affordance()
- self.assertIsInstance(affordance, ActionAffordance)
- self.assertIsNone(affordance.idempotent) # by default, not idempotent
- self.assertTrue(affordance.synchronous) # by default, synchronous
- self.assertIsNone(affordance.safe) # by default, not safe
- self.assertIsNone(affordance.input) # no input schema
- self.assertIsNone(affordance.output) # no output schema
- self.assertIsNone(affordance.description) # no doc
-
- assert isinstance(thing.action_echo_async_with_classmethod, BoundAction) # type definition
- affordance = thing.action_echo_async_with_classmethod.to_affordance()
- self.assertIsInstance(affordance, ActionAffordance)
- self.assertIsNone(affordance.idempotent) # by default, not idempotent
- self.assertTrue(affordance.synchronous) # by default, synchronous
- self.assertIsNone(affordance.safe) # by default, not safe
- self.assertIsNone(affordance.input) # no input schema
- self.assertIsNone(affordance.output) # no output schema
- self.assertIsNone(affordance.description) # no doc
-
- assert isinstance(thing.parameterized_action, BoundAction) # type definition
- affordance = thing.parameterized_action.to_affordance()
- self.assertIsInstance(affordance, ActionAffordance)
- self.assertIsNone(affordance.idempotent)
- self.assertTrue(affordance.synchronous)
- self.assertTrue(affordance.safe)
- self.assertIsNone(affordance.input)
- self.assertIsNone(affordance.output)
- self.assertIsNone(affordance.description)
-
- assert isinstance(thing.parameterized_action_without_call, BoundAction) # type definition
- affordance = thing.parameterized_action_without_call.to_affordance()
- self.assertIsInstance(affordance, ActionAffordance)
- self.assertTrue(affordance.idempotent) # by default, not idempotent
- self.assertTrue(affordance.synchronous) # by default, synchronous
- self.assertIsNone(affordance.safe) # by default, not safe
- self.assertIsNone(affordance.input) # no input schema
- self.assertIsNone(affordance.output) # no output schema
- self.assertIsNone(affordance.description) # no doc
-
- assert isinstance(thing.parameterized_action_async, BoundAction) # type definition
- affordance = thing.parameterized_action_async.to_affordance()
- self.assertIsInstance(affordance, ActionAffordance)
- self.assertIsNone(affordance.idempotent) # by default, not idempotent
- self.assertTrue(affordance.synchronous) # by default, not synchronous
- self.assertIsNone(affordance.safe) # by default, not safe
- self.assertIsNone(affordance.input) # no input schema
- self.assertIsNone(affordance.output) # no output schema
- self.assertIsNone(affordance.description) # no doc
-
- assert isinstance(thing.json_schema_validated_action, BoundAction) # type definition
- affordance = thing.json_schema_validated_action.to_affordance()
- self.assertIsInstance(affordance, ActionAffordance)
- self.assertIsNone(affordance.idempotent) # by default, not idempotent
- self.assertTrue(affordance.synchronous) # by default, not synchronous
- self.assertIsNone(affordance.safe) # by default, not safe
- self.assertIsInstance(affordance.input, dict)
- self.assertIsInstance(affordance.output, dict)
- self.assertIsNone(affordance.description) # no doc
-
-
-if __name__ == "__main__":
- unittest.main(testRunner=TestRunner())
+@pytest.fixture(scope="module")
+def thing() -> TestThing:
+ thing_cls = deepcopy(TestThing)
+ _thing = thing_cls(id="test-action")
+ replace_methods_with_actions(thing_cls=thing_cls)
+ return _thing
+
+
+def test_01_allowed_actions():
+ """Test if methods can be decorated with action"""
+ # 1. instance method can be decorated with action
+ assert TestThing.action_echo == action()(TestThing.action_echo.obj) # already predecorated as action
+ # 2. classmethod can be decorated with action
+ assert Action(TestThing.action_echo_with_classmethod) == action()(TestThing.action_echo_with_classmethod)
+ assert isclassmethod(TestThing.action_echo_with_classmethod)
+ # 3. async methods can be decorated with action
+ assert Action(TestThing.action_echo_async) == action()(TestThing.action_echo_async)
+ # 4. async classmethods can be decorated with action
+ assert Action(TestThing.action_echo_async_with_classmethod) == action()(
+ TestThing.action_echo_async_with_classmethod
+ )
+ assert isclassmethod(TestThing.action_echo_async_with_classmethod)
+ # 5. parameterized function can be decorated with action
+ assert Action(TestThing.parameterized_action) == action(safe=True)(TestThing.parameterized_action)
+ assert Action(TestThing.parameterized_action_without_call) == action(idempotent=True)(
+ TestThing.parameterized_action_without_call
+ )
+ assert Action(TestThing.parameterized_action_async) == action(synchronous=True)(
+ TestThing.parameterized_action_async
+ )
+ # 6. actions with input and output schema
+ assert Action(TestThing.json_schema_validated_action) == action(
+ input_schema={
+ "val1": "integer",
+ "val2": "string",
+ "val3": "object",
+ "val4": "array",
+ },
+ output_schema={"val1": "int", "val3": "dict"},
+ )(TestThing.json_schema_validated_action)
+ assert Action(TestThing.pydantic_validated_action) == action()(TestThing.pydantic_validated_action)
+
+
+def test_02_bound_method(thing: TestThing):
+ """Test if methods decorated with action are correctly bound"""
+ # 1. instance method can be decorated with action
+ assert isinstance(thing.action_echo, BoundAction)
+ assert isinstance(thing.action_echo, BoundSyncAction)
+ assert not isinstance(thing.action_echo, BoundAsyncAction)
+ assert isinstance(TestThing.action_echo, Action)
+ assert not isinstance(TestThing.action_echo, BoundAction)
+ # associated attributes of BoundAction
+ assert isinstance(thing.action_echo, BoundAction)
+ assert thing.action_echo.name == "action_echo"
+ assert thing.action_echo.owner_inst == thing
+ assert thing.action_echo.owner == TestThing
+ assert thing.action_echo.execution_info == TestThing.action_echo.execution_info
+ assert str(thing.action_echo) == f""
+ assert thing.action_echo != TestThing.action_echo
+ assert thing.action_echo.bound_obj == thing
+
+ # 2. classmethod can be decorated with action
+ assert isinstance(thing.action_echo_with_classmethod, BoundAction)
+ assert isinstance(thing.action_echo_with_classmethod, BoundSyncAction)
+ assert not isinstance(thing.action_echo_with_classmethod, BoundAsyncAction)
+ assert isinstance(TestThing.action_echo_with_classmethod, BoundAction)
+ assert isinstance(TestThing.action_echo_with_classmethod, BoundSyncAction)
+ assert not isinstance(TestThing.action_echo_with_classmethod, Action)
+ # associated attributes of BoundAction
+ assert isinstance(thing.action_echo_with_classmethod, BoundAction)
+ assert thing.action_echo_with_classmethod.name == "action_echo_with_classmethod"
+ assert thing.action_echo_with_classmethod.owner_inst == thing
+ assert thing.action_echo_with_classmethod.owner == TestThing
+ assert thing.action_echo_with_classmethod.execution_info == TestThing.action_echo_with_classmethod.execution_info
+ assert (
+ str(thing.action_echo_with_classmethod)
+ == f""
+ )
+ assert thing.action_echo_with_classmethod == TestThing.action_echo_with_classmethod
+ assert thing.action_echo_with_classmethod.bound_obj == TestThing
+
+ # 3. async methods can be decorated with action
+ assert isinstance(thing.action_echo_async, BoundAction)
+ assert not isinstance(thing.action_echo_async, BoundSyncAction)
+ assert isinstance(thing.action_echo_async, BoundAsyncAction)
+ assert isinstance(TestThing.action_echo_async, Action)
+ assert not isinstance(TestThing.action_echo_async, BoundAction)
+ # associated attributes of BoundAction
+ assert isinstance(thing.action_echo_async, BoundAction)
+ assert thing.action_echo_async.name == "action_echo_async"
+ assert thing.action_echo_async.owner_inst == thing
+ assert thing.action_echo_async.owner == TestThing
+ assert thing.action_echo_async.execution_info == TestThing.action_echo_async.execution_info
+ assert (
+ str(thing.action_echo_async)
+ == f""
+ )
+ assert thing.action_echo_async != TestThing.action_echo_async
+ assert thing.action_echo_async.bound_obj == thing
+
+ # 4. async classmethods can be decorated with action
+ assert isinstance(thing.action_echo_async_with_classmethod, BoundAction)
+ assert not isinstance(thing.action_echo_async_with_classmethod, BoundSyncAction)
+ assert isinstance(thing.action_echo_async_with_classmethod, BoundAsyncAction)
+ assert isinstance(TestThing.action_echo_async_with_classmethod, BoundAction)
+ assert isinstance(TestThing.action_echo_async_with_classmethod, BoundAsyncAction)
+ assert not isinstance(TestThing.action_echo_async_with_classmethod, Action)
+ # associated attributes of BoundAction
+ assert isinstance(thing.action_echo_async_with_classmethod, BoundAction)
+ assert thing.action_echo_async_with_classmethod.name == "action_echo_async_with_classmethod"
+ assert thing.action_echo_async_with_classmethod.owner_inst == thing
+ assert thing.action_echo_async_with_classmethod.owner == TestThing
+ assert (
+ thing.action_echo_async_with_classmethod.execution_info
+ == TestThing.action_echo_async_with_classmethod.execution_info
+ )
+ assert (
+ str(thing.action_echo_async_with_classmethod)
+ == f""
+ )
+ assert thing.action_echo_async_with_classmethod == TestThing.action_echo_async_with_classmethod
+ assert thing.action_echo_async_with_classmethod.bound_obj == TestThing
+
+ # 5. parameterized function can be decorated with action
+ assert isinstance(thing.parameterized_action, BoundAction)
+ assert isinstance(thing.parameterized_action, BoundSyncAction)
+ assert not isinstance(thing.parameterized_action, BoundAsyncAction)
+ assert isinstance(TestThing.parameterized_action, Action)
+ assert not isinstance(TestThing.parameterized_action, BoundAction)
+ # associated attributes of BoundAction
+ assert isinstance(thing.parameterized_action, BoundAction)
+ assert thing.parameterized_action.name == "parameterized_action"
+ assert thing.parameterized_action.owner_inst == thing
+ assert thing.parameterized_action.owner == TestThing
+ assert thing.parameterized_action.execution_info == TestThing.parameterized_action.execution_info
+ assert (
+ str(thing.parameterized_action)
+ == f""
+ )
+ assert thing.parameterized_action != TestThing.parameterized_action
+ assert thing.parameterized_action.bound_obj == thing
+
+ # 6. parameterized function can be decorated with action
+ assert isinstance(thing.parameterized_action_without_call, BoundAction)
+ assert isinstance(thing.parameterized_action_without_call, BoundSyncAction)
+ assert not isinstance(thing.parameterized_action_without_call, BoundAsyncAction)
+ assert isinstance(TestThing.parameterized_action_without_call, Action)
+ assert not isinstance(TestThing.parameterized_action_without_call, BoundAction)
+ # associated attributes of BoundAction
+ assert isinstance(thing.parameterized_action_without_call, BoundAction)
+ assert thing.parameterized_action_without_call.name == "parameterized_action_without_call"
+ assert thing.parameterized_action_without_call.owner_inst == thing
+ assert thing.parameterized_action_without_call.owner == TestThing
+ assert (
+ thing.parameterized_action_without_call.execution_info
+ == TestThing.parameterized_action_without_call.execution_info
+ )
+ assert (
+ str(thing.parameterized_action_without_call)
+ == f""
+ )
+ assert thing.parameterized_action_without_call != TestThing.parameterized_action_without_call
+ assert thing.parameterized_action_without_call.bound_obj == thing
+
+ # 7. parameterized function can be decorated with action
+ assert isinstance(thing.parameterized_action_async, BoundAction)
+ assert not isinstance(thing.parameterized_action_async, BoundSyncAction)
+ assert isinstance(thing.parameterized_action_async, BoundAsyncAction)
+ assert isinstance(TestThing.parameterized_action_async, Action)
+ assert not isinstance(TestThing.parameterized_action_async, BoundAction)
+ # associated attributes of BoundAction
+ assert isinstance(thing.parameterized_action_async, BoundAction)
+ assert thing.parameterized_action_async.name == "parameterized_action_async"
+ assert thing.parameterized_action_async.owner_inst == thing
+ assert thing.parameterized_action_async.owner == TestThing
+ assert thing.parameterized_action_async.execution_info == TestThing.parameterized_action_async.execution_info
+ assert (
+ str(thing.parameterized_action_async)
+ == f""
+ )
+ assert thing.parameterized_action_async != TestThing.parameterized_action_async
+ assert thing.parameterized_action_async.bound_obj == thing
+
+ # 8. actions with input and output schema
+ assert isinstance(thing.json_schema_validated_action, BoundAction)
+ assert isinstance(thing.json_schema_validated_action, BoundSyncAction)
+ assert not isinstance(thing.json_schema_validated_action, BoundAsyncAction)
+ assert isinstance(TestThing.json_schema_validated_action, Action)
+ assert not isinstance(TestThing.json_schema_validated_action, BoundAction)
+ # associated attributes of BoundAction
+ assert isinstance(thing.json_schema_validated_action, BoundAction)
+ assert thing.json_schema_validated_action.name == "json_schema_validated_action"
+ assert thing.json_schema_validated_action.owner_inst == thing
+ assert thing.json_schema_validated_action.owner == TestThing
+ assert thing.json_schema_validated_action.execution_info == TestThing.json_schema_validated_action.execution_info
+ assert (
+ str(thing.json_schema_validated_action)
+ == f""
+ )
+ assert thing.json_schema_validated_action != TestThing.json_schema_validated_action
+ assert thing.json_schema_validated_action.bound_obj == thing
+
+
+def test_03_remote_info():
+ """Test if the validator is working correctly, on which the logic of the action is based"""
+ remote_info = TestThing.action_echo.execution_info
+ assert isinstance(remote_info, ActionInfoValidator)
+ assert remote_info.isaction
+ assert not remote_info.isproperty
+ assert not remote_info.isparameterized
+ assert not remote_info.iscoroutine
+ assert not remote_info.safe
+ assert not remote_info.idempotent
+ assert remote_info.synchronous
+
+ remote_info = TestThing.action_echo_async.execution_info
+ assert isinstance(remote_info, ActionInfoValidator)
+ assert remote_info.isaction
+ assert remote_info.iscoroutine
+ assert not remote_info.isproperty
+ assert not remote_info.isparameterized
+ assert not remote_info.safe
+ assert not remote_info.idempotent
+ assert remote_info.synchronous
+
+ remote_info = TestThing.action_echo_with_classmethod.execution_info
+ assert isinstance(remote_info, ActionInfoValidator)
+ assert remote_info.isaction
+ assert not remote_info.iscoroutine
+ assert not remote_info.isproperty
+ assert not remote_info.isparameterized
+ assert not remote_info.safe
+ assert not remote_info.idempotent
+ assert remote_info.synchronous
+
+ remote_info = TestThing.parameterized_action.execution_info
+ assert isinstance(remote_info, ActionInfoValidator)
+ assert remote_info.isaction
+ assert not remote_info.iscoroutine
+ assert not remote_info.isproperty
+ assert remote_info.isparameterized
+ assert remote_info.safe
+ assert not remote_info.idempotent
+ assert remote_info.synchronous
+
+ remote_info = TestThing.parameterized_action_without_call.execution_info
+ assert isinstance(remote_info, ActionInfoValidator)
+ assert remote_info.isaction
+ assert not remote_info.iscoroutine
+ assert not remote_info.isproperty
+ assert remote_info.isparameterized
+ assert not remote_info.safe
+ assert remote_info.idempotent
+ assert remote_info.synchronous
+
+ remote_info = TestThing.parameterized_action_async.execution_info
+ assert isinstance(remote_info, ActionInfoValidator)
+ assert remote_info.isaction
+ assert remote_info.iscoroutine
+ assert not remote_info.isproperty
+ assert remote_info.isparameterized
+ assert not remote_info.safe
+ assert not remote_info.idempotent
+ assert remote_info.synchronous
+
+ remote_info = TestThing.json_schema_validated_action.execution_info
+ assert isinstance(remote_info, ActionInfoValidator)
+ assert remote_info.isaction
+ assert not remote_info.iscoroutine
+ assert not remote_info.isproperty
+ assert not remote_info.isparameterized
+ assert not remote_info.safe
+ assert not remote_info.idempotent
+ assert remote_info.synchronous
+ assert isinstance(remote_info.schema_validator, JSONSchemaValidator)
+
+
+def test_04_api_and_invalid_actions():
+ """Test if action prevents invalid objects from being named as actions and raises neat errors"""
+ # done allow action decorator to be terminated without '()' on a method
+ with pytest.raises(TypeError) as ex:
+ action(TestThing.incorrectly_decorated_method)
+ assert str(ex.value).startswith(
+ "input schema should be a JSON or pydantic BaseModel, not a function/method, did you decorate your action wrongly?"
+ )
+
+ # dunder methods cannot be decorated with action
+ with pytest.raises(ValueError) as ex:
+ action()(TestThing.__internal__)
+ assert str(ex.value).startswith("dunder objects cannot become remote")
+
+ # only functions and methods can be decorated with action
+ for obj in [
+ TestThing,
+ str,
+ 1,
+ 1.0,
+ "Str",
+ True,
+ None,
+ object(),
+ type,
+ property,
+ ]:
+ with pytest.raises(TypeError) as ex2:
+ action()(obj)
+ assert str(ex2.value).startswith("target for action or is not a function/method.")
+
+ with pytest.raises(ValueError) as ex:
+ action(safe=True, some_kw=1)
+ assert str(ex.value).startswith("Only 'safe', 'idempotent', 'synchronous' are allowed")
+
+
+def test_05_thing_cls_actions(thing: TestThing):
+ """Test class and instance level action access"""
+ # class level
+ for name, act in TestThing.actions.descriptors.items():
+ assert isinstance(act, Action)
+ for name in replace_methods_with_actions._exposed_actions:
+ assert name in TestThing.actions
+ # instance level
+ for name, act in thing.actions.values.items():
+ assert isinstance(act, BoundAction)
+ for name in replace_methods_with_actions._exposed_actions:
+ assert name in thing.actions
+ # cannot call an instance bound action at class level
+ with pytest.raises(NotImplementedError):
+ TestThing.action_echo(thing, 1)
+ # but can call instance bound action with instance
+ assert thing.action_echo(1) == 1
+ # can also call classmethods as usual
+ assert TestThing.action_echo_with_classmethod(2) == 2
+ assert thing.action_echo_with_classmethod(3) == 3
+ # async methods behave similarly
+ assert asyncio.run(thing.action_echo_async(4)) == 4
+ assert asyncio.run(TestThing.action_echo_async_with_classmethod(5)) == 5
+ with pytest.raises(NotImplementedError):
+ asyncio.run(TestThing.action_echo(7))
+ # parameterized actions behave similarly
+ assert thing.parameterized_action(1, "hello1", 1.1) == ("test-action", 1, "hello1", 1.1)
+ assert asyncio.run(thing.parameterized_action_async(2, "hello2", "foo2")) == ("test-action", 2, "hello2", "foo2")
+ with pytest.raises(NotImplementedError):
+ TestThing.parameterized_action(3, "hello3", 5)
+ with pytest.raises(NotImplementedError):
+ asyncio.run(TestThing.parameterized_action_async(4, "hello4", 5))
+
+
+def test_06_action_affordance(thing: TestThing):
+ """Test if action affordance is correctly created"""
+ assert isinstance(thing.action_echo, BoundAction)
+ affordance = thing.action_echo.to_affordance()
+ assert isinstance(affordance, ActionAffordance)
+ assert affordance.idempotent is None
+ assert affordance.synchronous is True
+ assert affordance.safe is None
+ assert affordance.input is None
+ assert affordance.output is None
+ assert affordance.description is None
+
+ assert isinstance(thing.action_echo_with_classmethod, BoundAction)
+ affordance = thing.action_echo_with_classmethod.to_affordance()
+ assert isinstance(affordance, ActionAffordance)
+ assert affordance.idempotent is None
+ assert affordance.synchronous is True
+ assert affordance.safe is None
+ assert affordance.input is None
+ assert affordance.output is None
+ assert affordance.description is None
+
+ assert isinstance(thing.action_echo_async, BoundAction)
+ affordance = thing.action_echo_async.to_affordance()
+ assert isinstance(affordance, ActionAffordance)
+ assert affordance.idempotent is None
+ assert affordance.synchronous is True
+ assert affordance.safe is None
+ assert affordance.input is None
+ assert affordance.output is None
+ assert affordance.description is None
+
+ assert isinstance(thing.action_echo_async_with_classmethod, BoundAction)
+ affordance = thing.action_echo_async_with_classmethod.to_affordance()
+ assert isinstance(affordance, ActionAffordance)
+ assert affordance.idempotent is None
+ assert affordance.synchronous is True
+ assert affordance.safe is None
+ assert affordance.input is None
+ assert affordance.output is None
+ assert affordance.description is None
+
+ assert isinstance(thing.parameterized_action, BoundAction)
+ affordance = thing.parameterized_action.to_affordance()
+ assert isinstance(affordance, ActionAffordance)
+ assert affordance.idempotent is None
+ assert affordance.synchronous is True
+ assert affordance.safe is True
+ assert affordance.input is None
+ assert affordance.output is None
+ assert affordance.description is None
+
+ assert isinstance(thing.parameterized_action_without_call, BoundAction)
+ affordance = thing.parameterized_action_without_call.to_affordance()
+ assert isinstance(affordance, ActionAffordance)
+ assert affordance.idempotent is True
+ assert affordance.synchronous is True
+ assert affordance.safe is None
+ assert affordance.input is None
+ assert affordance.output is None
+ assert affordance.description is None
+
+ assert isinstance(thing.parameterized_action_async, BoundAction)
+ affordance = thing.parameterized_action_async.to_affordance()
+ assert isinstance(affordance, ActionAffordance)
+ assert affordance.idempotent is None
+ assert affordance.synchronous is True
+ assert affordance.safe is None
+ assert affordance.input is None
+ assert affordance.output is None
+ assert affordance.description is None
+
+ assert isinstance(thing.json_schema_validated_action, BoundAction)
+ affordance = thing.json_schema_validated_action.to_affordance()
+ assert isinstance(affordance, ActionAffordance)
+ assert affordance.idempotent is None
+ assert affordance.synchronous is True
+ assert affordance.safe is None
+ assert isinstance(affordance.input, dict)
+ assert isinstance(affordance.output, dict)
+ assert affordance.description is None
diff --git a/tests/test_07_properties.py b/tests/test_07_properties.py
index 8aaadf54..1bce0622 100644
--- a/tests/test_07_properties.py
+++ b/tests/test_07_properties.py
@@ -1,306 +1,302 @@
-import logging
-import unittest
-import tempfile
-import os
import copy
+import json
+import os
+import tempfile
+
+from dataclasses import dataclass
+from typing import Callable
+
import pydantic
+import pytest
from hololinked.core.properties import Number
from hololinked.storage.database import BaseDB, ThingDB
-from hololinked.serializers import PythonBuiltinJSONSerializer
-from hololinked.logger import setup_logging
+from hololinked.utils import uuid_hex
+
try:
- from .utils import TestCase, TestRunner
from .things import TestThing
except ImportError:
- from utils import TestCase, TestRunner
from things import TestThing
-setup_logging(log_level=logging.ERROR)
-
-
-class TestProperty(TestCase):
- def test_01_simple_class_property(self):
- """Test basic class property functionality"""
- # Test class-level access
- self.assertEqual(TestThing.simple_class_prop, 42)
- TestThing.simple_class_prop = 100
- self.assertEqual(TestThing.simple_class_prop, 100)
-
- # Test that instance-level access reflects class value
- instance1 = TestThing(id="test1")
- instance2 = TestThing(id="test2")
- self.assertEqual(instance1.simple_class_prop, 100)
- self.assertEqual(instance2.simple_class_prop, 100)
-
- # Test that instance-level changes affect class value
- instance1.simple_class_prop = 200
- self.assertEqual(TestThing.simple_class_prop, 200)
- self.assertEqual(instance2.simple_class_prop, 200)
-
- def test_02_managed_class_property(self):
- """Test class property with custom getter/setter"""
- # Test initial value
- self.assertEqual(TestThing.managed_class_prop, 0)
- # Test valid value assignment
- TestThing.managed_class_prop = 50
- self.assertEqual(TestThing.managed_class_prop, 50)
- # Test validation in setter
- with self.assertRaises(ValueError):
- TestThing.managed_class_prop = -10
- # Verify value wasn't changed after failed assignment
- self.assertEqual(TestThing.managed_class_prop, 50)
- # Test instance-level validation
- instance = TestThing(id="test3")
- with self.assertRaises(ValueError):
- instance.managed_class_prop = -20
- # Test that instance-level access reflects class value
- self.assertEqual(instance.managed_class_prop, 50)
- # Test that instance-level changes affects class value
- instance.managed_class_prop = 100
- self.assertEqual(TestThing.managed_class_prop, 100)
- self.assertEqual(instance.managed_class_prop, 100)
-
- def test_03_readonly_class_property(self):
- """Test read-only class property behavior"""
- # Test reading the value
- self.assertEqual(TestThing.readonly_class_prop, "read-only-value")
-
- # Test that setting raises an error at class level
- with self.assertRaises(ValueError):
- TestThing.readonly_class_prop = "new-value"
-
- # Test that setting raises an error at instance level
- instance = TestThing(id="test4")
- with self.assertRaises(ValueError):
- instance.readonly_class_prop = "new-value"
-
- # Verify value remains unchanged
- self.assertEqual(TestThing.readonly_class_prop, "read-only-value")
- self.assertEqual(instance.readonly_class_prop, "read-only-value")
-
- def test_04_deletable_class_property(self):
- """Test class property deletion"""
- # Test initial value
- self.assertEqual(TestThing.deletable_class_prop, 100)
-
- # Test setting new value
- TestThing.deletable_class_prop = 150
- self.assertEqual(TestThing.deletable_class_prop, 150)
-
- # Test deletion
- instance = TestThing(id="test5")
- del TestThing.deletable_class_prop
- self.assertEqual(TestThing.deletable_class_prop, 100) # Should return to default
- self.assertEqual(instance.deletable_class_prop, 100)
-
- # Test instance-level deletion
- instance.deletable_class_prop = 200
- self.assertEqual(TestThing.deletable_class_prop, 200)
- del instance.deletable_class_prop
- self.assertEqual(TestThing.deletable_class_prop, 100) # Should return to default
-
- def test_05_descriptor_access(self):
- """Test descriptor access for class properties"""
- # Test direct access through descriptor
- instance = TestThing(id="test6")
- self.assertIsInstance(TestThing.not_a_class_prop, Number)
- self.assertEqual(instance.not_a_class_prop, 43)
- instance.not_a_class_prop = 50
- self.assertEqual(instance.not_a_class_prop, 50)
-
- del instance.not_a_class_prop
- # deleter deletes only an internal instance variable
- self.assertTrue(hasattr(TestThing, "not_a_class_prop"))
- self.assertEqual(instance.not_a_class_prop, 43)
-
- del TestThing.not_a_class_prop
- # descriptor itself is deleted
- self.assertFalse(hasattr(TestThing, "not_a_class_prop"))
- self.assertFalse(hasattr(instance, "not_a_class_prop"))
- with self.assertRaises(AttributeError):
- instance.not_a_class_prop
-
- def _generate_db_ops_tests(self) -> None:
- def test_prekill(thing: TestThing) -> None:
- self.assertEqual(thing.db_commit_number_prop, 0)
- thing.db_commit_number_prop = 100
- self.assertEqual(thing.db_commit_number_prop, 100)
- self.assertEqual(thing.db_engine.get_property("db_commit_number_prop"), 100)
-
- # test db persist property
- self.assertEqual(thing.db_persist_selector_prop, "a")
- thing.db_persist_selector_prop = "c"
- self.assertEqual(thing.db_persist_selector_prop, "c")
- self.assertEqual(thing.db_engine.get_property("db_persist_selector_prop"), "c")
-
- # test db init property
- self.assertEqual(thing.db_init_int_prop, TestThing.db_init_int_prop.default)
- thing.db_init_int_prop = 50
- self.assertEqual(thing.db_init_int_prop, 50)
- self.assertNotEqual(thing.db_engine.get_property("db_init_int_prop"), 50)
- self.assertEqual(
- thing.db_engine.get_property("db_init_int_prop"),
- TestThing.db_init_int_prop.default,
- )
- del thing
-
- def test_postkill(thing: TestThing) -> None:
- # deleted thing and reload from database
- self.assertEqual(thing.db_init_int_prop, TestThing.db_init_int_prop.default)
- self.assertEqual(thing.db_persist_selector_prop, "c")
- self.assertNotEqual(thing.db_commit_number_prop, 100)
- self.assertEqual(thing.db_commit_number_prop, TestThing.db_commit_number_prop.default)
-
- return test_prekill, test_postkill
-
- def test_06_sqlalchemy_db_operations(self):
- """Test SQLAlchemy database operations"""
- thing_id = "test-db-operations"
- file_path = f"{thing_id}.db"
- try:
- os.remove(file_path)
- except (OSError, FileNotFoundError):
- pass
- self.assertTrue(not os.path.exists(file_path))
-
- test_prekill, test_postkill = self._generate_db_ops_tests()
-
- thing = TestThing(id=thing_id, use_default_db=True)
- test_prekill(thing)
-
- thing = TestThing(id=thing_id, use_default_db=True)
- test_postkill(thing)
-
- def test_07_json_db_operations(self):
- with tempfile.NamedTemporaryFile(delete=False) as tf:
- filename = tf.name
-
- thing_id = "test-db-operations-json"
- test_prekill, test_postkill = self._generate_db_ops_tests()
-
- thing = TestThing(
- id=thing_id,
- use_json_file=True,
- json_filename=filename,
- )
- test_prekill(thing)
-
- thing = TestThing(
- id=thing_id,
- use_json_file=True,
- json_filename=filename,
- )
- test_postkill(thing)
-
- os.remove(filename)
-
- def test_08_db_config(self):
- """Test database configuration options"""
- thing = TestThing(id="test-sql-config")
-
- # ----- SQL config tests -----
- sql_db_config = {
- "provider": "postgresql",
- "host": "localhost",
- "port": 5432,
- "database": "hololinked",
- "user": "hololinked",
- "password": "postgresnonadminpassword",
- }
- with open("test_sql_config.json", "w") as f:
- PythonBuiltinJSONSerializer.dump(sql_db_config, f)
-
- # correct config
+@dataclass
+class Defaults:
+ SIMPLE_CLASS_PROP: int = 42
+ MANAGED_CLASS_PROP: int = 0
+ DELETABLE_CLASS_PROP: int = 100
+
+
+@pytest.fixture(autouse=True)
+def reset_class_properties():
+ # Reset class properties to defaults before each test
+ TestThing.simple_class_prop = Defaults.SIMPLE_CLASS_PROP
+ TestThing.managed_class_prop = Defaults.MANAGED_CLASS_PROP
+ TestThing.deletable_class_prop = Defaults.DELETABLE_CLASS_PROP
+
+ yield
+
+
+def test_01_simple_class_property():
+ # Test class-level access
+ assert TestThing.simple_class_prop == Defaults.SIMPLE_CLASS_PROP
+ TestThing.simple_class_prop = 100
+ assert TestThing.simple_class_prop == 100
+
+ # Test that instance-level access reflects class value
+ instance1 = TestThing(id=f"test-simple-class-prop-{uuid_hex()}")
+ instance2 = TestThing(id=f"test-simple-class-prop-{uuid_hex()}")
+ assert instance1.simple_class_prop == 100
+ assert instance2.simple_class_prop == 100
+
+ # Test that instance-level changes affect class value
+ instance1.simple_class_prop = 200
+ assert TestThing.simple_class_prop == 200
+ assert instance2.simple_class_prop == 200
+
+
+def test_02_managed_class_property():
+ # Test initial value
+ assert TestThing.managed_class_prop == Defaults.MANAGED_CLASS_PROP
+ # Test valid value assignment
+ TestThing.managed_class_prop = 50
+ assert TestThing.managed_class_prop == 50
+ # Test validation in setter
+ with pytest.raises(ValueError):
+ TestThing.managed_class_prop = -10
+ # Verify value wasn't changed after failed assignment
+ assert TestThing.managed_class_prop == 50
+ # Test instance-level validation
+ instance = TestThing(id=f"test-managed-class-prop-{uuid_hex()}")
+ with pytest.raises(ValueError):
+ instance.managed_class_prop = -20
+ # Test that instance-level access reflects class value
+ assert instance.managed_class_prop == 50
+ # Test that instance-level changes affects class value
+ instance.managed_class_prop = 100
+ assert TestThing.managed_class_prop == 100
+ assert instance.managed_class_prop == 100
+
+
+def test_03_readonly_class_property():
+ # Test reading the value
+ assert TestThing.readonly_class_prop == "read-only-value"
+
+ # Test that setting raises an error at class level
+ with pytest.raises(ValueError):
+ TestThing.readonly_class_prop = "new-value"
+
+ # Test that setting raises an error at instance level
+ instance = TestThing(id=f"test-readonly-class-prop-{uuid_hex()}")
+ with pytest.raises(ValueError):
+ instance.readonly_class_prop = "new-value"
+
+ # Verify value remains unchanged
+ assert TestThing.readonly_class_prop == "read-only-value"
+ assert instance.readonly_class_prop == "read-only-value"
+
+
+def test_04_deletable_class_property():
+ # Test initial value
+ assert TestThing.deletable_class_prop == Defaults.DELETABLE_CLASS_PROP
+
+ # Test setting new value
+ TestThing.deletable_class_prop = 150
+ assert TestThing.deletable_class_prop == 150
+
+ # Test deletion
+ instance = TestThing(id=f"test-deletable-class-prop-{uuid_hex()}")
+ del TestThing.deletable_class_prop
+ assert TestThing.deletable_class_prop == Defaults.DELETABLE_CLASS_PROP # Should return to default
+ assert instance.deletable_class_prop == Defaults.DELETABLE_CLASS_PROP
+
+ # Test instance-level deletion
+ instance.deletable_class_prop = 200
+ assert TestThing.deletable_class_prop == 200
+ del instance.deletable_class_prop
+ assert TestThing.deletable_class_prop == Defaults.DELETABLE_CLASS_PROP # Should return to default
+
+
+def test_05_descriptor_access():
+ # Test direct access through descriptor
+ instance = TestThing(id=f"test-descriptor-access-{uuid_hex()}")
+ assert isinstance(TestThing.not_a_class_prop, Number)
+ assert instance.not_a_class_prop == 43
+ instance.not_a_class_prop = 50
+ assert instance.not_a_class_prop == 50
+
+ del instance.not_a_class_prop
+ # deleter deletes only an internal instance variable
+ assert hasattr(TestThing, "not_a_class_prop")
+ assert instance.not_a_class_prop == 43
+
+ del TestThing.not_a_class_prop
+ # descriptor itself is deleted
+ assert not hasattr(TestThing, "not_a_class_prop")
+ assert not hasattr(instance, "not_a_class_prop")
+ with pytest.raises(AttributeError):
+ _ = instance.not_a_class_prop
+
+
+@pytest.fixture(scope="module")
+def db_ops_tests() -> tuple[Callable, Callable]:
+ def test_prekill(thing: TestThing):
+ assert thing.db_commit_number_prop == 0
+ thing.db_commit_number_prop = 100
+ assert thing.db_commit_number_prop == 100
+ assert thing.db_engine.get_property("db_commit_number_prop") == 100
+
+ # test db persist property
+ assert thing.db_persist_selector_prop == "a"
+ thing.db_persist_selector_prop = "c"
+ assert thing.db_persist_selector_prop == "c"
+ assert thing.db_engine.get_property("db_persist_selector_prop") == "c"
+
+ # test db init property
+ assert thing.db_init_int_prop == TestThing.db_init_int_prop.default
+ thing.db_init_int_prop = 50
+ assert thing.db_init_int_prop == 50
+ assert thing.db_engine.get_property("db_init_int_prop") != 50
+ assert thing.db_engine.get_property("db_init_int_prop") == TestThing.db_init_int_prop.default
+ del thing
+
+ def test_postkill(thing: TestThing):
+ # deleted thing and reload from database
+ assert thing.db_init_int_prop == TestThing.db_init_int_prop.default
+ assert thing.db_persist_selector_prop == "c"
+ assert thing.db_commit_number_prop != 100
+ assert thing.db_commit_number_prop == TestThing.db_commit_number_prop.default
+
+ return test_prekill, test_postkill
+
+
+def test_06_sqlalchemy_db_operations(db_ops_tests: tuple[Callable, Callable]):
+ thing_id = "test-db-operations"
+ file_path = f"{thing_id}.db"
+ try:
+ os.remove(file_path)
+ except (OSError, FileNotFoundError):
+ pass
+ assert not os.path.exists(file_path)
+
+ test_prekill, test_postkill = db_ops_tests
+
+ thing = TestThing(id=thing_id, use_default_db=True)
+ test_prekill(thing)
+
+ thing = TestThing(id=thing_id, use_default_db=True)
+ test_postkill(thing)
+
+
+def test_07_json_db_operations(db_ops_tests: tuple[Callable, Callable]):
+ with tempfile.NamedTemporaryFile(delete=False) as tf:
+ filename = tf.name
+
+ thing_id = f"test-db-operations-json-{uuid_hex()}"
+ test_prekill, test_postkill = db_ops_tests
+
+ thing = TestThing(id=thing_id, use_json_file=True, json_filename=filename)
+ test_prekill(thing)
+
+ thing = TestThing(id=thing_id, use_json_file=True, json_filename=filename)
+ test_postkill(thing)
+
+ os.remove(filename)
+
+
+def test_08_db_config():
+ thing = TestThing(id=f"test-sql-config-{uuid_hex()}")
+
+ # ----- SQL config tests -----
+ sql_db_config = {
+ "provider": "postgresql",
+ "host": "localhost",
+ "port": 5432,
+ "database": "hololinked",
+ "user": "hololinked",
+ "password": "postgresnonadminpassword",
+ }
+ with open("test_sql_config.json", "w") as f:
+ json.dump(sql_db_config, f)
+
+ # correct config
+ ThingDB(thing, config_file="test_sql_config.json")
+ # foreign field
+ sql_db_config_2 = copy.deepcopy(sql_db_config)
+ sql_db_config_2["passworda"] = "postgresnonadminpassword"
+ with open("test_sql_config.json", "w") as f:
+ json.dump(sql_db_config_2, f)
+ with pytest.raises(pydantic.ValidationError):
ThingDB(thing, config_file="test_sql_config.json")
- # foreign field
- sql_db_config_2 = copy.deepcopy(sql_db_config)
- sql_db_config_2["passworda"] = "postgresnonadminpassword"
- with open("test_sql_config.json", "w") as f:
- PythonBuiltinJSONSerializer.dump(sql_db_config_2, f)
- self.assertRaises(
- pydantic.ValidationError,
- ThingDB,
- thing,
- config_file="test_sql_config.json",
- )
- # missing field
- sql_db_config_3 = copy.deepcopy(sql_db_config)
- sql_db_config_3.pop("password")
- with open("test_sql_config.json", "w") as f:
- PythonBuiltinJSONSerializer.dump(sql_db_config_3, f)
- self.assertRaises(
- ValueError,
- ThingDB,
- thing,
- config_file="test_sql_config.json",
- )
- # URI instead of other fields
- sql_db_config = dict(
- provider="postgresql",
- uri="postgresql://hololinked:postgresnonadminpassword@localhost:5432/hololinked",
- )
- with open("test_sql_config.json", "w") as f:
- PythonBuiltinJSONSerializer.dump(sql_db_config, f)
+ # missing field
+ sql_db_config_3 = copy.deepcopy(sql_db_config)
+ sql_db_config_3.pop("password")
+ with open("test_sql_config.json", "w") as f:
+ json.dump(sql_db_config_3, f)
+ with pytest.raises(ValueError):
ThingDB(thing, config_file="test_sql_config.json")
-
- os.remove("test_sql_config.json")
-
- # ----- MongoDB config tests -----
- mongo_db_config = {
- "provider": "mongo",
- "host": "localhost",
- "port": 27017,
- "database": "hololinked",
- "user": "hololinked",
- "password": "mongononadminpassword",
- "authSource": "admin",
- }
- with open("test_mongo_config.json", "w") as f:
- PythonBuiltinJSONSerializer.dump(mongo_db_config, f)
-
- # correct config
+ # URI instead of other fields
+ sql_db_config = dict(
+ provider="postgresql",
+ uri="postgresql://hololinked:postgresnonadminpassword@localhost:5432/hololinked",
+ )
+ with open("test_sql_config.json", "w") as f:
+ json.dump(sql_db_config, f)
+ ThingDB(thing, config_file="test_sql_config.json")
+
+ os.remove("test_sql_config.json")
+
+ # ----- MongoDB config tests -----
+ mongo_db_config = {
+ "provider": "mongo",
+ "host": "localhost",
+ "port": 27017,
+ "database": "hololinked",
+ "user": "hololinked",
+ "password": "mongononadminpassword",
+ "authSource": "admin",
+ }
+ with open("test_mongo_config.json", "w") as f:
+ json.dump(mongo_db_config, f)
+
+ # correct config
+ BaseDB.load_conf("test_mongo_config.json")
+ # foreign field
+ mongo_db_config_2 = copy.deepcopy(mongo_db_config)
+ mongo_db_config_2["passworda"] = "mongononadminpassword"
+ with open("test_mongo_config.json", "w") as f:
+ json.dump(mongo_db_config_2, f)
+ with pytest.raises(pydantic.ValidationError):
BaseDB.load_conf("test_mongo_config.json")
- # foreign field
- mongo_db_config_2 = copy.deepcopy(mongo_db_config)
- mongo_db_config_2["passworda"] = "mongononadminpassword"
- with open("test_mongo_config.json", "w") as f:
- PythonBuiltinJSONSerializer.dump(mongo_db_config_2, f)
- self.assertRaises(pydantic.ValidationError, BaseDB.load_conf, "test_mongo_config.json")
- # missing field
- mongo_db_config_3 = copy.deepcopy(mongo_db_config)
- mongo_db_config_3.pop("password")
- with open("test_mongo_config.json", "w") as f:
- PythonBuiltinJSONSerializer.dump(mongo_db_config_3, f)
- self.assertRaises(ValueError, BaseDB.load_conf, "test_mongo_config.json")
- # URI instead of other fields
- mongo_db_config = dict(
- provider="mongo",
- uri="mongodb://hololinked:mongononadminpassword@localhost:27017/hololinked?authSource=admin",
- )
- with open("test_mongo_config.json", "w") as f:
- PythonBuiltinJSONSerializer.dump(mongo_db_config, f)
- # correct config
+ # missing field
+ mongo_db_config_3 = copy.deepcopy(mongo_db_config)
+ mongo_db_config_3.pop("password")
+ with open("test_mongo_config.json", "w") as f:
+ json.dump(mongo_db_config_3, f)
+ with pytest.raises(ValueError):
BaseDB.load_conf("test_mongo_config.json")
-
- os.remove("test_mongo_config.json")
-
- # ----- SQLite config tests -----
-
- sqlite_db_config = {
- "provider": "sqlite",
- "file": "test_sqlite.db",
- }
- with open("test_sqlite_config.json", "w") as f:
- PythonBuiltinJSONSerializer.dump(sqlite_db_config, f)
-
- # correct config
- ThingDB(thing, config_file="test_sqlite_config.json")
-
- os.remove("test_sqlite_config.json")
-
-
-if __name__ == "__main__":
- unittest.main(testRunner=TestRunner())
+ # URI instead of other fields
+ mongo_db_config = dict(
+ provider="mongo",
+ uri="mongodb://hololinked:mongononadminpassword@localhost:27017/hololinked?authSource=admin",
+ )
+ with open("test_mongo_config.json", "w") as f:
+ json.dump(mongo_db_config, f)
+ # correct config
+ BaseDB.load_conf("test_mongo_config.json")
+
+ os.remove("test_mongo_config.json")
+
+ # ----- SQLite config tests -----
+
+ sqlite_db_config = {
+ "provider": "sqlite",
+ "file": "test_sqlite.db",
+ }
+ with open("test_sqlite_config.json", "w") as f:
+ json.dump(sqlite_db_config, f)
+
+ # correct config
+ ThingDB(thing, config_file="test_sqlite_config.json")
+
+ os.remove("test_sqlite_config.json")
diff --git a/tests/test_08_events.py b/tests/test_08_events.py
index 3b3df10a..9248c733 100644
--- a/tests/test_08_events.py
+++ b/tests/test_08_events.py
@@ -1,112 +1,71 @@
-import unittest
-import logging
-
from hololinked.core.events import Event, EventDispatcher
from hololinked.core.zmq.brokers import EventPublisher
from hololinked.td.interaction_affordance import EventAffordance
-from hololinked.logger import setup_logging
+from hololinked.utils import uuid_hex
+
try:
- from .utils import TestCase, TestRunner
from .things import TestThing
except ImportError:
- from utils import TestCase, TestRunner
from things import TestThing
-setup_logging(log_level=logging.ERROR)
-
-
-class TestEvents(TestCase):
- @classmethod
- def setUpClass(cls):
- super().setUpClass()
- print(f"test events with {cls.__name__}")
-
- def _test_dispatcher(self, descriptor: Event, dispatcher: EventDispatcher, thing: TestThing):
- """pass the event descriptor and the dispatcher to test the dispatcher"""
- self.assertIsInstance(dispatcher, EventDispatcher) # instance access returns dispatcher
- self.assertTrue(dispatcher._owner_inst is thing) # dispatcher has the owner instance
- self.assertTrue(
- (
- thing.rpc_server
- and thing.rpc_server.event_publisher
- and isinstance(dispatcher.publisher, EventPublisher)
- ) # publisher is set
- or dispatcher.publisher is None # publisher is not set if no rpc_server
- )
- self.assertEqual(dispatcher._unique_identifier, f"{thing._qualified_id}/{descriptor.name}")
-
- def test_1_pure_events(self):
- """Test basic event functionality"""
-
- # 1. Test class-level access to event descriptor
- self.assertIsInstance(TestThing.test_event, Event) # class access returns descriptor
- # self.assertFalse(TestThing.test_event._observable) # not an oberservable property
-
- # 2. Test instance-level access to event dispatcher which is returned by the descriptor
- thing = TestThing(id="test-event")
- self._test_dispatcher(TestThing.test_event, thing.test_event, thing) # test dispatcher returned by descriptor
-
- # 3. Event with JSON schema has schema variable set
-
- def test_2_observable_events(self):
- """Test observable event (of properties) functionality"""
-
- # 1. observable properties have an event descriptor associated with them as a reference
- self.assertIsInstance(TestThing.observable_list_prop._observable_event_descriptor, Event)
- self.assertIsInstance(TestThing.state._observable_event_descriptor, Event)
- self.assertIsInstance(TestThing.observable_readonly_prop._observable_event_descriptor, Event)
-
- # 2. observable descriptors have been assigned as an attribute of the owning class
- self.assertTrue(
- hasattr(
- TestThing,
- TestThing.observable_list_prop._observable_event_descriptor.name,
- )
- )
- self.assertTrue(hasattr(TestThing, TestThing.state._observable_event_descriptor.name))
- self.assertTrue(
- hasattr(
- TestThing,
- TestThing.observable_readonly_prop._observable_event_descriptor.name,
- )
- )
-
- # 3. accessing those descriptors returns the event dispatcher
- thing = TestThing(id="test-event")
- self._test_dispatcher(
- TestThing.observable_list_prop._observable_event_descriptor,
- getattr(
- thing,
- TestThing.observable_list_prop._observable_event_descriptor.name,
- None,
- ),
- thing,
- ) # test dispatcher returned by descriptor
- self._test_dispatcher(
- TestThing.state._observable_event_descriptor,
- getattr(thing, TestThing.state._observable_event_descriptor.name, None),
- thing,
- )
- self._test_dispatcher(
- TestThing.observable_readonly_prop._observable_event_descriptor,
- getattr(
- thing,
- TestThing.observable_readonly_prop._observable_event_descriptor.name,
- None,
- ),
- thing,
- )
-
- def test_3_event_affordance(self):
- """Test event affordance generation"""
-
- # 1. Test event affordance generation
- thing = TestThing(id="test-event")
- event = TestThing.test_event.to_affordance(thing)
- self.assertIsInstance(event, EventAffordance)
-
-
-if __name__ == "__main__":
- unittest.main(testRunner=TestRunner())
+def validate_event_dispatcher(descriptor: Event, dispatcher: EventDispatcher, thing: TestThing):
+ # instance access returns dispatcher
+ assert isinstance(dispatcher, EventDispatcher)
+ # dispatcher has the owner instance
+ assert dispatcher._owner_inst is thing
+ # event publisher and RPC server presence depends on whether the thing has been started or not
+ assert (
+ thing.rpc_server and thing.rpc_server.event_publisher and isinstance(dispatcher.publisher, EventPublisher)
+ ) or dispatcher.publisher is None
+ # unique identifier is correctly formed, qualified by the thing ID
+ assert dispatcher._unique_identifier == f"{thing._qualified_id}/{descriptor.name}"
+
+
+def test_01_pure_events():
+ """Test basic event functionality"""
+ thing = TestThing(id=f"test-pure-events-{uuid_hex()}")
+ # 1. Test class-level access to event descriptor
+ assert isinstance(TestThing.test_event, Event) # class access returns descriptor
+ # 2. Test instance-level access to event dispatcher which is returned by the descriptor
+ validate_event_dispatcher(TestThing.test_event, thing.test_event, thing) # test dispatcher returned by descriptor
+ # 3. Event with JSON schema has schema variable set
+
+
+def test_02_observable_events():
+ """Test observable event (of properties) functionality"""
+ thing = TestThing(id=f"test-observable-events-{uuid_hex()}")
+ # 1. observable properties have an event descriptor associated with them as a reference
+ assert isinstance(TestThing.observable_list_prop._observable_event_descriptor, Event)
+ assert isinstance(TestThing.state._observable_event_descriptor, Event)
+ assert isinstance(TestThing.observable_readonly_prop._observable_event_descriptor, Event)
+
+ # 2. observable descriptors have been assigned as an attribute of the owning class
+ assert hasattr(TestThing, TestThing.observable_list_prop._observable_event_descriptor.name)
+ assert hasattr(TestThing, TestThing.state._observable_event_descriptor.name)
+ assert hasattr(TestThing, TestThing.observable_readonly_prop._observable_event_descriptor.name)
+
+ # 3. accessing those descriptors returns the event dispatcher
+ validate_event_dispatcher(
+ TestThing.observable_list_prop._observable_event_descriptor,
+ getattr(thing, TestThing.observable_list_prop._observable_event_descriptor.name, None),
+ thing,
+ )
+ validate_event_dispatcher(
+ TestThing.state._observable_event_descriptor,
+ getattr(thing, TestThing.state._observable_event_descriptor.name, None),
+ thing,
+ )
+ validate_event_dispatcher(
+ TestThing.observable_readonly_prop._observable_event_descriptor,
+ getattr(thing, TestThing.observable_readonly_prop._observable_event_descriptor.name, None),
+ thing,
+ )
+
+
+def test_03_event_affordance():
+ """Test event affordance generation"""
+ thing = TestThing(id=f"test-event-affordance-{uuid_hex()}")
+ event = TestThing.test_event.to_affordance(thing)
+ assert isinstance(event, EventAffordance)
diff --git a/tests/test_09_rpc_broker.py b/tests/test_09_rpc_broker.py
new file mode 100644
index 00000000..8fea0770
--- /dev/null
+++ b/tests/test_09_rpc_broker.py
@@ -0,0 +1,712 @@
+import asyncio
+import random
+import threading
+import time
+from copy import deepcopy
+from types import SimpleNamespace
+from typing import Any, Generator
+
+import jsonschema
+import pytest
+import structlog
+
+from hololinked.client.abstractions import SSE
+from hololinked.client.zmq.consumed_interactions import ZMQAction, ZMQEvent, ZMQProperty
+from hololinked.core import Thing
+from hololinked.core.actions import BoundAction
+from hololinked.core.zmq.brokers import AsyncZMQClient, EventDispatcher, SyncZMQClient # noqa: F401
+from hololinked.core.zmq.rpc_server import RPCServer
+from hololinked.td import ActionAffordance, EventAffordance, PropertyAffordance
+from hololinked.td.forms import Form
+from hololinked.utils import get_all_sub_things_recusively, uuid_hex
+
+try:
+ from .test_06_actions import replace_methods_with_actions
+ from .things import TestThing
+ from .things import test_thing_TD as test_thing_original_TD
+except ImportError:
+ from test_06_actions import replace_methods_with_actions
+ from things import TestThing
+ from things import test_thing_TD as test_thing_original_TD
+
+
+@pytest.fixture(scope="module")
+def data_structures():
+ return [
+ {"key": "value"},
+ [1, 2, 3],
+ "string",
+ 42,
+ 3.14,
+ True,
+ None,
+ {"nested": {"key": "value"}},
+ [{"list": "of"}, {"dicts": "here"}],
+ {"complex": {"nested": {"list": [1, 2, 3]}, "mixed": [1, "two", 3.0, None]}},
+ {"array": [1, 2, 3]},
+ ]
+
+
+@pytest.fixture(scope="class")
+def thing_id():
+ return f"test-thing-{uuid_hex()}"
+
+
+@pytest.fixture(scope="class")
+def server_id():
+ return f"test-server-{uuid_hex()}"
+
+
+@pytest.fixture(scope="class")
+def client_id():
+ return f"test-client-{uuid_hex()}"
+
+
+@pytest.fixture(scope="class")
+def owner_inst():
+ return SimpleNamespace(_noblock_messages={})
+
+
+@pytest.fixture(scope="class")
+def test_thing_TD(thing_id) -> dict[str, Any]:
+ td = deepcopy(test_thing_original_TD)
+ td["id"] = thing_id
+ return td
+
+
+@pytest.fixture(scope="class")
+def thing(thing_id: str) -> TestThing:
+ cls = deepcopy(TestThing)
+ replace_methods_with_actions(cls)
+ return cls(id=thing_id)
+
+
+@pytest.fixture(scope="class")
+def server(server_id, thing) -> Generator[RPCServer, None, None]:
+ _server = RPCServer(id=server_id, things=[thing])
+ thread = threading.Thread(target=_server.run, daemon=False)
+ thread.start()
+ yield _server
+ _server.stop()
+
+
+@pytest.fixture(scope="class")
+def async_client(client_id, server_id) -> Generator[AsyncZMQClient, None, None]:
+ client = AsyncZMQClient(
+ id=client_id,
+ server_id=server_id,
+ access_point="INPROC",
+ handshake=False,
+ )
+ yield client
+ client.exit()
+
+
+@pytest.fixture(scope="class")
+def sync_client(client_id, server_id) -> Generator[SyncZMQClient, None, None]:
+ client = SyncZMQClient(
+ id=client_id + "-sync",
+ server_id=server_id,
+ access_point="INPROC",
+ handshake=False,
+ )
+ yield client
+ client.exit()
+
+
+@pytest.fixture(scope="class")
+def action_echo(test_thing_TD, sync_client, async_client, owner_inst):
+ return ZMQAction(
+ resource=ActionAffordance.from_TD("action_echo", test_thing_TD),
+ sync_client=sync_client,
+ async_client=async_client,
+ owner_inst=owner_inst,
+ logger=structlog.get_logger(),
+ invokation_timeout=5,
+ execution_timeout=5,
+ )
+
+
+@pytest.fixture(scope="class")
+def action_get_serialized_data(test_thing_TD, sync_client, async_client, owner_inst):
+ return ZMQAction(
+ resource=ActionAffordance.from_TD("get_serialized_data", test_thing_TD),
+ sync_client=sync_client,
+ async_client=async_client,
+ owner_inst=owner_inst,
+ logger=structlog.get_logger(),
+ invokation_timeout=5,
+ execution_timeout=5,
+ )
+
+
+@pytest.fixture(scope="class")
+def action_sleep(test_thing_TD, sync_client, async_client, owner_inst):
+ return ZMQAction(
+ resource=ActionAffordance.from_TD("sleep", test_thing_TD),
+ sync_client=sync_client,
+ async_client=async_client,
+ owner_inst=owner_inst,
+ logger=structlog.get_logger(),
+ invokation_timeout=5,
+ execution_timeout=5,
+ )
+
+
+@pytest.fixture(scope="class")
+def action_get_mixed_content_data(test_thing_TD, sync_client, async_client, owner_inst):
+ return ZMQAction(
+ resource=ActionAffordance.from_TD("get_mixed_content_data", test_thing_TD),
+ sync_client=sync_client,
+ async_client=async_client,
+ owner_inst=owner_inst,
+ logger=structlog.get_logger(),
+ invokation_timeout=5,
+ execution_timeout=5,
+ )
+
+
+@pytest.fixture(scope="class")
+def action_push_events(test_thing_TD, sync_client, async_client, owner_inst):
+ return ZMQAction(
+ resource=ActionAffordance.from_TD("push_events", test_thing_TD),
+ sync_client=sync_client,
+ async_client=async_client,
+ owner_inst=owner_inst,
+ logger=structlog.get_logger(),
+ invokation_timeout=5,
+ execution_timeout=5,
+ )
+
+
+@pytest.fixture(scope="class")
+def base_property(test_thing_TD, sync_client, async_client, owner_inst):
+ return ZMQProperty(
+ resource=PropertyAffordance.from_TD("base_property", test_thing_TD),
+ sync_client=sync_client,
+ async_client=async_client,
+ owner_inst=owner_inst,
+ logger=structlog.get_logger(),
+ invokation_timeout=5,
+ execution_timeout=5,
+ )
+
+
+@pytest.fixture(scope="class")
+def total_number_of_events(test_thing_TD, sync_client, async_client, owner_inst):
+ return ZMQProperty(
+ resource=PropertyAffordance.from_TD("total_number_of_events", test_thing_TD),
+ sync_client=sync_client,
+ async_client=async_client,
+ owner_inst=owner_inst,
+ logger=structlog.get_logger(),
+ invokation_timeout=5,
+ execution_timeout=5,
+ )
+
+
+@pytest.fixture(scope="class")
+def test_event(test_thing_TD, owner_inst):
+ return ZMQEvent(
+ resource=EventAffordance.from_TD("test_event", test_thing_TD),
+ owner_inst=owner_inst,
+ logger=structlog.get_logger(),
+ )
+
+
+@pytest.mark.asyncio(loop_scope="class")
+class TestRPCBroker:
+ def test_01_creation_defaults(self, server: RPCServer, thing: TestThing):
+ assert server.req_rep_server.socket_address.startswith("inproc://")
+ assert server.event_publisher.socket_address.startswith("inproc://")
+ assert thing.rpc_server == server
+ assert thing.event_publisher == server.event_publisher
+
+ def test_02_handshake(self, sync_client: SyncZMQClient):
+ sync_client.handshake()
+
+ async def test_02_async_handshake(self, async_client: AsyncZMQClient):
+ async_client.handshake()
+ await async_client.handshake_complete()
+
+ async def test_03_action_abstraction_basic(self, action_echo: ZMQAction):
+ await action_echo.async_call("value")
+ action_echo.oneway(5)
+ noblock_msg_id = action_echo.noblock(10)
+ assert action_echo.last_return_value == "value"
+ response = action_echo._sync_zmq_client.recv_response(noblock_msg_id)
+ action_echo._last_zmq_response = response
+ assert action_echo.last_return_value == 10
+ assert action_echo(2) == 2
+
+ async def test_04_action_abstraction_thorough(self, action_echo: ZMQAction, data_structures: list[Any]):
+ msg_ids = [None for _ in range(len(data_structures))]
+ last_call_type = None
+ for index, data in enumerate(data_structures):
+ call_type = random.choice(["async_call", "plain_call", "oneway", "noblock"])
+ if call_type == "async_call":
+ result = await action_echo.async_call(data)
+ assert result == data
+ elif call_type == "plain_call":
+ result = action_echo(data)
+ assert result == data
+ elif call_type == "oneway":
+ action_echo.oneway(data)
+ assert data != action_echo.last_return_value
+ elif call_type == "noblock":
+ msg_ids[index] = action_echo.noblock(data)
+ assert data != action_echo.last_return_value
+ if last_call_type == "noblock":
+ response = action_echo._sync_zmq_client.recv_response(msg_ids[index - 1])
+ action_echo._last_zmq_response = response
+ assert action_echo.last_return_value == data_structures[index - 1]
+ last_call_type = call_type
+
+ async def test_05_property_abstractions_basic(self, base_property: ZMQProperty):
+ base_property.set(100)
+ assert base_property.get() == 100
+ base_property.oneway_set(200)
+ assert base_property.get() == 200
+
+ await base_property.async_set(300)
+ assert base_property.get() == 300
+ await base_property.async_set(0)
+ assert await base_property.async_get() == 0
+
+ async def test_06_property_abstractions_thorough(self, base_property: ZMQProperty, data_structures: list[Any]):
+ msg_ids = [None for _ in range(len(data_structures))]
+ last_call_type = None
+ for index, data in enumerate(data_structures):
+ call_type = random.choice(["async_set", "set", "oneway_set", "noblock_get"])
+ if call_type == "async_set":
+ assert await base_property.async_set(data) is None
+ assert await base_property.async_get() == data
+ elif call_type == "set":
+ assert base_property.set(data) is None
+ assert base_property.get() == data
+ elif call_type == "oneway_set":
+ assert base_property.oneway_set(data) is None
+ assert data != base_property.last_read_value
+ assert data == base_property.get()
+ elif call_type == "noblock_get":
+ msg_ids[index] = base_property.noblock_get()
+ assert data != base_property.last_read_value
+ if last_call_type == "noblock":
+ response = base_property._sync_zmq_client.recv_response(msg_ids[index - 1])
+ base_property._last_zmq_response = response
+ assert base_property.last_read_value == data_structures[index - 1]
+ last_call_type = call_type
+
+ async def notest_07_thing_execution_context(self, action_echo: ZMQAction):
+ old_thing_execution_context = action_echo._thing_execution_context
+ action_echo._thing_execution_context = dict(fetch_execution_logs=True)
+ await action_echo.async_call("value")
+ assert isinstance(action_echo.last_return_value, dict)
+ assert "execution_logs" in action_echo.last_return_value.keys()
+ assert "return_value" in action_echo.last_return_value.keys()
+ assert len(action_echo.last_return_value) == 2
+ assert action_echo.last_return_value != "value"
+ assert isinstance(action_echo.last_return_value["execution_logs"], list)
+ assert action_echo.last_return_value["return_value"] == "value"
+ action_echo._thing_execution_context = old_thing_execution_context
+
+ async def test_08_execution_timeout(self, action_sleep: ZMQAction):
+ try:
+ await action_sleep.async_call()
+ except Exception as ex:
+ assert isinstance(ex, TimeoutError)
+ assert "Execution timeout occured" in str(ex)
+ else:
+ assert False
+
+ async def test_09_invokation_timeout(self, action_sleep: ZMQAction):
+ try:
+ old_timeout = action_sleep._invokation_timeout
+ action_sleep._invokation_timeout = 0.1
+ await action_sleep.async_call()
+ except Exception as ex:
+ assert isinstance(ex, TimeoutError)
+ assert "Invokation timeout occured" in str(ex)
+ else:
+ assert False
+ finally:
+ action_sleep._invokation_timeout = old_timeout
+
+ async def test_10_binary_payloads(
+ self,
+ action_get_mixed_content_data: ZMQAction,
+ action_get_serialized_data: ZMQAction,
+ ):
+ assert action_get_mixed_content_data() == ("foobar", b"foobar")
+ assert action_get_serialized_data() == b"foobar"
+
+ await action_get_mixed_content_data.async_call()
+ result = action_get_mixed_content_data.last_return_value
+ assert result == ("foobar", b"foobar")
+
+ await action_get_serialized_data.async_call()
+ result = action_get_serialized_data.last_return_value
+ assert result == b"foobar"
+
+ def test_11_exposed_actions(self, thing: TestThing, sync_client: SyncZMQClient):
+ client = sync_client
+
+ assert isinstance(thing.action_echo, BoundAction)
+ action_echo = ZMQAction(
+ resource=thing.action_echo.to_affordance(),
+ sync_client=client,
+ async_client=None,
+ logger=structlog.get_logger(),
+ owner_inst=None,
+ )
+ assert action_echo(1) == 1
+
+ assert isinstance(thing.action_echo_with_classmethod, BoundAction)
+ action_echo_with_classmethod = ZMQAction(
+ resource=thing.action_echo_with_classmethod.to_affordance(),
+ sync_client=client,
+ async_client=None,
+ logger=structlog.get_logger(),
+ owner_inst=None,
+ )
+ assert action_echo_with_classmethod(2) == 2
+
+ assert isinstance(thing.action_echo_async, BoundAction)
+ action_echo_async = ZMQAction(
+ resource=thing.action_echo_async.to_affordance(),
+ sync_client=client,
+ async_client=None,
+ logger=structlog.get_logger(),
+ owner_inst=None,
+ )
+ assert action_echo_async("string") == "string"
+
+ assert isinstance(thing.action_echo_async_with_classmethod, BoundAction)
+ action_echo_async_with_classmethod = ZMQAction(
+ resource=thing.action_echo_async_with_classmethod.to_affordance(),
+ sync_client=client,
+ async_client=None,
+ logger=structlog.get_logger(),
+ owner_inst=None,
+ )
+ assert action_echo_async_with_classmethod([1, 2]) == [1, 2]
+
+ assert isinstance(thing.parameterized_action, BoundAction)
+ parameterized_action = ZMQAction(
+ resource=thing.parameterized_action.to_affordance(),
+ sync_client=client,
+ async_client=None,
+ logger=structlog.get_logger(),
+ owner_inst=None,
+ )
+ assert parameterized_action(arg1=1, arg2="hello", arg3=5) == [thing.id, 1, "hello", 5]
+
+ assert isinstance(thing.parameterized_action_async, BoundAction)
+ parameterized_action_async = ZMQAction(
+ resource=thing.parameterized_action_async.to_affordance(),
+ sync_client=client,
+ async_client=None,
+ logger=structlog.get_logger(),
+ owner_inst=None,
+ )
+ assert parameterized_action_async(arg1=2.5, arg2="hello", arg3="foo") == [thing.id, 2.5, "hello", "foo"]
+
+ assert isinstance(thing.parameterized_action_without_call, BoundAction)
+ parameterized_action_without_call = ZMQAction(
+ resource=thing.parameterized_action_without_call.to_affordance(),
+ sync_client=client,
+ async_client=None,
+ logger=structlog.get_logger(),
+ owner_inst=None,
+ )
+
+ with pytest.raises(NotImplementedError) as ex:
+ parameterized_action_without_call(arg1=2, arg2="hello", arg3=5)
+ assert str(ex.value).startswith("Subclasses must implement __call__")
+
+ def test_12_json_schema_validation(self, thing: TestThing, sync_client: SyncZMQClient):
+ assert isinstance(thing.json_schema_validated_action, BoundAction)
+ action_affordance = thing.json_schema_validated_action.to_affordance()
+ json_schema_validated_action = ZMQAction(
+ resource=action_affordance,
+ sync_client=sync_client,
+ async_client=None,
+ owner_inst=None,
+ logger=structlog.get_logger(),
+ )
+
+ with pytest.raises(Exception) as ex1:
+ json_schema_validated_action(val1="1", val2="hello", val3={"field": "value"}, val4=[])
+ assert str(ex1.value).startswith("'1' is not of type 'integer'")
+ with pytest.raises(Exception) as ex2:
+ json_schema_validated_action("1", val2="hello", val3={"field": "value"}, val4=[])
+ assert str(ex2.value).startswith("'1' is not of type 'integer'")
+ with pytest.raises(Exception) as ex3:
+ json_schema_validated_action(1, 2, val3={"field": "value"}, val4=[])
+ assert str(ex3.value).startswith("2 is not of type 'string'")
+ with pytest.raises(Exception) as ex4:
+ json_schema_validated_action(1, "hello", val3="field", val4=[])
+ assert str(ex4.value).startswith("'field' is not of type 'object'")
+ with pytest.raises(Exception) as ex5:
+ json_schema_validated_action(1, "hello", val3={"field": "value"}, val4="[]")
+ assert str(ex5.value).startswith("'[]' is not of type 'array'")
+ # data with valid schema
+ return_value = json_schema_validated_action(val1=1, val2="hello", val3={"field": "value"}, val4=[])
+ assert return_value == {"val1": 1, "val3": {"field": "value"}}
+ jsonschema.Draft7Validator(action_affordance.output).validate(return_value)
+
+ def test_13_pydantic_validation(self, thing: TestThing, sync_client: SyncZMQClient):
+ assert isinstance(thing.pydantic_validated_action, BoundAction)
+ action_affordance = thing.pydantic_validated_action.to_affordance()
+ pydantic_validated_action = ZMQAction(
+ resource=action_affordance,
+ sync_client=sync_client,
+ async_client=None,
+ owner_inst=None,
+ logger=structlog.get_logger(),
+ )
+
+ with pytest.raises(Exception) as ex1:
+ pydantic_validated_action(val1="1", val2="hello", val3={"field": "value"}, val4=[])
+ assert (
+ "validation error for pydantic_validated_action_input" in str(ex1.value)
+ and "val1" in str(ex1.value)
+ and "val2" not in str(ex1.value)
+ and "val3" not in str(ex1.value)
+ and "val4" not in str(ex1.value)
+ )
+ with pytest.raises(Exception) as ex2:
+ pydantic_validated_action("1", val2="hello", val3={"field": "value"}, val4=[])
+ assert (
+ "validation error for pydantic_validated_action_input" in str(ex2.value)
+ and "val1" in str(ex2.value)
+ and "val2" not in str(ex2.value)
+ and "val3" not in str(ex2.value)
+ and "val4" not in str(ex2.value)
+ )
+ with pytest.raises(Exception) as ex3:
+ pydantic_validated_action(1, 2, val3={"field": "value"}, val4=[])
+ assert (
+ "validation error for pydantic_validated_action_input" in str(ex3.value)
+ and "val1" not in str(ex3.value)
+ and "val2" in str(ex3.value)
+ and "val3" not in str(ex3.value)
+ and "val4" not in str(ex3.value)
+ )
+ with pytest.raises(Exception) as ex4:
+ pydantic_validated_action(1, "hello", val3="field", val4=[])
+ assert (
+ "validation error for pydantic_validated_action_input" in str(ex4.value)
+ and "val1" not in str(ex4.value)
+ and "val2" not in str(ex4.value)
+ and "val3" in str(ex4.value)
+ and "val4" not in str(ex4.value)
+ )
+ with pytest.raises(Exception) as ex5:
+ pydantic_validated_action(1, "hello", val3={"field": "value"}, val4="[]")
+ assert (
+ "validation error for pydantic_validated_action_input" in str(ex5.value)
+ and "val1" not in str(ex5.value)
+ and "val2" not in str(ex5.value)
+ and "val3" not in str(ex5.value)
+ and "val4" in str(ex5.value)
+ )
+ # data with valid schema
+ return_value = pydantic_validated_action(val1=1, val2="hello", val3={"field": "value"}, val4=[])
+ assert return_value == {"val2": "hello", "val4": []}
+
+ def test_14_property_abstractions(self, thing: TestThing, sync_client: SyncZMQClient):
+ descriptor = thing.properties["number_prop"]
+ # Property type check is omitted since Property is not imported
+ number_prop = ZMQProperty(
+ resource=descriptor.to_affordance(thing),
+ sync_client=sync_client,
+ async_client=None,
+ logger=structlog.get_logger(),
+ owner_inst=None,
+ )
+ assert number_prop.get() == descriptor.default
+ number_prop.set(100)
+ assert number_prop.get() == 100
+ number_prop.oneway_set(200)
+ assert number_prop.get() == 200
+
+ def test_15_json_schema_property(self, thing: TestThing, sync_client: SyncZMQClient):
+ """Test json schema based property"""
+ json_schema_prop = ZMQProperty(
+ resource=TestThing.json_schema_prop.to_affordance(thing),
+ sync_client=sync_client,
+ async_client=None,
+ logger=structlog.get_logger(),
+ owner_inst=None,
+ )
+ json_schema_prop.set("hello")
+ assert json_schema_prop.get() == "hello"
+ json_schema_prop.set("world")
+ assert json_schema_prop.get() == "world"
+
+ with pytest.raises(Exception) as ex:
+ json_schema_prop.set("world1")
+ assert "Failed validating 'pattern' in schema:" in str(ex.value)
+
+ def test_16_pydantic_model_property(self, thing: TestThing, sync_client: SyncZMQClient):
+ """Test pydantic model based property"""
+ pydantic_prop = ZMQProperty(
+ resource=TestThing.pydantic_prop.to_affordance(thing),
+ sync_client=sync_client,
+ async_client=None,
+ logger=structlog.get_logger(),
+ owner_inst=None,
+ )
+
+ valid_value = {"foo": "foo", "bar": 1, "foo_bar": 1.0}
+ pydantic_prop.set(valid_value)
+ assert pydantic_prop.get() == valid_value
+
+ invalid_value = {"foo": 1, "bar": "1", "foo_bar": 1.0}
+ with pytest.raises(Exception) as ex:
+ pydantic_prop.set(invalid_value)
+ assert "validation error for PydanticProp" in str(ex.value)
+
+ pydantic_simple_prop = ZMQProperty(
+ resource=TestThing.pydantic_simple_prop.to_affordance(thing),
+ sync_client=sync_client,
+ async_client=None,
+ logger=structlog.get_logger(),
+ owner_inst=None,
+ )
+ pydantic_simple_prop.set(5)
+ assert pydantic_simple_prop.get() == 5
+ with pytest.raises(Exception) as ex:
+ pydantic_simple_prop.set("5str")
+ assert "validation error for 'int'" in str(ex.value)
+
+ def test_17_creation_defaults(self, thing: TestThing, server: RPCServer):
+ """test server configuration defaults"""
+ all_things = get_all_sub_things_recusively(thing)
+ # assert len(all_things) > 1 # run the test only if there are sub things
+ for thing in all_things:
+ assert isinstance(thing, Thing)
+ for name, event in thing.events.values.items():
+ assert event.publisher == server.event_publisher
+ assert isinstance(event._unique_identifier, str)
+ assert event._owner_inst == thing
+
+ @pytest.mark.parametrize(
+ "event_name, expected_data",
+ [
+ pytest.param("test_event", "test data", id="test_event"),
+ pytest.param("test_binary_payload_event", b"test data", id="test_binary_payload_event"),
+ pytest.param(
+ "test_event_with_json_schema",
+ {"val1": 1, "val2": "test", "val3": {"key": "value"}, "val4": [1, 2, 3]},
+ id="test_event_with_json_schema",
+ ),
+ ],
+ )
+ def test_18_sync_client_event_stream(
+ self,
+ thing: TestThing,
+ server: RPCServer,
+ action_push_events: ZMQAction,
+ event_name: str,
+ expected_data: Any,
+ ):
+ """test if event can be streamed by a synchronous threaded client"""
+
+ resource = getattr(TestThing, event_name).to_affordance(thing) # type: EventAffordance
+
+ form = Form()
+ form.href = server.event_publisher.socket_address
+ form.contentType = "application/json"
+ form.op = "subscribeevent"
+ form.subprotocol = "sse"
+ resource.forms = [form]
+ event_client = ZMQEvent(
+ resource=resource,
+ logger=structlog.get_logger(),
+ owner_inst=None,
+ )
+
+ event_dispatcher = getattr(thing, event_name) # type: EventDispatcher
+ assert f"{resource.thing_id}/{resource.name}" == event_dispatcher._unique_identifier
+
+ attempts = 100
+ results = []
+
+ def cb(value: SSE):
+ nonlocal results
+ results.append(value)
+
+ event_client.subscribe(cb)
+ time.sleep(5) # calm down for event publisher to connect fully as there is no handshake for events
+ action_push_events(event_name=event_name, total_number_of_events=attempts)
+
+ for i in range(attempts):
+ if len(results) == attempts:
+ break
+ time.sleep(0.1)
+
+ assert abs(len(results) - attempts) <= 3
+ assert [res.data for res in results] == [expected_data] * len(results)
+ event_client.unsubscribe()
+
+ @pytest.mark.parametrize(
+ "event_name, expected_data",
+ [
+ pytest.param("test_event", "test data", id="test_event"),
+ pytest.param("test_binary_payload_event", b"test data", id="test_binary_payload_event"),
+ pytest.param(
+ "test_event_with_json_schema",
+ {"val1": 1, "val2": "test", "val3": {"key": "value"}, "val4": [1, 2, 3]},
+ id="test_event_with_json_schema",
+ ),
+ ],
+ )
+ async def test_19_async_client_event_stream(
+ self,
+ thing: TestThing,
+ action_push_events: ZMQAction,
+ event_name: str,
+ expected_data: Any,
+ ):
+ """test if event can be streamed by an asynchronous client in an async loop"""
+ resource = getattr(TestThing, event_name).to_affordance(thing) # type: EventAffordance
+
+ form = Form()
+ form.href = thing.rpc_server.event_publisher.socket_address
+ form.contentType = "application/json"
+ form.op = "subscribeevent"
+ form.subprotocol = "sse"
+ resource.forms = [form]
+
+ event_client = ZMQEvent(
+ resource=resource,
+ logger=structlog.get_logger(),
+ owner_inst=None,
+ )
+
+ event_dispatcher = getattr(thing, event_name) # type: EventDispatcher
+ assert f"{resource.thing_id}/{resource.name}" == event_dispatcher._unique_identifier
+
+ attempts = 100
+ results = []
+
+ def cb(value: SSE):
+ nonlocal results
+ # print("event callback", value)
+ results.append(value)
+
+ event_client.subscribe(cb, asynch=True)
+ time.sleep(5) # calm down for event publisher to connect fully as there is no handshake for events
+ action_push_events(event_name=event_name, total_number_of_events=attempts)
+
+ for i in range(attempts):
+ if len(results) == attempts:
+ break
+ await asyncio.sleep(0.1)
+ assert abs(len(results) - attempts) <= 3
+ # since we are pushing events in multiple protocols, sometimes the event from the previous test is
+ # still lingering on the socket. So the captured event must be at least the number of attempts.
+ assert [res.data for res in results] == [expected_data] * len(results)
+ event_client.unsubscribe()
diff --git a/tests/test_10_thing_description.py b/tests/test_10_thing_description.py
index 42542b7b..663575bc 100644
--- a/tests/test_10_thing_description.py
+++ b/tests/test_10_thing_description.py
@@ -1,432 +1,362 @@
import logging
-import unittest
+
+import pytest
+
from pydantic import BaseModel
+
from hololinked.constants import ResourceTypes
-from hololinked.schema_validators.json_schema import JSONSchema
-from hololinked.td.data_schema import DataSchema
-from hololinked.td.interaction_affordance import (
- PropertyAffordance,
- InteractionAffordance,
- ActionAffordance,
- EventAffordance,
-)
from hololinked.core.properties import (
- Property,
- Number,
- String,
Boolean,
+ ClassSelector,
List,
+ Number,
+ Property,
Selector,
- ClassSelector,
+ String,
)
-from hololinked.utils import issubklass
+from hololinked.td.data_schema import DataSchema
+from hololinked.td.interaction_affordance import (
+ ActionAffordance,
+ EventAffordance,
+ InteractionAffordance,
+ PropertyAffordance,
+)
+from hololinked.utils import issubklass, uuid_hex
+
try:
from .things import OceanOpticsSpectrometer, TestThing
- from .utils import TestCase, TestRunner
from .things.spectrometer import Intensity
except ImportError:
from things import OceanOpticsSpectrometer, TestThing
- from utils import TestCase, TestRunner
from things.spectrometer import Intensity
-class TestInteractionAffordance(TestCase):
- @classmethod
- def setUpClass(cls):
- super().setUpClass()
- cls.thing = OceanOpticsSpectrometer(id="test-thing", log_level=logging.ERROR)
- print(f"Test Interaction Affordance with {cls.__name__}")
+@pytest.fixture(scope="module")
+def thing():
+ return OceanOpticsSpectrometer(id=f"test-thing-{uuid_hex()}", log_level=logging.ERROR)
+
+
+@pytest.fixture(scope="module")
+def test_thing():
+ return TestThing(id=f"test-spectrometer-thing-{uuid_hex()}", log_level=logging.ERROR)
- def test_1_associated_objects(self):
- affordance = PropertyAffordance()
- affordance.objekt = OceanOpticsSpectrometer.integration_time
- affordance.owner = self.thing
- # req. 1. internal test for multiple inheritance of pydantic models as there are many classes to track
- self.assertIsInstance(affordance, BaseModel)
- self.assertIsInstance(affordance, DataSchema)
- self.assertIsInstance(affordance, InteractionAffordance)
- self.assertTrue(affordance.what, ResourceTypes.PROPERTY)
- # req. 2. owner must be a Thing
- self.assertEqual(affordance.owner, self.thing)
- # req. 3. when owner is set, thing id & thing class is also set
- self.assertEqual(affordance.thing_id, self.thing.id)
- self.assertEqual(affordance.thing_cls, self.thing.__class__)
- # req. 4. objekt must be a Property, since we use a property affordance here
- self.assertIsInstance(affordance.objekt, Property)
- # req. 5. objekt must be a property of the owner thing
- # --- not enforced yet
- # req. 6. when objekt is set, property name is also set
- self.assertEqual(affordance.name, OceanOpticsSpectrometer.integration_time.name)
-
- # test the opposite
- affordance = PropertyAffordance()
- # req. 7. accessing any of unset objects should raise an error
- self.assertTrue(affordance.owner is None)
- self.assertTrue(affordance.objekt is None)
- self.assertTrue(affordance.name is None)
- self.assertTrue(affordance.thing_id is None)
- self.assertTrue(affordance.thing_cls is None)
-
- # req. 8. Only the corresponding object can be set for each affordance type
- # i.e. ActionAffordance accepts only an Action as its Objekt, same for property and same for event
- affordance = ActionAffordance()
- with self.assertRaises(ValueError) as ex:
- affordance.objekt = OceanOpticsSpectrometer.integration_time
- with self.assertRaises(TypeError) as ex:
- affordance.objekt = 5
- self.assertIn(
- "objekt must be instance of Property, Action or Event, given type",
- str(ex.exception),
- )
- affordance.objekt = OceanOpticsSpectrometer.connect
- self.assertTrue(affordance.what, ResourceTypes.ACTION)
-
- affordance = EventAffordance()
- with self.assertRaises(ValueError) as ex:
- affordance.objekt = OceanOpticsSpectrometer.integration_time
- with self.assertRaises(TypeError) as ex:
- affordance.objekt = 5
- self.assertIn(
- "objekt must be instance of Property, Action or Event, given type",
- str(ex.exception),
- )
- affordance.objekt = OceanOpticsSpectrometer.intensity_measurement_event
- self.assertTrue(affordance.what, ResourceTypes.EVENT)
-
- affordance = PropertyAffordance()
- with self.assertRaises(ValueError) as ex:
- affordance.objekt = OceanOpticsSpectrometer.connect
- with self.assertRaises(TypeError) as ex:
- affordance.objekt = 5
- self.assertIn(
- "objekt must be instance of Property, Action or Event, given type",
- str(ex.exception),
- )
- affordance.objekt = OceanOpticsSpectrometer.integration_time
+def test_01_associated_objects(thing):
+ affordance = PropertyAffordance()
+ affordance.objekt = OceanOpticsSpectrometer.integration_time
+ affordance.owner = thing
+ assert isinstance(affordance, BaseModel)
+ assert isinstance(affordance, DataSchema)
+ assert isinstance(affordance, InteractionAffordance)
+ assert affordance.what == ResourceTypes.PROPERTY
+ assert affordance.owner == thing
+ assert affordance.thing_id == thing.id
+ assert affordance.thing_cls == thing.__class__
+ assert isinstance(affordance.objekt, Property)
+ assert affordance.name == OceanOpticsSpectrometer.integration_time.name
-class TestDataSchema(TestCase):
- @classmethod
- def setUpClass(cls):
- super().setUpClass()
- cls.thing = OceanOpticsSpectrometer(id="test-thing", log_level=logging.ERROR)
- print(f"Test Data Schema with {cls.__name__}")
-
- """
- OceanOpticsSpectrometer.trigger_mode # selector
- OceanOpticsSpectrometer.integration_time # number
- OceanOpticsSpectrometer.serial_number # string
- OceanOpticsSpectrometer.nonlinearity_correction # boolean
- OceanOpticsSpectrometer.custom_background_intensity # typed list float, int
- OceanOpticsSpectrometer.wavelengths # list float int
- """
-
- def test_2_number_schema(self):
- # test implicit generation before actual testing
- schema = OceanOpticsSpectrometer.integration_time.to_affordance(owner_inst=self.thing)
- self.assertIsInstance(schema, PropertyAffordance)
- self.assertEqual(schema.type, "number")
- # this is because we will use a Property directly so that we can generate dataschema
- # based on different parameters of the property. See below
-
- integration_time = Number(
- bounds=(1, 1000),
- default=100,
- crop_to_bounds=True,
- step=1,
- doc="integration time in milliseconds",
- metadata=dict(unit="ms"),
- )
- integration_time.__set_name__(OceanOpticsSpectrometer, "integration_time")
- # req. 1. Schema can be created
- schema = integration_time.to_affordance(owner_inst=self.thing)
- # print(schema.json())
- self.assertIsInstance(schema, PropertyAffordance)
- self.assertEqual(schema.type, "number")
- # req. 2. Test number schema specific attributes
- # minimum, maximum, multipleOf
- self.assertEqual(schema.minimum, integration_time.bounds[0])
- self.assertEqual(schema.maximum, integration_time.bounds[1])
- self.assertEqual(schema.multipleOf, integration_time.step)
- self.assertRaises(AttributeError, lambda: schema.exclusiveMinimum)
- self.assertRaises(AttributeError, lambda: schema.exclusiveMaximum)
- # exclusiveMinimum, exclusiveMaximum
- integration_time.inclusive_bounds = (False, False)
- integration_time.step = None
- schema = integration_time.to_affordance(owner_inst=self.thing)
- self.assertEqual(schema.exclusiveMinimum, integration_time.bounds[0])
- self.assertEqual(schema.exclusiveMaximum, integration_time.bounds[1])
- self.assertRaises(AttributeError, lambda: schema.minimum)
- self.assertRaises(AttributeError, lambda: schema.maximum)
- self.assertRaises(AttributeError, lambda: schema.multipleOf)
- # req. 3. oneOf for allow_None to be True
- integration_time.allow_None = True
- schema = integration_time.to_affordance(owner_inst=self.thing)
- self.assertTrue(any(subtype["type"] == "null" for subtype in schema.oneOf))
- self.assertTrue(any(subtype["type"] == "number" for subtype in schema.oneOf))
- self.assertTrue(len(schema.oneOf), 2)
- self.assertTrue(not hasattr(schema, "type") or schema.type is None)
- # when oneOf was used, make sure the entire dataschema is found within the number subtype
- number_schema = next(subtype for subtype in schema.oneOf if subtype["type"] == "number")
- self.assertEqual(number_schema["exclusiveMinimum"], integration_time.bounds[0])
- self.assertEqual(number_schema["exclusiveMaximum"], integration_time.bounds[1])
- self.assertRaises(KeyError, lambda: number_schema["minimum"])
- self.assertRaises(KeyError, lambda: number_schema["maximum"])
- self.assertRaises(KeyError, lambda: number_schema["multipleOf"])
- # print(schema.json())
- # Test some standard data schema values
- self.assertEqual(schema.default, integration_time.default)
- self.assertEqual(schema.unit, integration_time.metadata["unit"])
-
- def test_3_string_schema(self):
- # test implicit generation before actual testing
- schema = OceanOpticsSpectrometer.status.to_affordance(owner_inst=self.thing)
- self.assertIsInstance(schema, PropertyAffordance)
-
- status = String(
- regex=r"^[a-zA-Z0-9]{1,10}$",
- default="IDLE",
- doc="status of the spectrometer",
- )
- status.__set_name__(OceanOpticsSpectrometer, "status")
- # req. 1. Schema can be created from the string property
- schema = status.to_affordance(owner_inst=self.thing)
- # print(schema.json())
- self.assertIsInstance(schema, PropertyAffordance)
- self.assertEqual(schema.type, "string")
- # req. 2. Test string schema specific attributes
- self.assertEqual(schema.pattern, status.regex)
- # req. 3. oneOf for allow_None to be True
- status.allow_None = True
- schema = status.to_affordance(owner_inst=self.thing)
- self.assertTrue(any(subtype["type"] == "null" for subtype in schema.oneOf))
- self.assertTrue(any(subtype["type"] == "string" for subtype in schema.oneOf))
- self.assertTrue(len(schema.oneOf), 2)
- self.assertTrue(not hasattr(schema, "type") or schema.type is None)
- # when oneOf was used, make sure the entire dataschema is found within the string subtype
- string_schema = next(subtype for subtype in schema.oneOf if subtype["type"] == "string")
- self.assertEqual(string_schema["pattern"], status.regex)
- # print(schema.json())
- # Test some standard data schema values
- self.assertEqual(schema.default, status.default)
-
- def test_4_boolean_schema(self):
- # req. 1. Schema can be created from the boolean property and is a boolean schema based property affordance
- schema = OceanOpticsSpectrometer.nonlinearity_correction.to_affordance(owner_inst=self.thing)
- self.assertIsInstance(schema, PropertyAffordance)
-
- nonlinearity_correction = Boolean(default=True, doc="nonlinearity correction enabled")
- nonlinearity_correction.__set_name__(OceanOpticsSpectrometer, "nonlinearity_correction")
- schema = nonlinearity_correction.to_affordance(owner_inst=self.thing)
- # print(schema.json())
- self.assertIsInstance(schema, PropertyAffordance)
- self.assertEqual(schema.type, "boolean")
- # req. 2. Test boolean schema specific attributes
- # None exists for boolean schema
- # req. 3. oneOf for allow_None to be True
- nonlinearity_correction.allow_None = True
- schema = nonlinearity_correction.to_affordance(owner_inst=self.thing)
- self.assertTrue(any(subtype["type"] == "null" for subtype in schema.oneOf))
- self.assertTrue(any(subtype["type"] == "boolean" for subtype in schema.oneOf))
- self.assertTrue(len(schema.oneOf), 2)
- self.assertTrue(not hasattr(schema, "type") or schema.type is None)
- # print(schema.json())
- # Test some standard data schema values
- self.assertEqual(schema.default, nonlinearity_correction.default)
-
- def test_5_array_schema(self):
- schema = OceanOpticsSpectrometer.wavelengths.to_affordance(owner_inst=self.thing)
- assert isinstance(schema, PropertyAffordance)
-
- wavelengths = List(
- default=[],
- item_type=(float, int),
- readonly=True,
- allow_None=False,
- doc="wavelength bins of measurement",
- )
- wavelengths.__set_name__(OceanOpticsSpectrometer, "wavelengths")
- schema = wavelengths.to_affordance(owner_inst=self.thing)
- # req. 1. Schema can be created from the array property and is a array schema based property affordance
- self.assertIsInstance(schema, BaseModel)
- self.assertIsInstance(schema, DataSchema)
- self.assertIsInstance(schema, PropertyAffordance)
- self.assertEqual(schema.type, "array")
- # req. 2. Test array schema specific attributes
- for types in schema.items["oneOf"]:
- self.assertTrue(types["type"] == "number" or types["type"] == "integer")
- # req. 3. Test some standard data schema values
- if OceanOpticsSpectrometer.wavelengths.default is not None:
- self.assertEqual(schema.default, OceanOpticsSpectrometer.wavelengths.default)
- # req. 4. oneOf for allow_None to be True
- OceanOpticsSpectrometer.wavelengths.allow_None = True
- schema = OceanOpticsSpectrometer.wavelengths.to_affordance(owner_inst=self.thing)
- self.assertTrue(any(subtype["type"] == "null" for subtype in schema.oneOf))
- self.assertTrue(any(subtype["type"] == "array" for subtype in schema.oneOf))
- self.assertTrue(len(schema.oneOf), 2)
- self.assertTrue(not hasattr(schema, "type") or schema.type is None)
- # when oneOf was used, make sure the entire dataschema is found within the array subtype
- array_schema = next(subtype for subtype in schema.oneOf if subtype["type"] == "array")
- for types in array_schema["items"]["oneOf"]: # we know that there are two item types in this array
- self.assertTrue(types["type"] == "number" or types["type"] == "integer")
- # req. 5 check for length constraints
- for bounds in [(5, 1000), (None, 100), (50, None), (51, 101)]:
- wavelengths.bounds = bounds
- wavelengths.allow_None = False
- schema = wavelengths.to_affordance(owner_inst=self.thing)
- if bounds[0] is not None:
- self.assertEqual(schema.minItems, bounds[0])
- else:
- self.assertTrue(not hasattr(schema, "minItems") or schema.minItems is None)
- if bounds[1] is not None:
- self.assertEqual(schema.maxItems, bounds[1])
- else:
- self.assertTrue(not hasattr(schema, "maxItems") or schema.maxItems is None)
- # check if min & max items within allow_None and oneOf
- wavelengths.bounds = bounds
- wavelengths.allow_None = True
- schema = wavelengths.to_affordance(owner_inst=self.thing)
- subtype = next(subtype for subtype in schema.oneOf if subtype["type"] == "array")
- if bounds[0] is not None:
- self.assertEqual(subtype["minItems"], bounds[0])
- else:
- self.assertRaises(KeyError, lambda: subtype["minItems"])
- if bounds[1] is not None:
- self.assertEqual(subtype["maxItems"], bounds[1])
- else:
- self.assertRaises(KeyError, lambda: subtype["maxItems"])
-
- def test_6_enum_schema(self):
- schema = OceanOpticsSpectrometer.trigger_mode.to_affordance(owner_inst=self.thing)
- self.assertIsInstance(schema, PropertyAffordance)
-
- 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""",
- )
- trigger_mode.__set_name__(OceanOpticsSpectrometer, "trigger_mode")
- schema = trigger_mode.to_affordance(owner_inst=self.thing)
- self.assertIsInstance(schema, PropertyAffordance)
- self.assertEqual(schema.type, "integer")
- self.assertEqual(schema.default, 0)
- # check if enum is equal to objects
- self.assertEqual(schema.enum, trigger_mode.objects)
-
- # check if allow_None is handled
- trigger_mode.allow_None = True
- trigger_mode.default = 3
- trigger_mode.objects = [0, 1, 2, 3, 4, "0", "1", "2", "3", "4"]
- schema = trigger_mode.to_affordance(owner_inst=self.thing)
- self.assertIsInstance(schema, PropertyAffordance)
- self.assertTrue(not hasattr(schema, "type") or schema.type is None)
- self.assertEqual(schema.default, 3)
- enum_subschema = next(
- subtype
- for subtype in schema.oneOf
- if (subtype.get("type", None) != "null" or len(subtype.get("oneOf", [])) > 1)
- )
- self.assertIsInstance(enum_subschema, dict)
- self.assertEqual(enum_subschema["enum"], trigger_mode.objects)
-
- def test_7_class_selector_custom_schema(self):
- last_intensity = ClassSelector(
- default=Intensity([], []),
- allow_None=False,
- class_=Intensity,
- doc="last measurement intensity (in arbitrary units)",
- )
- last_intensity.__set_name__(OceanOpticsSpectrometer, "last_intensity")
- schema = last_intensity.to_affordance(owner_inst=self.thing)
- self.assertIsInstance(schema, PropertyAffordance)
- # Intensity contains an object schema
- self.assertEqual(schema.type, "object")
- self.assertEqual(schema.properties, Intensity.schema["properties"])
-
- last_intensity.allow_None = True
- schema = last_intensity.to_affordance(owner_inst=self.thing)
- self.assertIsInstance(schema, PropertyAffordance)
- self.assertTrue(not hasattr(schema, "type") or schema.type is None)
- subschema = next(subtype for subtype in schema.oneOf if subtype.get("type", None) == "object")
- self.assertIsInstance(subschema, dict)
- self.assertTrue(subschema["type"], "object")
- self.assertEqual(subschema["properties"], Intensity.schema["properties"])
-
- def test_8_json_schema_properties(self):
- # req. 1. test if all values of a model are found in the property affordance schema
- json_schema_prop = TestThing.json_schema_prop # type: Property
- json_schema_prop.allow_None = False
- schema = json_schema_prop.to_affordance(owner_inst=self.thing)
- self.assertIsInstance(schema, PropertyAffordance)
- for key in json_schema_prop.model:
- self.assertEqual(getattr(schema, key, NotImplemented), json_schema_prop.model[key])
-
- # req. 2. test the schema even if allow None is True
- json_schema_prop.allow_None = True
- schema = json_schema_prop.to_affordance(owner_inst=self.thing)
- self.assertIsInstance(schema, PropertyAffordance)
- subschema = next(
- subtype
- for subtype in schema.oneOf
- if (subtype.get("type", None) != "null" or len(subtype.get("oneOf", [])) > 1)
- )
- self.assertIsInstance(subschema, dict)
- for key in json_schema_prop.model:
- self.assertEqual(subschema.get(key, NotImplemented), json_schema_prop.model[key])
-
- def test_9_pydantic_properties(self):
- # req. 1. test if all values of a model are found in the property affordance schema for a BaseModel
- pydantic_prop = TestThing.pydantic_prop # type: Property
- pydantic_prop.allow_None = False
- schema = pydantic_prop.to_affordance(owner_inst=self.thing)
- self.assertIsInstance(schema, PropertyAffordance)
- # TODO, this is an inherently harder test case
- if issubklass(pydantic_prop.model, BaseModel):
- self.assertEqual(schema.type, "object")
- for field in pydantic_prop.model.model_fields:
- self.assertIn(field, schema.properties)
-
- # req. 2 test if all values of a model are found in the property affordance for a BaseModel when allow_None = True
- pydantic_prop.allow_None = True
- schema = pydantic_prop.to_affordance(owner_inst=self.thing)
- self.assertIsInstance(schema, PropertyAffordance)
- subschema = next(subtype for subtype in schema.oneOf if subtype.get("type", None) == "object")
- self.assertIsInstance(subschema, dict)
- for key in pydantic_prop.model.model_fields:
- self.assertIn(key, subschema.get("properties", {}))
-
- # req. 3. test if base python types can be used in pydantic property
- pydantic_simple_prop = TestThing.pydantic_simple_prop # type: Property # its an integer
- pydantic_simple_prop.allow_None = False
- schema = pydantic_simple_prop.to_affordance(owner_inst=self.thing)
- self.assertIsInstance(schema, PropertyAffordance)
- self.assertEqual(schema.type, "integer")
-
- pydantic_simple_prop.allow_None = True
- schema = pydantic_simple_prop.to_affordance(owner_inst=self.thing)
- self.assertIsInstance(schema, PropertyAffordance)
- subschema = next(subtype for subtype in schema.oneOf if subtype.get("type", None) == "integer")
- self.assertEqual(subschema["type"], "integer")
- subschema = next(subtype for subtype in schema.oneOf if subtype.get("type", None) == "null")
- self.assertEqual(subschema["type"], "null")
-
-
-class TestThingDescription(TestCase):
- def test_1_thing_model_generation(self):
- thing = TestThing(id="test-thing-model", log_level=logging.ERROR + 10)
- self.assertIsInstance(thing.get_thing_model(skip_names=["base_property"]).json(), dict)
-
-
-def load_tests(loader, tests, pattern):
- suite = unittest.TestSuite()
- suite.addTest(unittest.TestLoader().loadTestsFromTestCase(TestInteractionAffordance))
- suite.addTest(unittest.TestLoader().loadTestsFromTestCase(TestDataSchema))
- suite.addTest(unittest.TestLoader().loadTestsFromTestCase(TestThingDescription))
- return suite
-
-
-if __name__ == "__main__":
- unittest.main(testRunner=TestRunner())
+ affordance = PropertyAffordance()
+ assert affordance.owner is None
+ assert affordance.objekt is None
+ assert affordance.name is None
+ assert affordance.thing_id is None
+ assert affordance.thing_cls is None
+
+ affordance = ActionAffordance()
+ with pytest.raises(ValueError) as ex:
+ affordance.objekt = OceanOpticsSpectrometer.integration_time
+ with pytest.raises(TypeError) as ex:
+ affordance.objekt = 5
+ assert "objekt must be instance of Property, Action or Event, given type" in str(ex.value)
+ affordance.objekt = OceanOpticsSpectrometer.connect
+ assert affordance.what == ResourceTypes.ACTION
+
+ affordance = EventAffordance()
+ with pytest.raises(ValueError) as ex:
+ affordance.objekt = OceanOpticsSpectrometer.integration_time
+ with pytest.raises(TypeError) as ex:
+ affordance.objekt = 5
+ assert "objekt must be instance of Property, Action or Event, given type" in str(ex.value)
+ affordance.objekt = OceanOpticsSpectrometer.intensity_measurement_event
+ assert affordance.what == ResourceTypes.EVENT
+
+ affordance = PropertyAffordance()
+ with pytest.raises(ValueError) as ex:
+ affordance.objekt = OceanOpticsSpectrometer.connect
+ with pytest.raises(TypeError) as ex:
+ affordance.objekt = 5
+ assert "objekt must be instance of Property, Action or Event, given type" in str(ex.value)
+ affordance.objekt = OceanOpticsSpectrometer.integration_time
+
+
+def test_02_number_schema(thing):
+ schema = OceanOpticsSpectrometer.integration_time.to_affordance(owner_inst=thing)
+ assert isinstance(schema, PropertyAffordance)
+ assert schema.type == "number"
+
+ integration_time = Number(
+ bounds=(1, 1000),
+ default=100,
+ crop_to_bounds=True,
+ step=1,
+ doc="integration time in milliseconds",
+ metadata=dict(unit="ms"),
+ )
+ integration_time.__set_name__(OceanOpticsSpectrometer, "integration_time")
+ schema = integration_time.to_affordance(owner_inst=thing)
+ assert isinstance(schema, PropertyAffordance)
+ assert schema.type == "number"
+ assert schema.minimum == integration_time.bounds[0]
+ assert schema.maximum == integration_time.bounds[1]
+ assert schema.multipleOf == integration_time.step
+ with pytest.raises(AttributeError):
+ _ = schema.exclusiveMinimum
+ with pytest.raises(AttributeError):
+ _ = schema.exclusiveMaximum
+ integration_time.inclusive_bounds = (False, False)
+ integration_time.step = None
+ schema = integration_time.to_affordance(owner_inst=thing)
+ assert schema.exclusiveMinimum == integration_time.bounds[0]
+ assert schema.exclusiveMaximum == integration_time.bounds[1]
+ with pytest.raises(AttributeError):
+ _ = schema.minimum
+ with pytest.raises(AttributeError):
+ _ = schema.maximum
+ with pytest.raises(AttributeError):
+ _ = schema.multipleOf
+ integration_time.allow_None = True
+ schema = integration_time.to_affordance(owner_inst=thing)
+ assert any(subtype["type"] == "null" for subtype in schema.oneOf)
+ assert any(subtype["type"] == "number" for subtype in schema.oneOf)
+ assert len(schema.oneOf) == 2
+ assert not hasattr(schema, "type") or schema.type is None
+ number_schema = next(subtype for subtype in schema.oneOf if subtype["type"] == "number")
+ assert number_schema["exclusiveMinimum"] == integration_time.bounds[0]
+ assert number_schema["exclusiveMaximum"] == integration_time.bounds[1]
+ with pytest.raises(KeyError):
+ _ = number_schema["minimum"]
+ with pytest.raises(KeyError):
+ _ = number_schema["maximum"]
+ with pytest.raises(KeyError):
+ _ = number_schema["multipleOf"]
+ assert schema.default == integration_time.default
+ assert schema.unit == integration_time.metadata["unit"]
+
+
+def test_03_string_schema(thing):
+ schema = OceanOpticsSpectrometer.status.to_affordance(owner_inst=thing)
+ assert isinstance(schema, PropertyAffordance)
+
+ status = String(
+ regex=r"^[a-zA-Z0-9]{1,10}$",
+ default="IDLE",
+ doc="status of the spectrometer",
+ )
+ status.__set_name__(OceanOpticsSpectrometer, "status")
+ schema = status.to_affordance(owner_inst=thing)
+ assert isinstance(schema, PropertyAffordance)
+ assert schema.type == "string"
+ assert schema.pattern == status.regex
+ status.allow_None = True
+ schema = status.to_affordance(owner_inst=thing)
+ assert any(subtype["type"] == "null" for subtype in schema.oneOf)
+ assert any(subtype["type"] == "string" for subtype in schema.oneOf)
+ assert len(schema.oneOf) == 2
+ assert not hasattr(schema, "type") or schema.type is None
+ string_schema = next(subtype for subtype in schema.oneOf if subtype["type"] == "string")
+ assert string_schema["pattern"] == status.regex
+ assert schema.default == status.default
+
+
+def test_04_boolean_schema(thing):
+ schema = OceanOpticsSpectrometer.nonlinearity_correction.to_affordance(owner_inst=thing)
+ assert isinstance(schema, PropertyAffordance)
+
+ nonlinearity_correction = Boolean(default=True, doc="nonlinearity correction enabled")
+ nonlinearity_correction.__set_name__(OceanOpticsSpectrometer, "nonlinearity_correction")
+ schema = nonlinearity_correction.to_affordance(owner_inst=thing)
+ assert isinstance(schema, PropertyAffordance)
+ assert schema.type == "boolean"
+ nonlinearity_correction.allow_None = True
+ schema = nonlinearity_correction.to_affordance(owner_inst=thing)
+ assert any(subtype["type"] == "null" for subtype in schema.oneOf)
+ assert any(subtype["type"] == "boolean" for subtype in schema.oneOf)
+ assert len(schema.oneOf) == 2
+ assert not hasattr(schema, "type") or schema.type is None
+ assert schema.default == nonlinearity_correction.default
+
+
+def test_05_array_schema(thing):
+ schema = OceanOpticsSpectrometer.wavelengths.to_affordance(owner_inst=thing)
+ assert isinstance(schema, PropertyAffordance)
+
+ wavelengths = List(
+ default=[],
+ item_type=(float, int),
+ readonly=True,
+ allow_None=False,
+ doc="wavelength bins of measurement",
+ )
+ wavelengths.__set_name__(OceanOpticsSpectrometer, "wavelengths")
+ schema = wavelengths.to_affordance(owner_inst=thing)
+ assert isinstance(schema, BaseModel)
+ assert isinstance(schema, DataSchema)
+ assert isinstance(schema, PropertyAffordance)
+ assert schema.type == "array"
+ for types in schema.items["oneOf"]:
+ assert types["type"] == "number" or types["type"] == "integer"
+ if OceanOpticsSpectrometer.wavelengths.default is not None:
+ assert schema.default == OceanOpticsSpectrometer.wavelengths.default
+ OceanOpticsSpectrometer.wavelengths.allow_None = True
+ schema = OceanOpticsSpectrometer.wavelengths.to_affordance(owner_inst=thing)
+ assert any(subtype["type"] == "null" for subtype in schema.oneOf)
+ assert any(subtype["type"] == "array" for subtype in schema.oneOf)
+ assert len(schema.oneOf) == 2
+ assert not hasattr(schema, "type") or schema.type is None
+ array_schema = next(subtype for subtype in schema.oneOf if subtype["type"] == "array")
+ for types in array_schema["items"]["oneOf"]:
+ assert types["type"] == "number" or types["type"] == "integer"
+
+ for bounds in [(5, 1000), (None, 100), (50, None), (51, 101)]:
+ wavelengths.bounds = bounds
+ wavelengths.allow_None = False
+ schema = wavelengths.to_affordance(owner_inst=thing)
+ if bounds[0] is not None:
+ assert schema.minItems == bounds[0]
+ else:
+ assert not hasattr(schema, "minItems") or schema.minItems is None
+ if bounds[1] is not None:
+ assert schema.maxItems == bounds[1]
+ else:
+ assert not hasattr(schema, "maxItems") or schema.maxItems is None
+ wavelengths.bounds = bounds
+ wavelengths.allow_None = True
+ schema = wavelengths.to_affordance(owner_inst=thing)
+ subtype = next(subtype for subtype in schema.oneOf if subtype["type"] == "array")
+ if bounds[0] is not None:
+ assert subtype["minItems"] == bounds[0]
+ else:
+ with pytest.raises(KeyError):
+ _ = subtype["minItems"]
+ if bounds[1] is not None:
+ assert subtype["maxItems"] == bounds[1]
+ else:
+ with pytest.raises(KeyError):
+ _ = subtype["maxItems"]
+
+
+def test_06_enum_schema(thing):
+ schema = OceanOpticsSpectrometer.trigger_mode.to_affordance(owner_inst=thing)
+ assert isinstance(schema, PropertyAffordance)
+
+ 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""",
+ )
+ trigger_mode.__set_name__(OceanOpticsSpectrometer, "trigger_mode")
+ schema = trigger_mode.to_affordance(owner_inst=thing)
+ assert isinstance(schema, PropertyAffordance)
+ assert schema.type == "integer"
+ assert schema.default == 0
+ assert schema.enum == trigger_mode.objects
+
+ trigger_mode.allow_None = True
+ trigger_mode.default = 3
+ trigger_mode.objects = [0, 1, 2, 3, 4, "0", "1", "2", "3", "4"]
+ schema = trigger_mode.to_affordance(owner_inst=thing)
+ assert isinstance(schema, PropertyAffordance)
+ assert not hasattr(schema, "type") or schema.type is None
+ assert schema.default == 3
+ enum_subschema = next(
+ subtype
+ for subtype in schema.oneOf
+ if (subtype.get("type", None) != "null" or len(subtype.get("oneOf", [])) > 1)
+ )
+ assert isinstance(enum_subschema, dict)
+ assert enum_subschema["enum"] == trigger_mode.objects
+
+
+def test_07_class_selector_custom_schema(thing):
+ last_intensity = ClassSelector(
+ default=Intensity([], []),
+ allow_None=False,
+ class_=Intensity,
+ doc="last measurement intensity (in arbitrary units)",
+ )
+ last_intensity.__set_name__(OceanOpticsSpectrometer, "last_intensity")
+ schema = last_intensity.to_affordance(owner_inst=thing)
+ assert isinstance(schema, PropertyAffordance)
+ assert schema.type == "object"
+ assert schema.properties == Intensity.schema["properties"]
+
+ last_intensity.allow_None = True
+ schema = last_intensity.to_affordance(owner_inst=thing)
+ assert isinstance(schema, PropertyAffordance)
+ assert not hasattr(schema, "type") or schema.type is None
+ subschema = next(subtype for subtype in schema.oneOf if subtype.get("type", None) == "object")
+ assert isinstance(subschema, dict)
+ assert subschema["type"] == "object"
+ assert subschema["properties"] == Intensity.schema["properties"]
+
+
+def test_08_json_schema_properties(thing):
+ json_schema_prop = TestThing.json_schema_prop # type: Property
+ json_schema_prop.allow_None = False
+ schema = json_schema_prop.to_affordance(owner_inst=thing)
+ assert isinstance(schema, PropertyAffordance)
+ for key in json_schema_prop.model:
+ assert getattr(schema, key, NotImplemented) == json_schema_prop.model[key]
+
+ json_schema_prop.allow_None = True
+ schema = json_schema_prop.to_affordance(owner_inst=thing)
+ assert isinstance(schema, PropertyAffordance)
+ subschema = next(
+ subtype
+ for subtype in schema.oneOf
+ if (subtype.get("type", None) != "null" or len(subtype.get("oneOf", [])) > 1)
+ )
+ assert isinstance(subschema, dict)
+ for key in json_schema_prop.model:
+ assert subschema.get(key, NotImplemented) == json_schema_prop.model[key]
+
+
+def test_09_pydantic_properties(thing):
+ pydantic_prop = TestThing.pydantic_prop # type: Property
+ pydantic_prop.allow_None = False
+ schema = pydantic_prop.to_affordance(owner_inst=thing)
+ assert isinstance(schema, PropertyAffordance)
+ if issubklass(pydantic_prop.model, BaseModel):
+ assert schema.type == "object"
+ for field in pydantic_prop.model.model_fields:
+ assert field in schema.properties
+
+ pydantic_prop.allow_None = True
+ schema = pydantic_prop.to_affordance(owner_inst=thing)
+ assert isinstance(schema, PropertyAffordance)
+ subschema = next(subtype for subtype in schema.oneOf if subtype.get("type", None) == "object")
+ assert isinstance(subschema, dict)
+ for key in pydantic_prop.model.model_fields:
+ assert key in subschema.get("properties", {})
+
+ pydantic_simple_prop = TestThing.pydantic_simple_prop # type: Property # its an integer
+ pydantic_simple_prop.allow_None = False
+ schema = pydantic_simple_prop.to_affordance(owner_inst=thing)
+ assert isinstance(schema, PropertyAffordance)
+ assert schema.type == "integer"
+
+ pydantic_simple_prop.allow_None = True
+ schema = pydantic_simple_prop.to_affordance(owner_inst=thing)
+ assert isinstance(schema, PropertyAffordance)
+ subschema = next(subtype for subtype in schema.oneOf if subtype.get("type", None) == "integer")
+ assert subschema["type"] == "integer"
+ subschema = next(subtype for subtype in schema.oneOf if subtype.get("type", None) == "null")
+ assert subschema["type"] == "null"
+
+
+def test_10_thing_model_generation():
+ thing = TestThing(id="test-thing-model", log_level=logging.ERROR + 10)
+ assert isinstance(thing.get_thing_model(skip_names=["base_property"]).json(), dict)
diff --git a/tests/test_11_rpc_e2e.py b/tests/test_11_rpc_e2e.py
index 12187b30..cd576cd6 100644
--- a/tests/test_11_rpc_e2e.py
+++ b/tests/test_11_rpc_e2e.py
@@ -1,373 +1,310 @@
-# an end to end through the zmq object proxy client with IPC protocol which is assumed to be most stable
import time
-import unittest
-import logging
-from uuid import uuid4
+from typing import Any, Generator
+
+import pytest
+
from hololinked.client.abstractions import SSE
from hololinked.client.factory import ClientFactory
from hololinked.client.proxy import ObjectProxy
-from hololinked.logger import setup_logging
+from hololinked.utils import uuid_hex
try:
from .things import TestThing
- from .utils import TestCase, TestRunner, fake, AsyncTestCase
+ from .utils import fake
except ImportError:
from things import TestThing
- from utils import TestCase, TestRunner, fake, AsyncTestCase
-
-setup_logging(log_level=logging.ERROR + 10)
-
-
-class TestRPCEndToEnd(TestCase):
- """Test the zmq object proxy client"""
-
- @classmethod
- def setUpClass(cls):
- cls.thing_id = f"test-thing-{uuid4().hex[:8]}"
- cls.server_id = cls.thing_id
- super().setUpClass()
- cls.setUpThing()
- print("Test ZMQ IPC End to End")
-
- @classmethod
- def setUpThing(cls):
- """Set up the thing for the zmq object proxy client"""
- cls.thing = TestThing(id=cls.thing_id)
- cls.thing.run_with_zmq_server(forked=True)
- cls.thing_model = cls.thing.get_thing_model(ignore_errors=True).json()
-
- @classmethod
- def tearDownClass(cls):
- """Test the stop of the zmq object proxy client"""
- cls._client = None
- cls.thing.rpc_server.stop()
- super().tearDownClass()
-
- @classmethod
- def get_client(cls):
- try:
- if cls._client is not None:
- return cls._client
- raise AttributeError()
- except AttributeError:
- cls._client = ClientFactory.zmq(
- cls.server_id,
- cls.thing_id,
- "IPC",
- ignore_TD_errors=True,
- )
- return cls._client
-
- def test_01_creation_and_handshake(self):
- """Test the creation and handshake of the zmq object proxy client"""
- thing = self.get_client()
- self.assertIsInstance(thing, ObjectProxy)
- self.assertTrue(
- len(thing.properties) + len(thing.actions) + len(thing.events)
- >= len(self.thing_model["properties"]) + len(self.thing_model["actions"]) + len(self.thing_model["events"])
- )
+ from utils import fake
- def test_02_invoke_action(self):
- """Test the invocation of an action on the zmq object proxy client"""
- thing = self.get_client()
- self.assertIsInstance(thing, ObjectProxy)
- # Test invoke_action method with reply
- self.assertEqual(thing.invoke_action("action_echo", fake.text(max_nb_chars=100)), fake.last)
- self.assertEqual(thing.invoke_action("action_echo", fake.sentence()), fake.last)
- self.assertEqual(thing.invoke_action("action_echo", fake.json()), fake.last)
- # Test invoke_action with dot notation
- self.assertEqual(thing.action_echo(fake.chrome()), fake.last)
- self.assertEqual(thing.action_echo(fake.sha256()), fake.last)
- self.assertEqual(thing.action_echo(fake.address()), fake.last)
- # Test invoke_action with no reply
- self.assertEqual(
- thing.invoke_action("set_non_remote_number_prop", fake.random_number(), oneway=True),
- None,
- )
- self.assertEqual(thing.get_non_remote_number_prop(), fake.last)
- # Test invoke_action in non blocking mode
- noblock_payload = fake.pylist(20, value_types=[int, float, str, bool])
- noblock_msg_id = thing.invoke_action("action_echo", noblock_payload, noblock=True)
- self.assertIsInstance(noblock_msg_id, str)
- self.assertEqual(
- thing.invoke_action("action_echo", fake.pylist(20, value_types=[int, float, str, bool])),
- fake.last,
- )
- self.assertEqual(
- thing.invoke_action("action_echo", fake.pylist(10, value_types=[int, float, str, bool])),
- fake.last,
- )
- self.assertEqual(thing.read_reply(noblock_msg_id), noblock_payload)
-
- def test_03_rwd_properties(self):
- """Test the read, write and delete of properties on the zmq object proxy client"""
- thing = self.get_client()
- self.assertIsInstance(thing, ObjectProxy)
- # Test read_property method
- self.assertIsInstance(thing.read_property("number_prop"), (int, float))
- self.assertIsInstance(thing.read_property("string_prop"), str)
- self.assertIn(thing.read_property("selector_prop"), TestThing.selector_prop.objects)
- # Test write_property method
- thing.write_property("number_prop", fake.random_number())
- self.assertEqual(thing.read_property("number_prop"), fake.last)
- thing.write_property(
- "selector_prop",
- TestThing.selector_prop.objects[fake.random_int(0, len(TestThing.selector_prop.objects) - 1)],
- )
- self.assertEqual(
- thing.read_property("selector_prop"),
- TestThing.selector_prop.objects[fake.last],
- )
- thing.write_property("observable_list_prop", fake.pylist(25, value_types=[int, float, str, bool]))
- self.assertEqual(thing.read_property("observable_list_prop"), fake.last)
- # Test read property through dot notation attribute access
- self.assertIsInstance(thing.number_prop, (int, float))
- self.assertIsInstance(thing.string_prop, str)
- self.assertIn(thing.selector_prop, TestThing.selector_prop.objects)
- # Test write property through dot notation attribute access
- thing.number_prop = fake.random_number()
- self.assertEqual(thing.number_prop, fake.last)
- thing.selector_prop = TestThing.selector_prop.objects[
- fake.random_int(0, len(TestThing.selector_prop.objects) - 1)
- ]
- self.assertEqual(thing.selector_prop, TestThing.selector_prop.objects[fake.last])
- thing.observable_list_prop = fake.pylist(25, value_types=[int, float, str, bool])
- self.assertEqual(thing.observable_list_prop, fake.last)
- # Test one way write property
- thing.write_property("number_prop", fake.random_number(), oneway=True)
- self.assertEqual(thing.read_property("number_prop"), fake.last)
- thing.write_property(
- "selector_prop",
- TestThing.selector_prop.objects[fake.random_int(0, len(TestThing.selector_prop.objects) - 1)],
- oneway=True,
- )
- self.assertEqual(
- thing.read_property("selector_prop"),
- TestThing.selector_prop.objects[fake.last],
- )
- thing.write_property(
- "observable_list_prop",
- fake.pylist(25, value_types=[int, float, str, bool]),
- oneway=True,
- )
- self.assertEqual(thing.read_property("observable_list_prop"), fake.last)
- # Test noblock read property
- noblock_msg_id = thing.read_property("number_prop", noblock=True)
- self.assertIsInstance(noblock_msg_id, str)
- self.assertIn(thing.read_property("selector_prop"), TestThing.selector_prop.objects)
- self.assertIsInstance(thing.read_property("string_prop"), str)
- self.assertEqual(thing.read_reply(noblock_msg_id), thing.number_prop)
- # Test noblock write property
- noblock_msg_id = thing.write_property("number_prop", fake.random_number(), noblock=True)
- self.assertIsInstance(noblock_msg_id, str)
- self.assertEqual(thing.read_property("number_prop"), fake.last) # noblock worked
- self.assertEqual(thing.read_reply(noblock_msg_id), None)
- # Test exception propagation to client
- thing.string_prop = "world"
- self.assertEqual(thing.string_prop, "world")
- with self.assertRaises(ValueError):
- thing.string_prop = "WORLD"
- with self.assertRaises(TypeError):
- thing.int_prop = "5"
- # Test non remote prop (non-)availability on client
- with self.assertRaises(AttributeError):
- thing.non_remote_number_prop
-
- def test_04_RW_multiple_properties(self):
- # TD is not well defined for this yet, although both client and server separately work.
- # Test partial list of read write properties
- thing = self.get_client()
- self.assertIsInstance(thing, ObjectProxy)
- # Test read_multiple_properties method
- thing.write_multiple_properties(number_prop=15, string_prop="foobar")
- self.assertEqual(thing.number_prop, 15)
- self.assertEqual(thing.string_prop, "foobar")
- # check prop that was not set in multiple properties
-
- thing.int_prop = 5
- thing.selector_prop = "b"
- thing.number_prop = -15 # simply override
- props = thing.read_multiple_properties(names=["selector_prop", "int_prop", "number_prop", "string_prop"])
- self.assertEqual(props["selector_prop"], "b")
- self.assertEqual(props["int_prop"], 5)
- self.assertEqual(props["number_prop"], -15)
- self.assertEqual(props["string_prop"], "foobar")
-
- def test_05_subscribe_event(self):
- """Test the subscription to an event on the zmq object proxy client"""
- thing = self.get_client()
- self.assertIsInstance(thing, ObjectProxy)
- results = []
+@pytest.fixture(scope="class")
+def access_point(request) -> str:
+ return "INPROC"
- def cb(value: SSE):
- results.append(value)
- thing.subscribe_event("test_event", cb)
- time.sleep(1) # wait for the subscription to be established
+@pytest.fixture(scope="class")
+def thing(access_point) -> Generator[TestThing, None, None]:
+ thing_id = f"test-thing-{uuid_hex()}"
+ thing = TestThing(id=thing_id)
+ thing.run_with_zmq_server(forked=True, access_points=[access_point])
+ yield thing
+ thing.rpc_server.stop()
+
- thing.push_events()
- time.sleep(3) # wait for the event to be pushed
- self.assertGreater(len(results), 0, "No events received")
- self.assertEqual(len(results), 100)
- thing.unsubscribe_event("test_event")
+@pytest.fixture(scope="class")
+def thing_model(thing: TestThing) -> dict[str, Any]:
+ return thing.get_thing_model(ignore_errors=True).json()
- def test_06_observe_properties(self):
- thing = self.get_client()
- self.assertIsInstance(thing, ObjectProxy)
- # First check if an attribute is set on the object proxy
- self.assertIsNotNone(thing, "observable_list_prop_change_event")
- self.assertIsNotNone(thing, "observable_readonly_prop_change_event")
+@pytest.fixture(scope="class")
+def client(thing: TestThing, access_point: str) -> Generator[ObjectProxy, None, None]:
+ client = ClientFactory.zmq(
+ server_id=thing.id,
+ thing_id=thing.id,
+ access_point=access_point.replace("*", "localhost"),
+ ignore_TD_errors=True,
+ )
+ yield client
+ # client.close()
- # req 1 - observable events come due to writing a property
- propective_values = [
- [1, 2, 3, 4, 5],
- ["a", "b", "c", "d", "e"],
- [1, "a", 2, "b", 3],
+
+@pytest.mark.asyncio(loop_scope="class")
+class TestRPC_E2E:
+ """End-to-end tests for RPC"""
+
+ def test_01_creation_and_handshake(self, client: ObjectProxy, thing_model: dict[str, Any]):
+ assert isinstance(client, ObjectProxy)
+ assert len(client.properties) + len(client.actions) + len(client.events) >= (
+ len(thing_model["properties"]) + len(thing_model["actions"]) + len(thing_model["events"])
+ )
+
+ @pytest.mark.parametrize(
+ "payload",
+ [
+ pytest.param(fake.text(max_nb_chars=100), id="text"),
+ pytest.param(fake.sentence(), id="sentence"),
+ pytest.param(fake.json(), id="json"),
+ ],
+ )
+ def test_02_invoke_action_manual(self, client: ObjectProxy, payload: Any):
+ assert client.invoke_action("action_echo", payload) == payload
+
+ @pytest.mark.parametrize(
+ "payload",
+ [
+ pytest.param(fake.chrome(), id="chrome"),
+ pytest.param(fake.sha256(), id="sha256"),
+ pytest.param(fake.address(), id="address"),
+ ],
+ )
+ def test_03_invoke_action_dot_notation(self, client: ObjectProxy, payload: Any):
+ assert client.action_echo(payload) == payload
+
+ @pytest.mark.parametrize(
+ "payload",
+ [
+ pytest.param(fake.random_number(), id="random-number"),
+ pytest.param(fake.random_int(), id="random-int"),
+ ],
+ )
+ def test_04_invoke_action_oneway(self, client: ObjectProxy, payload: Any):
+ assert client.invoke_action("set_non_remote_number_prop", payload, oneway=True) is None
+ assert client.get_non_remote_number_prop() == payload
+
+ @pytest.mark.parametrize(
+ "payload",
+ [
+ pytest.param(
+ fake.pylist(20, value_types=[int, float, str, bool]),
+ id="pylist-explicit-types",
+ ),
+ ],
+ )
+ def test_05_invoke_action_noblock(self, client: ObjectProxy, payload: Any):
+ noblock_msg_id = client.invoke_action("action_echo", payload, noblock=True)
+ assert isinstance(noblock_msg_id, str)
+ assert client.invoke_action("action_echo", fake.pylist(20, value_types=[int, float, str, bool])) == fake.last
+ assert client.invoke_action("action_echo", fake.pylist(10, value_types=[int, float, str, bool])) == fake.last
+ assert client.read_reply(noblock_msg_id) == payload
+
+ def test_06_read_property_manual(self, client: ObjectProxy):
+ assert isinstance(client.read_property("number_prop"), (int, float))
+ assert isinstance(client.read_property("string_prop"), str)
+ assert client.read_property("selector_prop") in TestThing.selector_prop.objects
+
+ @pytest.mark.parametrize(
+ "prop, payload",
+ [
+ pytest.param("number_prop", fake.random_number(), id="random-number"),
+ pytest.param(
+ "selector_prop",
+ TestThing.selector_prop.objects[fake.random_int(0, len(TestThing.selector_prop.objects) - 1)],
+ id="selector-value",
+ ),
+ pytest.param(
+ "observable_list_prop",
+ fake.pylist(25, value_types=[int, float, str, bool]),
+ id="observable-list",
+ ),
+ ],
+ )
+ def test_07_write_property_manual(self, client: ObjectProxy, prop: str, payload: Any):
+ client.write_property(prop, payload)
+ assert client.read_property(prop) == payload
+
+ def test_08_read_property_dot_notation(self, client: ObjectProxy):
+ assert isinstance(client.number_prop, (int, float))
+ assert isinstance(client.string_prop, str)
+ assert client.selector_prop in TestThing.selector_prop.objects
+
+ def test_09_write_property_dot_notation(self, client: ObjectProxy):
+ client.number_prop = fake.random_number()
+ assert client.number_prop == fake.last
+ client.selector_prop = TestThing.selector_prop.objects[
+ fake.random_int(0, len(TestThing.selector_prop.objects) - 1)
]
- result = []
- attempt = 0
+ assert client.selector_prop == TestThing.selector_prop.objects[fake.last]
+ client.observable_list_prop = fake.pylist(25, value_types=[int, float, str, bool])
+ assert client.observable_list_prop == fake.last
+
+ @pytest.mark.parametrize(
+ "prop, payload",
+ [
+ pytest.param("number_prop", fake.random_number(), id="random-number"),
+ pytest.param(
+ "selector_prop",
+ TestThing.selector_prop.objects[fake.random_int(0, len(TestThing.selector_prop.objects) - 1)],
+ id="selector-value",
+ ),
+ pytest.param(
+ "observable_list_prop",
+ fake.pylist(25, value_types=[int, float, str, bool]),
+ id="observable-list",
+ ),
+ ],
+ )
+ def test_10_write_property_oneway(self, client: ObjectProxy, prop: str, payload: Any):
+ client.write_property(prop, payload, oneway=True)
+ assert client.read_property(prop) == payload
+
+ def test_11_read_property_noblock(self, client: ObjectProxy):
+ noblock_msg_id = client.read_property("number_prop", noblock=True)
+ assert isinstance(noblock_msg_id, str)
+ assert client.read_property("selector_prop") in TestThing.selector_prop.objects
+ assert isinstance(client.read_property("string_prop"), str)
+ assert client.read_reply(noblock_msg_id) == client.number_prop
+
+ def test_12_write_property_noblock(self, client: ObjectProxy):
+ noblock_msg_id = client.write_property("number_prop", fake.random_number(), noblock=True)
+ assert isinstance(noblock_msg_id, str)
+ assert client.read_property("number_prop") == fake.last
+ assert client.read_reply(noblock_msg_id) is None
+
+ def test_13_error_handling(self, client: ObjectProxy):
+ client.string_prop = "world"
+ assert client.string_prop == "world"
+ with pytest.raises(ValueError):
+ client.string_prop = "WORLD"
+ with pytest.raises(TypeError):
+ client.int_prop = "5"
+ with pytest.raises(AttributeError):
+ _ = client.non_remote_number_prop
+
+ def test_14_rw_multiple_properties(self, client: ObjectProxy):
+ client.write_multiple_properties(number_prop=15, string_prop="foobar")
+ assert client.number_prop == 15
+ assert client.string_prop == "foobar"
+ client.int_prop = 5
+ client.selector_prop = "b"
+ client.number_prop = -15
+ props = client.read_multiple_properties(names=["selector_prop", "int_prop", "number_prop", "string_prop"])
+ assert props["selector_prop"] == "b"
+ assert props["int_prop"] == 5
+ assert props["number_prop"] == -15
+ assert props["string_prop"] == "foobar"
+
+ def test_15_subscribe_event(self, client: ObjectProxy):
+ results = []
def cb(value: SSE):
- nonlocal attempt, result
- self.assertEqual(value.data, propective_values[attempt])
- result.append(value)
- attempt += 1
+ results.append(value)
- thing.observe_property("observable_list_prop", cb)
+ client.subscribe_event("test_event", cb)
time.sleep(3)
- # Calm down for event publisher to connect fully as there is no handshake for events
- for value in propective_values:
- thing.observable_list_prop = value
- for i in range(20):
- if attempt == len(propective_values):
+ for i in range(10):
+ client.push_events(total_number_of_events=1)
+ time.sleep(1)
+ if len(results) > 0:
+ results.clear()
break
- # wait for the callback to be called
- time.sleep(0.1)
- thing.unobserve_property("observable_list_prop")
-
- for res in result:
- self.assertIn(res.data, propective_values)
+ else:
+ pytest.skip("No events received from server, probably due to OS level issues")
- # # req 2 - observable events come due to reading a property
- propective_values = [1, 2, 3, 4, 5]
+ client.push_events()
+ time.sleep(3)
+ assert len(results) > 0, "No events received"
+ assert abs(len(results) - 100) < 3, f"Expected 100 events, got {len(results)}"
+ client.unsubscribe_event("test_event")
+
+ @pytest.mark.parametrize(
+ "prop, prospective_values, op",
+ [
+ pytest.param(
+ "observable_list_prop",
+ [
+ [1, 2, 3, 4, 5],
+ ["a", "b", "c", "d", "e"],
+ [1, "a", 2, "b", 3],
+ ],
+ "write",
+ id="observable-list-prop",
+ ),
+ pytest.param(
+ "observable_readonly_prop",
+ [1, 2, 3, 4, 5],
+ "read",
+ id="observable-readonly-prop",
+ ),
+ ],
+ )
+ def test_16_observe_properties(self, client: ObjectProxy, prop: str, prospective_values: Any, op: str):
+ assert hasattr(client, f"{prop}_change_event")
result = []
attempt = 0
def cb(value: SSE):
- nonlocal attempt, result
- self.assertEqual(value.data, propective_values[attempt])
+ nonlocal attempt
result.append(value)
attempt += 1
- thing.observe_property("observable_readonly_prop", cb)
+ client.observe_property(prop, cb)
time.sleep(3)
- # Calm down for event publisher to connect fully as there is no handshake for events
- for _ in propective_values:
- thing.observable_readonly_prop # read property through do notation access
-
- for i in range(20):
- if attempt == len(propective_values):
+ for value in prospective_values:
+ if op == "read":
+ _ = client.read_property(prop)
+ else:
+ client.write_property(prop, value)
+ for _ in range(20):
+ if attempt == len(prospective_values):
break
- # wait for the callback to be called
time.sleep(0.1)
-
- thing.unobserve_property("observable_readonly_prop")
- for res in result:
- self.assertIn(res.data, propective_values)
-
-
-class TestRPCEndToEndAsync(AsyncTestCase):
- @classmethod
- def setUpClass(cls):
- cls.thing_id = f"test-thing-{uuid4().hex[:8]}"
- cls.server_id = cls.thing_id
- super().setUpClass()
- cls.setUpThing()
-
- @classmethod
- def setUpThing(cls):
- """Set up the thing for the zmq object proxy client"""
- cls.thing = TestThing(id=cls.thing_id)
- cls.thing.run_with_zmq_server(forked=True)
- cls.thing_model = cls.thing.get_thing_model(ignore_errors=True).json()
-
- @classmethod
- def tearDownClass(cls):
- cls._client = None
- cls.thing.rpc_server.stop()
- super().tearDownClass()
-
- @classmethod
- def get_client(cls):
- try:
- if cls._client is not None:
- return cls._client
- raise AttributeError()
- except AttributeError:
- cls._client = ClientFactory.zmq(
- cls.server_id,
- cls.thing_id,
- "IPC",
- ignore_TD_errors=True,
- )
- return cls._client
-
- async def test_01_creation_and_handshake(self):
- """Test the creation and handshake of the zmq object proxy client"""
- thing = self.get_client()
- self.assertIsInstance(thing, ObjectProxy)
- self.assertTrue(
- len(thing.properties) + len(thing.actions) + len(thing.events)
- >= len(self.thing_model["properties"]) + len(self.thing_model["actions"]) + len(self.thing_model["events"])
- )
-
- async def test_02_invoke_action(self):
- thing = self.get_client()
- self.assertIsInstance(thing, ObjectProxy)
- self.assertEqual(
- await thing.async_invoke_action("action_echo", fake.text(max_nb_chars=100)),
- fake.last,
- )
- self.assertEqual(await thing.async_invoke_action("action_echo", fake.sentence()), fake.last)
- self.assertEqual(await thing.async_invoke_action("action_echo", fake.json()), fake.last)
-
- async def test_03_rwd_properties(self):
- """Test the read, write and delete of properties on the zmq object proxy client"""
- thing = self.get_client()
- self.assertIsInstance(thing, ObjectProxy)
- # Test read_property method
- self.assertIsInstance(await thing.async_read_property("number_prop"), (int, float))
- self.assertIsInstance(await thing.async_read_property("string_prop"), str)
- self.assertIn(
- await thing.async_read_property("selector_prop"),
- TestThing.selector_prop.objects,
- )
- # Test write_property method
- await thing.async_write_property("number_prop", fake.random_number())
- self.assertEqual(await thing.async_read_property("number_prop"), fake.last)
- await thing.async_write_property(
- "selector_prop",
- TestThing.selector_prop.objects[fake.random_int(0, len(TestThing.selector_prop.objects) - 1)],
- )
- self.assertEqual(
- await thing.async_read_property("selector_prop"),
- TestThing.selector_prop.objects[fake.last],
- )
- await thing.async_write_property("observable_list_prop", fake.pylist(25, value_types=[int, float, str, bool]))
- self.assertEqual(await thing.async_read_property("observable_list_prop"), fake.last)
- # await complete_pending_tasks_in_current_loop_async()
-
-
-def load_tests(loader, tests, pattern):
- suite = unittest.TestSuite()
- suite.addTest(unittest.TestLoader().loadTestsFromTestCase(TestRPCEndToEnd))
- suite.addTest(unittest.TestLoader().loadTestsFromTestCase(TestRPCEndToEndAsync))
- return suite
-
-
-if __name__ == "__main__":
- runner = TestRunner()
- runner.run(load_tests(unittest.TestLoader(), None, None))
+ client.unobserve_property(prop)
+ for index, res in enumerate(result):
+ assert res.data == prospective_values[index]
+
+ @pytest.mark.parametrize(
+ "payload",
+ [
+ pytest.param(fake.text(max_nb_chars=100), id="text"),
+ pytest.param(fake.sentence(), id="sentence"),
+ pytest.param(fake.json(), id="json"),
+ ],
+ )
+ async def test_17_async_invoke_action(self, client, payload):
+ result = await client.async_invoke_action("action_echo", payload)
+ assert result == payload
+
+ async def test_18_async_read_property(self, client):
+ assert isinstance(await client.async_read_property("number_prop"), (int, float))
+ assert isinstance(await client.async_read_property("string_prop"), str)
+ assert await client.async_read_property("selector_prop") in TestThing.selector_prop.objects
+
+ @pytest.mark.parametrize(
+ "prop, payload",
+ [
+ pytest.param("number_prop", fake.random_number(), id="random-number"),
+ pytest.param(
+ "selector_prop",
+ TestThing.selector_prop.objects[fake.random_int(0, len(TestThing.selector_prop.objects) - 1)],
+ id="selector-value",
+ ),
+ pytest.param(
+ "observable_list_prop",
+ fake.pylist(25, value_types=[int, float, str, bool]),
+ id="observable-list",
+ ),
+ ],
+ )
+ async def test_19_async_write_property(self, client, prop, payload):
+ await client.async_write_property(prop, payload)
+ assert await client.async_read_property(prop) == payload
diff --git a/tests/test_12_protocols_zmq.py b/tests/test_12_protocols_zmq.py
deleted file mode 100644
index 0bdb4c84..00000000
--- a/tests/test_12_protocols_zmq.py
+++ /dev/null
@@ -1,131 +0,0 @@
-import logging
-import unittest
-
-from hololinked.client import ClientFactory
-from hololinked.logger import setup_logging
-
-try:
- from .test_11_rpc_e2e import TestRPCEndToEnd, TestRPCEndToEndAsync
- from .utils import TestRunner
- from .things import TestThing
-except ImportError:
- from test_11_rpc_e2e import TestRPCEndToEnd, TestRPCEndToEndAsync
- from utils import TestRunner
- from things import TestThing
-
-setup_logging(log_level=logging.ERROR + 10)
-
-
-class TestZMQ_TCP(TestRPCEndToEnd):
- @classmethod
- def setUpClass(cls):
- super().setUpClass()
- print("Test ZMQ TCP End to End")
-
- @classmethod
- def setUpThing(cls):
- """Set up the thing for the zmq object proxy client"""
- cls.thing = TestThing(id=cls.thing_id)
- cls.thing.run_with_zmq_server(forked=True, access_points="tcp://*:5557")
- cls.thing_model = cls.thing.get_thing_model(ignore_errors=True).json()
-
- @classmethod
- def get_client(cls):
- try:
- if cls._client is not None:
- return cls._client
- raise AttributeError()
- except AttributeError:
- cls._client = ClientFactory.zmq(
- cls.server_id,
- cls.thing_id,
- "tcp://localhost:5557",
- ignore_TD_errors=True,
- )
- return cls._client
-
-
-class TestZMQAsync_TCP(TestRPCEndToEndAsync):
- @classmethod
- def setUpThing(cls):
- """Set up the thing for the zmq object proxy client"""
- cls.thing = TestThing(id=cls.thing_id)
- cls.thing.run_with_zmq_server(forked=True, access_points="tcp://*:6000")
- cls.thing_model = cls.thing.get_thing_model(ignore_errors=True).json()
-
- @classmethod
- def get_client(cls):
- try:
- if cls._client is not None:
- return cls._client
- raise AttributeError()
- except AttributeError:
- cls._client = ClientFactory.zmq(
- cls.server_id,
- cls.thing_id,
- "tcp://localhost:6000",
- ignore_TD_errors=True,
- )
- return cls._client
-
-
-class TestZMQ_INPROC(TestRPCEndToEnd):
- @classmethod
- def setUpThing(cls):
- """Set up the thing for the zmq object proxy client"""
- cls.thing = TestThing(id=cls.thing_id)
- cls.thing.run_with_zmq_server(forked=True, access_points="inproc")
- cls.thing_model = cls.thing.get_thing_model(ignore_errors=True).json()
-
- @classmethod
- def get_client(cls):
- try:
- if cls._client is not None:
- return cls._client
- raise AttributeError()
- except AttributeError:
- cls._client = ClientFactory.zmq(
- cls.server_id,
- cls.thing_id,
- "inproc",
- ignore_TD_errors=True,
- )
- return cls._client
-
-
-class TestZMQAsync_INPROC(TestRPCEndToEndAsync):
- @classmethod
- def setUpThing(cls):
- """Set up the thing for the zmq object proxy client"""
- cls.thing = TestThing(id=cls.thing_id)
- cls.thing.run_with_zmq_server(forked=True, access_points="inproc")
- cls.thing_model = cls.thing.get_thing_model(ignore_errors=True).json()
-
- @classmethod
- def get_client(cls):
- try:
- if cls._client is not None:
- return cls._client
- raise AttributeError()
- except AttributeError:
- cls._client = ClientFactory.zmq(
- cls.server_id,
- cls.thing_id,
- "inproc",
- ignore_TD_errors=True,
- )
- return cls._client
-
-
-def load_tests(loader, tests, pattern):
- suite = unittest.TestSuite()
- suite.addTest(unittest.TestLoader().loadTestsFromTestCase(TestZMQ_TCP))
- suite.addTest(unittest.TestLoader().loadTestsFromTestCase(TestZMQAsync_TCP))
- suite.addTest(unittest.TestLoader().loadTestsFromTestCase(TestZMQ_INPROC))
- suite.addTest(unittest.TestLoader().loadTestsFromTestCase(TestZMQAsync_INPROC))
- return suite
-
-
-if __name__ == "__main__":
- runner = TestRunner()
- runner.run(load_tests(unittest.TestLoader(), None, None))
diff --git a/tests/test_12_protocols_zmq_ipc.py b/tests/test_12_protocols_zmq_ipc.py
new file mode 100644
index 00000000..46cef4bc
--- /dev/null
+++ b/tests/test_12_protocols_zmq_ipc.py
@@ -0,0 +1,18 @@
+import pytest
+
+try:
+ from .test_11_rpc_e2e import TestRPC_E2E as BaseRPC_E2E # noqa: F401
+ from .test_11_rpc_e2e import client, thing, thing_model # noqa: F401
+except ImportError:
+ from test_11_rpc_e2e import TestRPC_E2E as BaseRPC_E2E # noqa: F401
+ from test_11_rpc_e2e import client, thing, thing_model # noqa: F401
+
+
+@pytest.fixture(scope="class")
+def access_point(request):
+ return "IPC"
+
+
+@pytest.mark.asyncio(loop_scope="class")
+class TestZMQ_IPC_E2E(BaseRPC_E2E):
+ pass
diff --git a/tests/test_13_protocols_zmq_tcp.py b/tests/test_13_protocols_zmq_tcp.py
new file mode 100644
index 00000000..fa00462f
--- /dev/null
+++ b/tests/test_13_protocols_zmq_tcp.py
@@ -0,0 +1,18 @@
+import pytest
+
+try:
+ from .test_11_rpc_e2e import TestRPC_E2E as BaseRPC_E2E # noqa: F401
+ from .test_11_rpc_e2e import client, thing, thing_model # noqa: F401
+except ImportError:
+ from test_11_rpc_e2e import TestRPC_E2E as BaseRPC_E2E # noqa: F401
+ from test_11_rpc_e2e import client, thing, thing_model # noqa: F401
+
+
+@pytest.fixture(scope="class")
+def access_point(request):
+ return "tcp://*:61000"
+
+
+@pytest.mark.asyncio(loop_scope="class")
+class TestZMQ_TCP_E2E(BaseRPC_E2E):
+ pass
diff --git a/tests/test_14_protocols_http.py b/tests/test_14_protocols_http.py
new file mode 100644
index 00000000..83ca4370
--- /dev/null
+++ b/tests/test_14_protocols_http.py
@@ -0,0 +1,556 @@
+import base64
+import itertools
+import random
+import sys
+import time
+from contextlib import contextmanager
+from dataclasses import dataclass
+from typing import Any, Generator
+
+import pytest
+import requests
+
+from hololinked.client import ClientFactory, ObjectProxy
+from hololinked.config import global_config
+from hololinked.core.zmq.message import (
+ PreserializedData,
+ SerializableData,
+ ServerExecutionContext,
+ ThingExecutionContext,
+ default_server_execution_context,
+)
+from hololinked.serializers import BaseSerializer, JSONSerializer, MsgpackSerializer, PickleSerializer
+from hololinked.server import stop
+from hololinked.server.http import HTTPServer
+from hololinked.server.http.handlers import RPCHandler
+from hololinked.server.security import Argon2BasicSecurity, BcryptBasicSecurity, Security
+from hololinked.utils import uuid_hex
+
+try:
+ from .things import OceanOpticsSpectrometer
+except ImportError:
+ from things import OceanOpticsSpectrometer
+
+
+hostname_prefix = "http://127.0.0.1"
+readiness_endpoint = "/readiness"
+liveness_endpoint = "/liveness"
+stop_endpoint = "/stop"
+start_acquisition_endpoint = "/start-acquisition"
+intensity_measurement_event_endpoint = "/intensity-measurement-event"
+stop_acquisition_endpoint = "/stop-acquisition"
+
+count = itertools.count(62000)
+
+
+@pytest.fixture(scope="module")
+def session() -> requests.Session:
+ return requests.Session()
+
+
+@pytest.fixture(scope="function")
+def port() -> int:
+ global count
+ return next(count)
+
+
+@pytest.fixture(scope="function")
+def server(port) -> Generator[HTTPServer, None, None]:
+ server = HTTPServer(port=port)
+ server.run(forked=True)
+ wait_until_server_ready(port=port)
+ yield server
+ stop()
+
+
+@pytest.fixture(scope="function")
+def thing(port: int) -> Generator[OceanOpticsSpectrometer, None, None]:
+ thing = OceanOpticsSpectrometer(id=f"test-thing-{uuid_hex()}", serial_number="simulation")
+ print() # TODO, can be removed when tornado logs respect level
+ thing.run_with_http_server(port=port, forked=True, config=dict(cors=True))
+ wait_until_server_ready(port=port)
+ yield thing
+ stop()
+
+
+@contextmanager
+def running_thing(
+ id_prefix: str,
+ port: int = None,
+ **http_server_kwargs,
+) -> Generator[OceanOpticsSpectrometer, None, None]:
+ """same as thing fixture but to use it manually"""
+ global count
+ port = port or next(count)
+ thing = OceanOpticsSpectrometer(id=f"{id_prefix}-{uuid_hex()}", serial_number="simulation")
+ print() # TODO, can be removed when tornado logs respect level
+ thing.run_with_http_server(port=port, forked=True, config=dict(cors=True), **http_server_kwargs)
+ wait_until_server_ready(port=port)
+ try:
+ yield thing
+ finally:
+ stop()
+
+
+@pytest.fixture(scope="function")
+def endpoints(thing: OceanOpticsSpectrometer) -> list[tuple[str, str, Any]]:
+ return running_thing_endpoints(thing)
+
+
+@pytest.fixture(scope="function")
+def td_endpoint(thing: OceanOpticsSpectrometer, port: int) -> str:
+ return f"{hostname_prefix}:{port}/{thing.id}/resources/wot-td"
+
+
+@pytest.fixture(scope="function")
+def object_proxy(td_endpoint: str) -> "ObjectProxy":
+ return ClientFactory.http(url=td_endpoint)
+
+
+def running_thing_endpoints(thing: OceanOpticsSpectrometer) -> list[tuple[str, str, Any]]:
+ if thing.__class__ == OceanOpticsSpectrometer:
+ return [
+ # properties
+ ("get", f"/{thing.id}/max-intensity", 16384),
+ ("get", f"/{thing.id}/serial-number", "simulation"),
+ ("put", f"/{thing.id}/integration-time", 1200),
+ ("get", f"/{thing.id}/integration-time", 1200),
+ # actions
+ ("post", f"/{thing.id}/disconnect", None),
+ ("post", f"/{thing.id}/connect", None),
+ ]
+ raise NotImplementedError(f"endpoints cannot be generated for {thing.__class__}")
+
+
+def wait_until_server_ready(port: int, tries: int = 10) -> None:
+ session = requests.Session()
+ for _ in range(tries):
+ try:
+ response = session.get(f"{hostname_prefix}:{port}{liveness_endpoint}")
+ if response.status_code in [200, 201, 202, 204]:
+ response = session.get(f"{hostname_prefix}:{port}{readiness_endpoint}")
+ if response.status_code in [200, 201, 202, 204]:
+ return
+ except Exception:
+ pass
+ time.sleep(1)
+ print(f"Server on port {port} not ready after {tries} tries, you need to retrigger this test job")
+ sys.exit(1)
+
+
+def sse_stream(url: str, chunk_size: int = 2048, **kwargs):
+ with requests.get(url, stream=True, **kwargs) as resp:
+ resp.raise_for_status()
+ buffer = "" # type: str
+ for chunk in resp.iter_content(chunk_size=chunk_size, decode_unicode=True):
+ buffer += chunk
+ while "\n\n" in buffer:
+ raw_event, buffer = buffer.split("\n\n", 1)
+ event = {}
+ for line in raw_event.splitlines():
+ if not line or line.startswith(":"):
+ continue
+ if ":" in line:
+ field, value = line.split(":", 1)
+ event.setdefault(field, "")
+ event[field] += value.lstrip()
+ yield event
+
+
+async def test_01_init_run_and_stop(port: int):
+ server = HTTPServer(port=port)
+ server.run(forked=True)
+ wait_until_server_ready(port=port)
+ await server.async_stop()
+ stop()
+ time.sleep(2)
+
+ # stop remotely
+ server.run(forked=True)
+ wait_until_server_ready(port=port)
+ time.sleep(2)
+ response = requests.post(f"{hostname_prefix}:{port}{stop_endpoint}")
+ assert response.status_code in [200, 201, 202, 204]
+ time.sleep(2)
+ await server.async_stop()
+ stop()
+
+
+def test_02_add_interaction_affordance(server: HTTPServer):
+ server.add_property("/max-intensity", OceanOpticsSpectrometer.max_intensity)
+ server.add_action("/connect", OceanOpticsSpectrometer.connect)
+ server.add_event("/intensity/event", OceanOpticsSpectrometer.intensity_measurement_event)
+ assert "/max-intensity" in server.router
+ assert "/connect" in server.router
+ assert "/intensity/event" in server.router
+ # replacing interaction affordances on an existing URL path causes a warning
+ with pytest.warns(UserWarning):
+ server.add_property("/max-intensity", OceanOpticsSpectrometer.last_intensity)
+ with pytest.warns(UserWarning):
+ server.add_action("/connect", OceanOpticsSpectrometer.disconnect)
+ with pytest.warns(UserWarning):
+ server.add_event("/intensity/event", OceanOpticsSpectrometer.intensity_measurement_event)
+
+
+# tests 03 & 04 removed as they need more work to be done
+
+
+class TestableRPCHandler(RPCHandler):
+ """handler that tests RPC handler functionalities, without executing an operation on a Thing"""
+
+ @dataclass
+ class LatestRequestInfo:
+ server_execution_context: ServerExecutionContext | dict[str, Any]
+ thing_execution_context: ThingExecutionContext | dict[str, Any]
+ payload: SerializableData
+ preserialized_payload: PreserializedData
+
+ latest_request_info: LatestRequestInfo
+
+ def update_latest_request_info(self) -> None:
+ server_execution_context, thing_execution_context, _, _ = self.get_execution_parameters()
+ payload, preserialized_payload = self.get_request_payload()
+ TestableRPCHandler.latest_request_info = TestableRPCHandler.LatestRequestInfo(
+ server_execution_context=server_execution_context,
+ thing_execution_context=thing_execution_context,
+ payload=payload,
+ preserialized_payload=preserialized_payload,
+ )
+
+ async def get(self):
+ self.update_latest_request_info()
+ self.set_status(200)
+ self.finish()
+
+ async def put(self):
+ self.update_latest_request_info()
+ self.set_status(200)
+ self.finish()
+
+ async def post(self):
+ await self.handle_through_thing("invokeaction")
+
+
+@pytest.fixture(scope="function")
+def test_rpc_handler_thing(port: int) -> Generator[OceanOpticsSpectrometer, None, None]:
+ global_config.ALLOW_PICKLE = True
+ with running_thing(
+ id_prefix="test-rpc-handler",
+ port=port,
+ property_handler=TestableRPCHandler,
+ action_handler=TestableRPCHandler,
+ ) as thing:
+ yield thing
+ global_config.ALLOW_PICKLE = False
+
+
+@pytest.mark.parametrize(
+ "serializer",
+ [
+ pytest.param(JSONSerializer(), id="json"),
+ pytest.param(MsgpackSerializer(), id="msgpack"),
+ pytest.param(PickleSerializer(), id="pickle"),
+ ],
+)
+@pytest.mark.parametrize(
+ "endpoint",
+ [
+ pytest.param(("get", "/integration-time", None), id="get without params"),
+ pytest.param(("get", "/integration-time?fetchExecutionLogs=true", None), id="get with fetchExecutionLogs"),
+ pytest.param(
+ ("get", "/integration-time?fetchExecutionLogs=true&oneway=true", None),
+ id="get with fetchExecutionLogs and oneway",
+ ),
+ pytest.param(
+ ("get", "/integration-time?oneway=true&invokationTimeout=100", None),
+ id="get with oneway and invokationTimeout",
+ ),
+ pytest.param(
+ (
+ "get",
+ "/integration-time?invokationTimeout=100&executionTimeout=120&fetchExecutionLogs=true",
+ None,
+ ),
+ id="get with all params",
+ ),
+ pytest.param(("put", "/integration-time", 1200), id="put without params"),
+ pytest.param(
+ ("put", "/integration-time?fetchExecutionLogs=true", {"a": 1, "b": 2}), id="put with fetchExecutionLogs"
+ ),
+ pytest.param(
+ ("put", "/integration-time?fetchExecutionLogs=true&oneway=true", [1, 2, 3]),
+ id="put with fetchExecutionLogs and oneway",
+ ),
+ pytest.param(
+ ("put", "/integration-time?oneway=true&invokationTimeout=100", "abcd"),
+ id="put with oneway and invokationTimeout",
+ ),
+ pytest.param(
+ (
+ "put",
+ "/integration-time?invokationTimeout=100&executionTimeout=120&fetchExecutionLogs=true",
+ True,
+ ),
+ id="put with all params",
+ ),
+ ],
+)
+def test_05_handlers(
+ session: requests.Session,
+ test_rpc_handler_thing: OceanOpticsSpectrometer,
+ port: int,
+ serializer: BaseSerializer,
+ endpoint: tuple[str, str, Any],
+):
+ """Test request info and payload decoding in RPC handlers along with content type handling"""
+
+ method, path, body = endpoint
+ response = session.request(
+ method=method,
+ url=f"{hostname_prefix}:{port}/{test_rpc_handler_thing.id}{path}",
+ data=serializer.dumps(body) if body is not None else None,
+ headers={"Content-Type": serializer.content_type},
+ )
+ assert response.status_code in [200, 201, 202, 204]
+ # test ThingExecutionContext
+ assert isinstance(TestableRPCHandler.latest_request_info.thing_execution_context, ThingExecutionContext)
+ if "fetchExecutionLogs" in path:
+ assert TestableRPCHandler.latest_request_info.thing_execution_context.fetchExecutionLogs
+ else:
+ assert not TestableRPCHandler.latest_request_info.thing_execution_context.fetchExecutionLogs
+ # test ServerExecutionContext
+ assert isinstance(TestableRPCHandler.latest_request_info.server_execution_context, ServerExecutionContext)
+ if "oneway" in path:
+ assert TestableRPCHandler.latest_request_info.server_execution_context.oneway
+ else:
+ assert not TestableRPCHandler.latest_request_info.server_execution_context.oneway
+ if "invokationTimeout" in path:
+ assert TestableRPCHandler.latest_request_info.server_execution_context.invokationTimeout == 100
+ else:
+ assert (
+ TestableRPCHandler.latest_request_info.server_execution_context.invokationTimeout
+ == default_server_execution_context.invokationTimeout
+ )
+ if "executionTimeout" in path:
+ assert TestableRPCHandler.latest_request_info.server_execution_context.executionTimeout == 120
+ else:
+ assert (
+ TestableRPCHandler.latest_request_info.server_execution_context.executionTimeout
+ == default_server_execution_context.executionTimeout
+ )
+ assert TestableRPCHandler.latest_request_info.payload.deserialize() == body
+
+
+def do_a_path_e2e(session: requests.Session, endpoint: tuple[str, str, Any], **request_kwargs):
+ """
+ basic end-to-end test with the HTTP server using handlers.
+ Auth & other features not included, only invokation of interaction affordances.
+ """
+ method, path, body = endpoint
+ # request will go through the Thing object
+ response = session.request(
+ method=method,
+ url=path,
+ data=JSONSerializer().dumps(body) if body is not None and method != "get" else None,
+ **request_kwargs,
+ )
+ assert response.status_code in [200, 201, 202, 204]
+ # check if the response body is as expected
+ if body and method != "put":
+ assert response.json() == body
+ # check headers
+ assert "Access-Control-Allow-Origin" in response.headers
+ assert "Access-Control-Allow-Credentials" in response.headers
+ assert "Content-Type" in response.headers
+
+ # test unsupported HTTP methods
+ response = session.request(
+ method="post" if method in ["get", "put"] else random.choice(["put", "delete"]) if method == "post" else method,
+ # get and put become post and post becomes put
+ # i.e swap the default HTTP method with an unsupported one to generate 405
+ url=path,
+ data=JSONSerializer().dumps(body) if body is not None and method != "get" else None,
+ **request_kwargs,
+ )
+ assert response.status_code == 405
+
+ # check options for supported HTTP methods
+ response = session.options(path, **request_kwargs)
+ assert response.status_code in [200, 201, 202, 204]
+ assert "Access-Control-Allow-Origin" in response.headers
+ assert "Access-Control-Allow-Credentials" in response.headers
+ assert "Access-Control-Allow-Headers" in response.headers
+ assert "Access-Control-Allow-Methods" in response.headers
+ allow_methods = response.headers.get("Access-Control-Allow-Methods", [])
+ assert ( # noqa
+ method.upper() in allow_methods,
+ f"Method {method} not allowed in {allow_methods}",
+ )
+
+
+def do_a_path_invalid_auth_e2e(session: requests.Session, endpoint: tuple[str, str, Any], headers: dict = None):
+ method, path, body = endpoint
+ response = session.request(
+ method=method,
+ url=path,
+ data=JSONSerializer().dumps(body) if body is not None and method != "get" else None,
+ headers=headers,
+ )
+ assert response.status_code == 401
+
+
+def do_authenticated_path_e2e(
+ session: requests.Session,
+ endpoint: tuple[str, str, Any],
+ auth_headers: dict[str, str] = None,
+ wrong_auth_headers: list[dict[str, str]] = None,
+):
+ """Test end-to-end with authentication"""
+ do_a_path_e2e(session, endpoint, headers=auth_headers)
+ for wrong_auth_header in wrong_auth_headers:
+ do_a_path_invalid_auth_e2e(session, endpoint, headers=wrong_auth_header)
+
+
+def test_06_basic_end_to_end(
+ thing: OceanOpticsSpectrometer,
+ session: requests.Session,
+ port: int,
+ endpoints: list[tuple[str, str, Any]],
+) -> None:
+ """basic end-to-end test with the HTTP server using handlers."""
+ for method, path, body in endpoints:
+ do_a_path_e2e(
+ session=session,
+ endpoint=(method, f"{hostname_prefix}:{port}{path}", body),
+ headers={"Content-Type": "application/json"},
+ )
+
+
+@pytest.mark.parametrize(
+ "security_scheme",
+ [
+ BcryptBasicSecurity(username="someuser", password="somepassword"),
+ Argon2BasicSecurity(username="someuser", password="somepassword"),
+ ],
+)
+def test_07_basic_security_end_to_end(session: requests.Session, port: int, security_scheme: Security):
+ """Test end-to-end with Basic Authentication."""
+ with running_thing(id_prefix="test-sec", port=port, security_schemes=[security_scheme]) as thing:
+ endpoints = running_thing_endpoints(thing)
+ for method, path, body in endpoints:
+ do_authenticated_path_e2e(
+ session=session,
+ endpoint=(f"{method}", f"{hostname_prefix}:{port}{path}", body),
+ auth_headers={
+ "Content-type": "application/json",
+ "Authorization": f"Basic {base64.b64encode(b'someuser:somepassword').decode('utf-8')}",
+ },
+ wrong_auth_headers=[
+ {
+ "Content-type": "application/json",
+ "Authorization": f"Basic {base64.b64encode(b'wronguser:wrongpassword').decode('utf-8')}",
+ },
+ {
+ "Content-type": "application/json",
+ "Authorization": f"Basic {base64.b64encode(b'someuser:wrongpassword').decode('utf-8')}",
+ },
+ {
+ "Content-type": "application/json",
+ "Authorization": f"Basic {base64.b64encode(b'wronguser:somepassword').decode('utf-8')}",
+ },
+ ],
+ )
+
+
+@pytest.mark.parametrize(
+ "security_scheme, headers",
+ [
+ (None, {}),
+ (
+ BcryptBasicSecurity(username="someuser", password="somepassword"),
+ {
+ "Content-type": "application/json",
+ "Authorization": f"Basic {base64.b64encode(b'someuser:somepassword').decode('utf-8')}",
+ },
+ ),
+ ],
+)
+def test_09_sse(
+ session: requests.Session,
+ port: int,
+ security_scheme: Security | None,
+ headers: dict[str, str],
+) -> None:
+ """Test Server-Sent Events (SSE)"""
+ with running_thing(
+ id_prefix="test-sse",
+ port=port,
+ security_schemes=[security_scheme] if security_scheme else None,
+ ) as thing:
+ response = session.post(f"{hostname_prefix}:{port}/{thing.id}/start-acquisition", headers=headers)
+ assert response.status_code == 200
+ sse_gen = sse_stream(
+ f"{hostname_prefix}:{port}/{thing.id}/intensity-measurement-event",
+ headers=headers,
+ )
+ for _ in range(5):
+ evt = next(sse_gen)
+ assert "exception" not in evt and "data" in evt
+ response = session.post(f"{hostname_prefix}:{port}/{thing.id}/stop-acquisition", headers=headers)
+ assert response.status_code == 200
+
+
+def test_10_forms_generation(session: requests.Session, td_endpoint: str) -> None:
+ response = session.get(td_endpoint)
+
+ assert response.status_code == 200
+ td = response.json()
+
+ assert "properties" in td
+ assert "actions" in td
+ assert "events" in td
+ assert len(td["properties"]) >= 0
+ assert len(td["actions"]) >= 0
+ assert len(td["events"]) >= 0
+ for interaction in list(td["properties"].values()) + list(td["actions"].values()) + list(td["events"].values()):
+ assert "forms" in interaction
+ assert len(interaction["forms"]) > 0
+ for form in interaction["forms"]:
+ assert "href" in form
+ assert "htv:methodName" in form
+ assert "contentType" in form
+ assert "op" in form
+
+
+async def test_11_object_proxy_basic(object_proxy: ObjectProxy) -> None:
+ assert isinstance(object_proxy, ObjectProxy)
+ assert object_proxy.test_echo("Hello World!") == "Hello World!"
+ assert await object_proxy.async_invoke_action("test_echo", "Hello World!") == "Hello World!"
+ assert object_proxy.read_property("max_intensity") == 16384
+ assert object_proxy.write_property("integration_time", 1200) is None
+ assert object_proxy.read_property("integration_time") == 1200
+
+
+# def notest_12_object_proxy_with_basic_auth(self):
+# security_scheme = BcryptBasicSecurity(username="cliuser", password="clipass")
+# port = 60013
+# thing_id = f"test-basic-proxy-{uuid.uuid4().hex[0:8]}"
+# thing = OceanOpticsSpectrometer(id=thing_id, serial_number="simulation", log_level=logging.ERROR + 10)
+# thing.run_with_http_server(
+# forked=True,
+# port=port,
+# config={"cors": True},
+# security_schemes=[security_scheme],
+# )
+# self.wait_until_server_ready(port=port)
+
+# object_proxy = ClientFactory.http(
+# url=f"http://127.0.0.1:{port}/{thing_id}/resources/wot-td",
+# username="cliuser",
+# password="clipass",
+# )
+# self.assertEqual(object_proxy.read_property("max_intensity"), 16384)
+# headers = {}
+# token = base64.b64encode("cliuser:clipass".encode("utf-8")).decode("ascii")
+# headers["Authorization"] = f"Basic {token}"
+# self.stop_server(port=port, thing_ids=[thing_id], headers=headers)
diff --git a/tests/test_15_protocols_http_e2e.py b/tests/test_15_protocols_http_e2e.py
new file mode 100644
index 00000000..6061eaf4
--- /dev/null
+++ b/tests/test_15_protocols_http_e2e.py
@@ -0,0 +1,54 @@
+from typing import Any, Generator
+
+import pytest
+
+from hololinked.client import ClientFactory, ObjectProxy
+from hololinked.server import stop
+from hololinked.utils import uuid_hex
+
+try:
+ from .test_11_rpc_e2e import TestRPC_E2E as BaseRPC_E2E # noqa: F401
+ from .test_11_rpc_e2e import client, thing, thing_model # noqa: F401
+ from .test_14_protocols_http import hostname_prefix, wait_until_server_ready
+ from .things import TestThing
+except ImportError:
+ from test_11_rpc_e2e import TestRPC_E2E as BaseRPC_E2E # noqa: F401
+ from test_11_rpc_e2e import client, thing, thing_model # noqa: F401
+ from test_14_protocols_http import hostname_prefix, wait_until_server_ready
+ from things import TestThing
+
+
+@pytest.fixture(scope="class")
+def port() -> int:
+ return 63000
+
+
+@pytest.fixture(scope="class")
+def thing(port: int) -> Generator[TestThing, None, None]:
+ thing = TestThing(id=f"test-thing-{uuid_hex()}", serial_number="simulation")
+ print() # TODO, can be removed when tornado logs respect level
+ thing.run_with_http_server(port=port, forked=True, config=dict(cors=True))
+ wait_until_server_ready(port=port)
+ yield thing
+ stop()
+
+
+@pytest.fixture(scope="class")
+def thing_model(thing: TestThing) -> dict[str, Any]:
+ return thing.get_thing_model(ignore_errors=True).json()
+
+
+@pytest.fixture(scope="class")
+def td_endpoint(thing: TestThing, port: int) -> str:
+ return f"{hostname_prefix}:{port}/{thing.id}/resources/wot-td"
+
+
+@pytest.fixture(scope="class")
+def client(td_endpoint: str) -> "ObjectProxy":
+ return ClientFactory.http(url=td_endpoint, ignore_TD_errors=True)
+
+
+@pytest.mark.asyncio(loop_scope="class")
+class TestHTTP_E2E(BaseRPC_E2E):
+ def test_14_rw_multiple_properties(self, client: ObjectProxy):
+ pass
diff --git a/tests/things/__init__.py b/tests/things/__init__.py
index fa61194b..13a4ec37 100644
--- a/tests/things/__init__.py
+++ b/tests/things/__init__.py
@@ -1,4 +1,2 @@
-from .test_thing import TestThing, test_thing_TD
-from .spectrometer import OceanOpticsSpectrometer
-from .starter import run_thing_with_zmq_server_forked
-
+from .spectrometer import OceanOpticsSpectrometer # noqa: F401
+from .test_thing import TestThing, test_thing_TD # noqa: F401
diff --git a/tests/things/starter.py b/tests/things/starter.py
deleted file mode 100644
index 395b3235..00000000
--- a/tests/things/starter.py
+++ /dev/null
@@ -1,131 +0,0 @@
-import asyncio
-import typing
-import multiprocessing
-import threading
-import logging
-import queue
-
-from hololinked.exceptions import BreakLoop
-from hololinked.core.zmq.brokers import AsyncZMQServer
-from hololinked.core.zmq.message import EXIT
-from hololinked.core import ThingMeta, Thing
-from hololinked.utils import get_current_async_loop
-from hololinked.logger import setup_logging
-
-
-def run_thing_with_zmq_server(
- thing_cls: ThingMeta,
- id: str,
- access_points: typing.List[str] = ["IPC"],
- done_queue: typing.Optional[multiprocessing.Queue] = None,
- log_level: int = logging.WARN,
- prerun_callback: typing.Optional[typing.Callable] = None,
-) -> None:
- setup_logging(log_level=log_level)
- if prerun_callback:
- prerun_callback(thing_cls)
- thing = thing_cls(id=id, log_level=log_level) # type: Thing
- thing.run_with_zmq_server(access_points=access_points)
- if done_queue is not None:
- done_queue.put(id)
-
-
-def run_thing_with_http_server(
- thing_cls: ThingMeta,
- id: str,
- done_queue: queue.Queue = None,
- log_level: int = logging.WARN,
- prerun_callback: typing.Optional[typing.Callable] = None,
-) -> None:
- if prerun_callback:
- prerun_callback(thing_cls)
- thing = thing_cls(id=id, log_level=log_level) # type: Thing
- thing.run_with_http_server()
- if done_queue is not None:
- done_queue.put(id)
-
-
-def run_thing_with_zmq_server_forked(
- thing_cls: ThingMeta,
- id: str,
- access_points: typing.List[str] = ["IPC"],
- done_queue: typing.Optional[multiprocessing.Queue] = None,
- log_level: int = logging.WARN,
- prerun_callback: typing.Optional[typing.Callable] = None,
- as_process: bool = True,
-) -> typing.Union[multiprocessing.Process, threading.Thread]:
- """
- run a Thing in a ZMQ server by forking from main process or thread.
-
- Parameters:
- -----------
- thing_cls: ThingMeta
- The class of the Thing to be run.
- id: str
- The id of the Thing to be run.
- log_level: int
- The log level to be used for the Thing. Default is logging.WARN.
- protocols: list of str
- The ZMQ protocols to be used for the Thing. Default is ['IPC'].
- tcp_socket_address: str
- The TCP socket address to be used for the Thing. Default is None.
- prerun_callback: callable
- A callback function to be called before running the Thing. Default is None.
- as_process: bool
- Whether to run the Thing in a separate process or thread. Default is True (as process).
- done_queue: multiprocessing.Queue
- A queue to be used for communication between processes. Default is None.
- """
-
- if as_process:
- P = multiprocessing.Process(
- target=run_thing_with_zmq_server,
- kwargs=dict(
- thing_cls=thing_cls,
- id=id,
- access_points=access_points,
- done_queue=done_queue,
- log_level=log_level,
- prerun_callback=prerun_callback,
- ),
- daemon=True,
- )
- P.start()
- return P
- else:
- T = threading.Thread(
- target=run_thing_with_zmq_server,
- kwargs=dict(
- thing_cls=thing_cls,
- id=id,
- access_points=access_points,
- done_queue=done_queue,
- log_level=log_level,
- prerun_callback=prerun_callback,
- ),
- daemon=True,
- )
- T.start()
- return T
-
-
-def run_zmq_server(server: AsyncZMQServer, owner, done_queue: multiprocessing.Queue) -> None:
- event_loop = get_current_async_loop()
-
- async def run():
- while True:
- try:
- messages = await server.async_recv_requests()
- owner.last_server_message = messages[0]
- for message in messages:
- if message.type == EXIT:
- server.exit()
- return
- await asyncio.sleep(0.01)
- except BreakLoop:
- break
-
- event_loop.run_until_complete(run())
- event_loop.run_until_complete(asyncio.gather(*asyncio.all_tasks(event_loop)))
- if done_queue:
- done_queue.put(True)
diff --git a/tests/things/test_thing.py b/tests/things/test_thing.py
index 1de58114..f642949b 100644
--- a/tests/things/test_thing.py
+++ b/tests/things/test_thing.py
@@ -2,19 +2,21 @@
import threading
import time
import typing
+
import numpy as np
+
from pydantic import BaseModel, Field, WithJsonSchema
-from hololinked.core import Thing, action, Property, Event
+from hololinked.core import Event, Property, Thing, action
+from hololinked.core.actions import Action, BoundAction
from hololinked.core.properties import (
+ ClassSelector,
+ Integer,
+ List,
Number,
- String,
Selector,
- List,
- Integer,
- ClassSelector,
+ String,
)
-from hololinked.core.actions import Action, BoundAction
from hololinked.param import ParameterizedFunction
from hololinked.schema_validators import JSONSchema
diff --git a/tests/utils.py b/tests/utils.py
index 77a602e4..d52a2917 100644
--- a/tests/utils.py
+++ b/tests/utils.py
@@ -1,74 +1,7 @@
-import asyncio
-import zmq.asyncio
import threading
import typing
-import unittest
-from faker import Faker
-
-from hololinked.config import global_config
-
-
-class TestResult(unittest.TextTestResult):
- """Custom test result class to format the output of test results."""
-
- def addSuccess(self, test):
- super().addSuccess(test)
- self.stream.write(f" {test} ✔")
- self.stream.flush()
-
- def addFailure(self, test, err):
- super().addFailure(test, err)
- self.stream.write(f" {test} ❌")
- self.stream.flush()
-
- def addError(self, test, err):
- super().addError(test, err)
- self.stream.write(f" {test} ❌ Error")
- self.stream.flush()
-
-
-class TestRunner(unittest.TextTestRunner):
- """Custom test runner class to use the custom test result class."""
-
- resultclass = TestResult
-
-class TestCase(unittest.TestCase):
- """Custom test case class to print some extra spaces and info about test carried out"""
-
- @classmethod
- def setUpClass(cls):
- print("----------------------------------------------------------------------")
- global_config.ZMQ_CONTEXT = zmq.asyncio.Context()
- # always replace otherwise one context opens too many sockets
-
- @classmethod
- def tearDownClass(cls):
- print(f"\n\ntear down {cls.__name__}")
-
- def setUp(self):
- print() # add gaps between results printed by unit test
-
-
-class AsyncTestCase(unittest.IsolatedAsyncioTestCase):
- """Custom async test case class to print some extra spaces and info about test carried out"""
-
- @classmethod
- def setUpClass(cls):
- global_config.ZMQ_CONTEXT = zmq.asyncio.Context()
- # always replace otherwise one context opens too many sockets
- print("----------------------------------------------------------------------")
-
- @classmethod
- def tearDownClass(cls):
- print(f"\n\ntear down {cls.__name__}")
-
- async def asyncSetUp(self):
- loop = asyncio.get_running_loop()
- loop.set_debug(False)
-
- def setUp(self):
- print() # add gaps between results printed by unit test
+from faker import Faker
def print_lingering_threads(exclude_daemon: bool = True):
@@ -81,9 +14,7 @@ def print_lingering_threads(exclude_daemon: bool = True):
alive_threads = [t for t in alive_threads if not t.daemon]
for thread in alive_threads:
- print(
- f"Thread Name: {thread.name}, Thread ID: {thread.ident}, Is Alive: {thread.is_alive()}"
- )
+ print(f"Thread Name: {thread.name}, Thread ID: {thread.ident}, Is Alive: {thread.is_alive()}")
class TrackingFaker:
diff --git a/tests/not working - yet to be integrated/test_14_rpc.py b/tests/yet-to-be-integrated/not-working/test_14_rpc.py
similarity index 64%
rename from tests/not working - yet to be integrated/test_14_rpc.py
rename to tests/yet-to-be-integrated/not-working/test_14_rpc.py
index 5bec84ee..bb30686e 100644
--- a/tests/not working - yet to be integrated/test_14_rpc.py
+++ b/tests/yet-to-be-integrated/not-working/test_14_rpc.py
@@ -1,41 +1,46 @@
-import threading, random, asyncio, requests
-import logging, multiprocessing, unittest
+import asyncio
+import logging
+import multiprocessing
+import random
+import threading
+import unittest
+
+import requests
+
from hololinked.client import ObjectProxy
+
try:
+ from .things import TestThing
from .utils import TestCase, TestRunner
- from .things import TestThing, start_thing_forked
except ImportError:
+ from things import TestThing
from utils import TestCase, TestRunner
- from things import TestThing, start_thing_forked
-
class TestRPC(TestCase):
-
@classmethod
def setUpClass(self):
print("test RPC")
self.thing_cls = TestThing
start_thing_forked(
- thing_cls=self.thing_cls,
- instance_name='test-rpc',
- log_level=logging.WARN,
- protocols=['IPC', 'TCP'],
- tcp_socket_address='tcp://*:58000',
- http_server=True
- )
- self.thing_client = ObjectProxy('test-rpc') # type: TestThing
-
+ thing_cls=self.thing_cls,
+ instance_name="test-rpc",
+ log_level=logging.WARN,
+ protocols=["IPC", "TCP"],
+ tcp_socket_address="tcp://*:58000",
+ http_server=True,
+ )
+ self.thing_client = ObjectProxy("test-rpc") # type: TestThing
+
@classmethod
def tearDownClass(self):
print("tear down test RPC")
self.thing_client.exit()
-
def test_1_normal_client(self):
- # First test a simple single-threaded client and make sure it succeeds
- # all requests
+ # First test a simple single-threaded client and make sure it succeeds
+ # all requests
done_queue = multiprocessing.Queue()
start_client(done_queue)
self.assertEqual(done_queue.get(), True)
@@ -43,34 +48,33 @@ def test_1_normal_client(self):
def test_2_threaded_client(self):
# Then test a multi-threaded client and make sure it succeeds all requests
done_queue = multiprocessing.Queue()
- start_client(done_queue, 'threading')
+ start_client(done_queue, "threading")
self.assertEqual(done_queue.get(), True)
def test_3_async_client(self):
# Then an async client
done_queue = multiprocessing.Queue()
- start_client(done_queue, 'async')
+ start_client(done_queue, "async")
self.assertEqual(done_queue.get(), True)
def test_4_async_multiple_client(self):
# Then an async client with multiple coroutines/futures
done_queue = multiprocessing.Queue()
- start_client(done_queue, 'async_multiple')
+ start_client(done_queue, "async_multiple")
self.assertEqual(done_queue.get(), True)
def test_5_http_client(self):
# Then a HTTP client which uses a message mapped ZMQ client pool on the HTTP server
done_queue = multiprocessing.Queue()
- start_client(done_queue, 'http')
+ start_client(done_queue, "http")
self.assertEqual(done_queue.get(), True)
def test_6_tcp_client(self):
# Also, for sake, a TCP client
done_queue = multiprocessing.Queue()
- start_client(done_queue, tcp_socket_address='tcp://localhost:58000')
+ start_client(done_queue, tcp_socket_address="tcp://localhost:58000")
self.assertEqual(done_queue.get(), True)
-
def test_7_multiple_clients(self):
# Then parallely run all of them at once and make sure they all succeed
# which means the server can request accept from anywhere at any time and not fail
@@ -79,24 +83,24 @@ def test_7_multiple_clients(self):
done_queue_2 = multiprocessing.Queue()
start_client(done_queue_2)
-
+
done_queue_3 = multiprocessing.Queue()
- start_client(done_queue_3, 'threading')
+ start_client(done_queue_3, "threading")
done_queue_4 = multiprocessing.Queue()
- start_client(done_queue_4, 'async')
+ start_client(done_queue_4, "async")
done_queue_5 = multiprocessing.Queue()
- start_client(done_queue_5, 'async_multiple')
+ start_client(done_queue_5, "async_multiple")
done_queue_6 = multiprocessing.Queue()
- start_client(done_queue_6, 'http')
+ start_client(done_queue_6, "http")
done_queue_7 = multiprocessing.Queue()
- start_client(done_queue_7, typ='threading', tcp_socket_address='tcp://localhost:58000')
+ start_client(done_queue_7, typ="threading", tcp_socket_address="tcp://localhost:58000")
done_queue_8 = multiprocessing.Queue()
- start_client(done_queue_8, tcp_socket_address='tcp://localhost:58000')
+ start_client(done_queue_8, tcp_socket_address="tcp://localhost:58000")
self.assertEqual(done_queue_1.get(), True)
self.assertEqual(done_queue_2.get(), True)
@@ -108,41 +112,39 @@ def test_7_multiple_clients(self):
self.assertEqual(done_queue_8.get(), True)
-
-def start_client(done_queue : multiprocessing.Queue, typ : str = 'normal', tcp_socket_address : str = None):
- if typ == 'normal':
+def start_client(done_queue: multiprocessing.Queue, typ: str = "normal", tcp_socket_address: str = None):
+ if typ == "normal":
return multiprocessing.Process(target=normal_client, args=(done_queue, tcp_socket_address)).start()
- elif typ == 'threading':
+ elif typ == "threading":
return multiprocessing.Process(target=threading_client, args=(done_queue, tcp_socket_address)).start()
- elif typ == 'async':
+ elif typ == "async":
return multiprocessing.Process(target=async_client, args=(done_queue,)).start()
- elif typ == 'async_multiple':
+ elif typ == "async_multiple":
return multiprocessing.Process(target=async_client_multiple, args=(done_queue,)).start()
- elif typ == 'http':
+ elif typ == "http":
return multiprocessing.Process(target=http_client, args=(done_queue,)).start()
raise NotImplementedError(f"client type {typ} not implemented or unknown.")
def gen_random_data():
choice = random.randint(0, 1)
- if choice == 0: # float
- return random.random()*1000
+ if choice == 0: # float
+ return random.random() * 1000
elif choice == 1:
- return random.choice(['a', True, False, 10, 55e-3, [i for i in range(100)], {'a': 1, 'b': 2},
- None])
+ return random.choice(["a", True, False, 10, 55e-3, [i for i in range(100)], {"a": 1, "b": 2}, None])
-def normal_client(done_queue : multiprocessing.Queue = None, tcp_socket_address : str = None):
+def normal_client(done_queue: multiprocessing.Queue = None, tcp_socket_address: str = None):
success = True
if tcp_socket_address:
- client = ObjectProxy('test-rpc', socket_address=tcp_socket_address, protocol='TCP') # type: TestThing
+ client = ObjectProxy("test-rpc", socket_address=tcp_socket_address, protocol="TCP") # type: TestThing
else:
- client = ObjectProxy('test-rpc') # type: TestThing
+ client = ObjectProxy("test-rpc") # type: TestThing
for i in range(2000):
value = gen_random_data()
ret = client.test_echo(value)
# print("single-thread", 1, i, value, ret)
- if value != ret:
+ if value != ret:
print("error", "single-thread", 1, i, value, ret)
success = False
break
@@ -150,15 +152,15 @@ def normal_client(done_queue : multiprocessing.Queue = None, tcp_socket_address
if done_queue is not None:
done_queue.put(success)
-
-def threading_client(done_queue : multiprocessing.Queue = None, tcp_socket_address : str = None):
+
+def threading_client(done_queue: multiprocessing.Queue = None, tcp_socket_address: str = None):
success = True
if tcp_socket_address:
- client = ObjectProxy('test-rpc', socket_address=tcp_socket_address, protocol='TCP') # type: TestThing
+ client = ObjectProxy("test-rpc", socket_address=tcp_socket_address, protocol="TCP") # type: TestThing
else:
- client = ObjectProxy('test-rpc') # type: TestThing
+ client = ObjectProxy("test-rpc") # type: TestThing
- def message_thread(id : int):
+ def message_thread(id: int):
nonlocal success, client
for i in range(1000):
value = gen_random_data()
@@ -183,15 +185,15 @@ def message_thread(id : int):
done_queue.put(success)
-def async_client(done_queue : multiprocessing.Queue = None):
+def async_client(done_queue: multiprocessing.Queue = None):
success = True
- client = ObjectProxy('test-rpc', async_mixin=True) # type: TestThing
+ client = ObjectProxy("test-rpc", async_mixin=True) # type: TestThing
async def message_coro():
nonlocal success, client
for i in range(2000):
value = gen_random_data()
- ret = await client.async_invoke_action('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)
@@ -203,46 +205,45 @@ async def message_coro():
done_queue.put(success)
-def async_client_multiple(done_queue : multiprocessing.Queue = None):
+def async_client_multiple(done_queue: multiprocessing.Queue = None):
success = True
- client = ObjectProxy('test-rpc', async_mixin=True) # type: TestThing
+ client = ObjectProxy("test-rpc", async_mixin=True) # type: TestThing
async def message_coro(id):
nonlocal success, client
for i in range(1000):
value = gen_random_data()
- ret = await client.async_invoke_action('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)
success = False
break
- asyncio.get_event_loop().run_until_complete(
- asyncio.gather(*[message_coro(1), message_coro(2), message_coro(3)]))
+ asyncio.get_event_loop().run_until_complete(asyncio.gather(*[message_coro(1), message_coro(2), message_coro(3)]))
if done_queue is not None:
done_queue.put(success)
-def http_client(done_queue : multiprocessing.Queue = None):
+def http_client(done_queue: multiprocessing.Queue = None):
success = True
session = requests.Session()
- def worker(id : int):
+ def worker(id: int):
nonlocal success
for i in range(1000):
value = gen_random_data()
- ret = session.post(
- 'http://localhost:8080/test-rpc/test-echo',
- json={'value': value},
- headers={'Content-Type': 'application/json'}
- )
+ ret = session.post(
+ "http://localhost:8080/test-rpc/test-echo",
+ json={"value": value},
+ headers={"Content-Type": "application/json"},
+ )
# print("http", id, i, value, ret)
if value != ret.json():
print("http", id, i, value, ret)
success = False
break
-
+
T1 = threading.Thread(target=worker, args=(1,))
T2 = threading.Thread(target=worker, args=(2,))
T1.start()
@@ -254,8 +255,5 @@ def worker(id : int):
done_queue.put(success)
-
-
-
-if __name__ == '__main__':
+if __name__ == "__main__":
unittest.main(testRunner=TestRunner())
diff --git a/tests/working/test_07_properties_mongodb.py b/tests/yet-to-be-integrated/working/test_07_properties_mongodb.py
similarity index 100%
rename from tests/working/test_07_properties_mongodb.py
rename to tests/yet-to-be-integrated/working/test_07_properties_mongodb.py
diff --git a/uv.lock b/uv.lock
index 92ea4677..977ce71a 100644
--- a/uv.lock
+++ b/uv.lock
@@ -666,6 +666,7 @@ test = [
{ name = "fastjsonschema" },
{ name = "numpy" },
{ name = "pytest" },
+ { name = "pytest-asyncio" },
{ name = "pytest-cov" },
{ name = "pytest-order" },
{ name = "requests" },
@@ -712,6 +713,7 @@ test = [
{ name = "fastjsonschema", specifier = "==2.20.0" },
{ name = "numpy", specifier = ">=2.0.0" },
{ name = "pytest", specifier = ">=8.0.0" },
+ { name = "pytest-asyncio", specifier = ">=1.3.0" },
{ name = "pytest-cov", specifier = ">=4.0.0" },
{ name = "pytest-order", specifier = ">=1.0.0" },
{ name = "requests", specifier = "==2.32.3" },
@@ -1888,6 +1890,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750 },
]
+[[package]]
+name = "pytest-asyncio"
+version = "1.3.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "pytest" },
+ { name = "typing-extensions", marker = "python_full_version < '3.13'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075 },
+]
+
[[package]]
name = "pytest-cov"
version = "6.3.0"