From d18fa1f0de6d440f6ef04e24a683d94146b4f383 Mon Sep 17 00:00:00 2001
From: Vignesh Venkatasubramanian Vaidyanathan
<62492557+VigneshVSV@users.noreply.github.com>
Date: Wed, 25 Mar 2026 19:18:33 +0100
Subject: [PATCH 01/15] add dependencies and update some docs
---
hololinked/client/security.py | 233 +++++++++++++++++++++++-----------
pyproject.toml | 2 +
uv.lock | 160 ++++++++++++++++++++++-
3 files changed, 321 insertions(+), 74 deletions(-)
diff --git a/hololinked/client/security.py b/hololinked/client/security.py
index a23121d2..2aebf85e 100644
--- a/hololinked/client/security.py
+++ b/hololinked/client/security.py
@@ -1,6 +1,7 @@
import base64
import threading
import time
+import warnings
import httpx
@@ -9,15 +10,31 @@
class BasicSecurity(BaseModel):
"""
- Basic Security Scheme with username and password.
- The credentials are added into the `Authorization` header.
+ Basic Security Scheme with username and password. The credentials are added into the `Authorization` header.
+ Normally, you can instantiate this indirectly through the `ClientFactory` by passing `username` and `password`
+ parameters, if the protocol supports it.
+
+ ```python
+ client = ClientFactory.http(
+ url="http://localhost:9000/my-thing/resources/wot-td",
+ security=BasicSecurity(
+ username=os.getenv("USERNAME", "admin"),
+ password=os.getenv("PASSWORD", "adminpass"),
+ base64_encoding=True
+ )
+ )
+ ```
"""
http_header_name: str = "Authorization"
+ """
+ Name of the HTTP header to use for authentication, default is `Authorization`.
+ Override this if the server expects the credentials in a different header.
+ """
_credentials: str = PrivateAttr()
- def __init__(self, username: str, password: str, use_base64: bool = True):
+ def __init__(self, username: str, password: str, use_base64: bool = True) -> None:
"""
Parameters
----------
@@ -36,18 +53,30 @@ def __init__(self, username: str, password: str, use_base64: bool = True):
@property
def http_header(self) -> str:
- """Value for the Authorization header"""
+ """Value for the Authorization header - contains the credentials"""
return self._credentials
class APIKeySecurity(BaseModel):
"""
- API Key Security Scheme.
- The API key is added into a header named `X-API-Key`.
+ API Key Security Scheme. The API key is added into a header named `X-API-Key`.
+
+ ```python
+ client = ClientFactory.http(
+ url="http://localhost:9000/my-thing/resources/wot-td",
+ security_scheme=APIKeySecurity(value=os.getenv("APIKEY", "default-api-key"))
+ )
+ ```
"""
value: str
+ """The API key value to use for authentication."""
+
http_header_name: str = "X-API-Key"
+ """
+ Name of the HTTP header to use for authentication, default is `X-API-Key`.
+ Override this if the server expects the API key in a different header.
+ """
@property
def http_header(self) -> str:
@@ -65,8 +94,10 @@ class ROPC(BaseModel):
class OAuthDirectAccessGrant(BaseModel):
"""
- OAuth2 Direct Access Grant Security Scheme.
- Implements Resource Owner Password Credentials (ROPC) flow.
+ OAuth2 Direct Access Grant Security Scheme. Implements Resource Owner Password Credentials (ROPC) flow - in simple
+ terms, plain username and password authentication without the general features of OAuth2. Please implement other
+ flows on your own for applications with a web interface. There is no intention to provide a complete OAuth2 client
+ implementation in this library.
"""
token_endpoint: str
@@ -83,12 +114,41 @@ def __init__(
self,
username: str,
password: str,
- oidc_config_url: str = None,
+ oidc_config_url: str | None = None,
+ token_endpoint: str | None = None,
scope: str | list[str] = "openid",
verify_ssl: bool = True,
**kwargs,
):
- token_endpoint = kwargs.get("token_endpoint", None)
+ """
+ Initialize OAuthDirectAccessGrant security scheme.
+
+ Parameters
+ ----------
+ username: str
+ The username for authentication.
+ password: str
+ The password for authentication.
+ oidc_config_url: str | None
+ The URL to fetch OIDC configuration, which should contain the token endpoint and optionally the
+ revocation endpoint. If provided, `token_endpoint` (next argument) will be ignored.
+ token_endpoint: str | None
+ The token endpoint URL for obtaining tokens. Required if `oidc_config_url` is not provided.
+ scope: str | list[str]
+ The scope to request when obtaining tokens, by default "openid".
+ verify_ssl: bool
+ Whether to verify SSL certificates when fetching OIDC configuration, by default True.
+ Set to False if you are using self-signed certificates in development or testing environments or using
+ a local provider.
+ kwargs:
+ client_id: str
+ The client ID for authentication, required for most OIDC providers.
+ client_secret: str
+ The client secret for authentication, required for some OIDC providers.
+ revocation_endpoint: str
+ The token revocation endpoint URL, required if you want to support logout functionality.
+ If not provided, logout functionality will not be available.
+ """
client_id = kwargs.get("client_id", None)
client_secret = kwargs.get("client_secret", None)
revocation_endpoint = kwargs.get("revocation_endpoint", None)
@@ -114,7 +174,7 @@ def __init__(
class OAuth2Security:
"""
- OAuth2 Security Scheme, Currently only supports Resource Owner Password Credentials (ROPC) flow.
+ OAuth2 Security Scheme, supporting only direct access grant or Resource Owner Password Credentials (ROPC) flow.
Please implement other flows on your own for applications with a web interface.
"""
@@ -123,16 +183,31 @@ class OAuth2Security:
def __init__(
self,
oidc_settings: OAuthDirectAccessGrant,
- req_rep_sync_client: httpx.Client,
- req_rep_async_client: httpx.AsyncClient,
- refresh_interval_fraction: float = 0.75,
+ refresh_interval_fraction: int | float = 0.75,
+ **kwargs,
) -> None:
- self._oidc_settings = oidc_settings
- self._req_rep_async_client = req_rep_async_client
- self._req_rep_sync_client = req_rep_sync_client
+ """
+ Initialize OIDC security scheme.
+
+ Parameters
+ ----------
+ oidc_settings: OAuthDirectAccessGrant
+ The settings for OIDC authentication, including token endpoint, client id, username and password.
+ refresh_interval_fraction: int | float
+ The fraction of token expiration time to wait before refreshing tokens, by default 0.75,
+ which means refreshing tokens when 75% of the token expiration time has passed.
+ kwargs:
+ sync_http_client: httpx.Client
+ The http client to use for synchronous requests, by default a new httpx.Client with 10s timeout.
+ async_http_client: httpx.AsyncClient
+ The http client to use for asynchronous requests, by default a new httpx.AsyncClient with 10s timeout.
+ Unused currently, optional.
+ """
+ self.oidc_settings = oidc_settings
self.tokens = None
+ self._sync_http_client = kwargs.get("sync_http_client", httpx.Client(timeout=10.0)) # type: httpx.Client
+ self._async_http_client = kwargs.get("async_http_client", httpx.AsyncClient(timeout=10.0)) # type: httpx.AsyncClient
self._refresh_thread = None
- self._refresh_lock = threading.Lock()
self._refresh = True
self._refresh_interval_fraction = refresh_interval_fraction
@@ -140,59 +215,51 @@ def __init__(
def http_header(self) -> str:
if not self.tokens:
return ""
- try:
- self._refresh_lock.acquire()
- return f"Bearer {self.tokens.access_token}"
- finally:
- self._refresh_lock.release()
+ return f"Bearer {self.tokens.access_token}"
def login(self) -> None:
"""login with username and password and obtain tokens"""
- try:
- self._refresh_lock.acquire()
- body = dict(
- grant_type=self._oidc_settings.grant_type,
- client_id=self._oidc_settings.client_id,
- scope=self._oidc_settings.scope,
- username=self._oidc_settings.username,
- password=self._oidc_settings.password,
- )
- if self._oidc_settings.client_secret:
- body["client_secret"] = self._oidc_settings.client_secret
- response = self._req_rep_sync_client.post(
- self._oidc_settings.token_endpoint,
- data=body,
- headers={"Content-Type": "application/x-www-form-urlencoded"},
- )
- response.raise_for_status()
- self.tokens = ROPC(
- access_token=response.json().get("access_token"),
- refresh_token=response.json().get("refresh_token"),
- expires_in=response.json().get("expires_in"),
- id_token=response.json().get("id_token"),
- scope=response.json().get("scope"),
- token_type=response.json().get("token_type"),
- )
- if self._refresh_thread and self._refresh_thread.is_alive():
- return
- if not self.tokens.refresh_token or not self.tokens.expires_in:
- return
- self._refresh_thread = threading.Thread(target=self._refresh_tokens_in_background, daemon=True)
- self._refresh_thread.start()
- finally:
- self._refresh_lock.release()
+ body = dict(
+ grant_type=self.oidc_settings.grant_type,
+ client_id=self.oidc_settings.client_id,
+ scope=self.oidc_settings.scope,
+ username=self.oidc_settings.username,
+ password=self.oidc_settings.password,
+ )
+ if self.oidc_settings.client_secret:
+ body["client_secret"] = self.oidc_settings.client_secret
+ response = self._sync_http_client.post(
+ self.oidc_settings.token_endpoint,
+ data=body,
+ headers={"Content-Type": "application/x-www-form-urlencoded"},
+ )
+ response.raise_for_status()
+ self.tokens = ROPC(
+ access_token=response.json().get("access_token"),
+ refresh_token=response.json().get("refresh_token"),
+ expires_in=response.json().get("expires_in"),
+ id_token=response.json().get("id_token"),
+ scope=response.json().get("scope"),
+ token_type=response.json().get("token_type"),
+ )
+ if self._refresh_thread and self._refresh_thread.is_alive():
+ return
+ self._refresh_thread = threading.Thread(target=self._refresh_tokens_in_background, daemon=True)
+ self._refresh_thread.start()
def logout(self) -> None:
"""logout and invalidate tokens"""
+ if not self.tokens or not self.oidc_settings.revocation_endpoint:
+ return
body = dict(
- client_id=self._oidc_settings.client_id,
+ client_id=self.oidc_settings.client_id,
token=self.tokens.refresh_token if self.tokens.refresh_token else self.tokens.access_token,
token_type_hint="refresh_token" if self.tokens.refresh_token else "access_token",
)
- if self._oidc_settings.client_secret:
- body["client_secret"] = self._oidc_settings.client_secret
- response = self._req_rep_sync_client.post(
- self._oidc_settings.revocation_endpoint,
+ if self.oidc_settings.client_secret:
+ body["client_secret"] = self.oidc_settings.client_secret
+ response = self._sync_http_client.post(
+ self.oidc_settings.revocation_endpoint,
data=body,
headers={"Content-Type": "application/x-www-form-urlencoded"},
)
@@ -202,31 +269,51 @@ def logout(self) -> None:
def refresh_tokens(self) -> None:
"""refresh tokens, even forcibly by relogin if necessary"""
+ if not self.tokens:
+ return
+ if not self.tokens.refresh_token:
+ warnings.warn(
+ "OIDC refresh token not available, cannot refresh tokens."
+ + "You need to login again to obtain new tokens.",
+ UserWarning,
+ )
+ return
try:
- self._refresh_lock.acquire()
body = dict(
grant_type="refresh_token",
- client_id=self._oidc_settings.client_id,
+ client_id=self.oidc_settings.client_id,
refresh_token=self.tokens.refresh_token,
)
- if self._oidc_settings.client_secret:
- body["client_secret"] = self._oidc_settings.client_secret
- response = self._req_rep_sync_client.post(
- self._oidc_settings.token_endpoint,
+ if self.oidc_settings.client_secret:
+ body["client_secret"] = self.oidc_settings.client_secret
+ response = self._sync_http_client.post(
+ self.oidc_settings.token_endpoint,
data=body,
headers={"Content-Type": "application/x-www-form-urlencoded"},
)
response.raise_for_status()
- self.tokens = ROPC(**response.json())
+ self.tokens = ROPC(
+ access_token=response.json().get("access_token"),
+ refresh_token=response.json().get("refresh_token"),
+ expires_in=response.json().get("expires_in", self.tokens.expires_in),
+ id_token=response.json().get("id_token", self.tokens.id_token),
+ scope=response.json().get("scope", self.tokens.scope),
+ token_type=response.json().get("token_type", self.tokens.token_type),
+ )
except httpx.HTTPStatusError:
- self._refresh_lock.release()
self.login()
- finally:
- self._refresh_lock.release()
def _refresh_tokens_in_background(self) -> None:
"""background thread to refresh tokens periodically"""
- time.sleep(int(0.75 * self.tokens.expires_in))
+ if not self.tokens:
+ return
+ if not self.tokens.expires_in:
+ warnings.warn(
+ "OIDC token expiration time is not set. Automatic token refresh will not work."
+ + "You need to manually login again once access token is expired.",
+ UserWarning,
+ )
+ return
while self._refresh:
- self.refresh_tokens()
time.sleep(int(self._refresh_interval_fraction * self.tokens.expires_in))
+ self.refresh_tokens()
diff --git a/pyproject.toml b/pyproject.toml
index 6edac2f0..070e8489 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -72,6 +72,8 @@ dev = [
"pip>=25.2",
"ruff>=0.12.10",
"pre-commit>=4.5.0",
+ "mypy>=1.19.1",
+ "ty>=0.0.24",
]
test = [
"requests==2.32.3",
diff --git a/uv.lock b/uv.lock
index 29f35c72..24dc5dc3 100644
--- a/uv.lock
+++ b/uv.lock
@@ -728,7 +728,7 @@ wheels = [
[[package]]
name = "hololinked"
-version = "0.3.11"
+version = "0.3.12"
source = { editable = "." }
dependencies = [
{ name = "aiomqtt" },
@@ -757,11 +757,13 @@ dependencies = [
dev = [
{ name = "ipython" },
{ name = "jupyter" },
+ { name = "mypy" },
{ name = "numpy" },
{ name = "pandas" },
{ name = "pip" },
{ name = "pre-commit" },
{ name = "ruff" },
+ { name = "ty" },
]
scanning = [
{ name = "bandit" },
@@ -805,11 +807,13 @@ requires-dist = [
dev = [
{ name = "ipython", specifier = "==8.12.3" },
{ name = "jupyter", specifier = ">=1.1.1" },
+ { name = "mypy", specifier = ">=1.19.1" },
{ name = "numpy", specifier = ">=2.0.0" },
{ name = "pandas", specifier = "==2.2.3" },
{ name = "pip", specifier = ">=25.2" },
{ name = "pre-commit", specifier = ">=4.5.0" },
{ name = "ruff", specifier = ">=0.12.10" },
+ { name = "ty", specifier = ">=0.0.24" },
]
scanning = [{ name = "bandit", specifier = ">=1.9.1" }]
test = [
@@ -1252,6 +1256,79 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/82/3d/14ce75ef66813643812f3093ab17e46d3a206942ce7376d31ec2d36229e7/lark-1.3.1-py3-none-any.whl", hash = "sha256:c629b661023a014c37da873b4ff58a817398d12635d3bbb2c5a03be7fe5d1e12", size = 113151 },
]
+[[package]]
+name = "librt"
+version = "0.8.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/56/9c/b4b0c54d84da4a94b37bd44151e46d5e583c9534c7e02250b961b1b6d8a8/librt-0.8.1.tar.gz", hash = "sha256:be46a14693955b3bd96014ccbdb8339ee8c9346fbe11c1b78901b55125f14c73", size = 177471 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/1d/01/0e748af5e4fee180cf7cd12bd12b0513ad23b045dccb2a83191bde82d168/librt-0.8.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:681dc2451d6d846794a828c16c22dc452d924e9f700a485b7ecb887a30aad1fd", size = 65315 },
+ { url = "https://files.pythonhosted.org/packages/9d/4d/7184806efda571887c798d573ca4134c80ac8642dcdd32f12c31b939c595/librt-0.8.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a3b4350b13cc0e6f5bec8fa7caf29a8fb8cdc051a3bae45cfbfd7ce64f009965", size = 68021 },
+ { url = "https://files.pythonhosted.org/packages/ae/88/c3c52d2a5d5101f28d3dc89298444626e7874aa904eed498464c2af17627/librt-0.8.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ac1e7817fd0ed3d14fd7c5df91daed84c48e4c2a11ee99c0547f9f62fdae13da", size = 194500 },
+ { url = "https://files.pythonhosted.org/packages/d6/5d/6fb0a25b6a8906e85b2c3b87bee1d6ed31510be7605b06772f9374ca5cb3/librt-0.8.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:747328be0c5b7075cde86a0e09d7a9196029800ba75a1689332348e998fb85c0", size = 205622 },
+ { url = "https://files.pythonhosted.org/packages/b2/a6/8006ae81227105476a45691f5831499e4d936b1c049b0c1feb17c11b02d1/librt-0.8.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f0af2bd2bc204fa27f3d6711d0f360e6b8c684a035206257a81673ab924aa11e", size = 218304 },
+ { url = "https://files.pythonhosted.org/packages/ee/19/60e07886ad16670aae57ef44dada41912c90906a6fe9f2b9abac21374748/librt-0.8.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d480de377f5b687b6b1bc0c0407426da556e2a757633cc7e4d2e1a057aa688f3", size = 211493 },
+ { url = "https://files.pythonhosted.org/packages/9c/cf/f666c89d0e861d05600438213feeb818c7514d3315bae3648b1fc145d2b6/librt-0.8.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d0ee06b5b5291f609ddb37b9750985b27bc567791bc87c76a569b3feed8481ac", size = 219129 },
+ { url = "https://files.pythonhosted.org/packages/8f/ef/f1bea01e40b4a879364c031476c82a0dc69ce068daad67ab96302fed2d45/librt-0.8.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:9e2c6f77b9ad48ce5603b83b7da9ee3e36b3ab425353f695cba13200c5d96596", size = 213113 },
+ { url = "https://files.pythonhosted.org/packages/9b/80/cdab544370cc6bc1b72ea369525f547a59e6938ef6863a11ab3cd24759af/librt-0.8.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:439352ba9373f11cb8e1933da194dcc6206daf779ff8df0ed69c5e39113e6a99", size = 212269 },
+ { url = "https://files.pythonhosted.org/packages/9d/9c/48d6ed8dac595654f15eceab2035131c136d1ae9a1e3548e777bb6dbb95d/librt-0.8.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:82210adabbc331dbb65d7868b105185464ef13f56f7f76688565ad79f648b0fe", size = 234673 },
+ { url = "https://files.pythonhosted.org/packages/16/01/35b68b1db517f27a01be4467593292eb5315def8900afad29fabf56304ba/librt-0.8.1-cp311-cp311-win32.whl", hash = "sha256:52c224e14614b750c0a6d97368e16804a98c684657c7518752c356834fff83bb", size = 54597 },
+ { url = "https://files.pythonhosted.org/packages/71/02/796fe8f02822235966693f257bf2c79f40e11337337a657a8cfebba5febc/librt-0.8.1-cp311-cp311-win_amd64.whl", hash = "sha256:c00e5c884f528c9932d278d5c9cbbea38a6b81eb62c02e06ae53751a83a4d52b", size = 61733 },
+ { url = "https://files.pythonhosted.org/packages/28/ad/232e13d61f879a42a4e7117d65e4984bb28371a34bb6fb9ca54ec2c8f54e/librt-0.8.1-cp311-cp311-win_arm64.whl", hash = "sha256:f7cdf7f26c2286ffb02e46d7bac56c94655540b26347673bea15fa52a6af17e9", size = 52273 },
+ { url = "https://files.pythonhosted.org/packages/95/21/d39b0a87ac52fc98f621fb6f8060efb017a767ebbbac2f99fbcbc9ddc0d7/librt-0.8.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a28f2612ab566b17f3698b0da021ff9960610301607c9a5e8eaca62f5e1c350a", size = 66516 },
+ { url = "https://files.pythonhosted.org/packages/69/f1/46375e71441c43e8ae335905e069f1c54febee63a146278bcee8782c84fd/librt-0.8.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:60a78b694c9aee2a0f1aaeaa7d101cf713e92e8423a941d2897f4fa37908dab9", size = 68634 },
+ { url = "https://files.pythonhosted.org/packages/0a/33/c510de7f93bf1fa19e13423a606d8189a02624a800710f6e6a0a0f0784b3/librt-0.8.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:758509ea3f1eba2a57558e7e98f4659d0ea7670bff49673b0dde18a3c7e6c0eb", size = 198941 },
+ { url = "https://files.pythonhosted.org/packages/dd/36/e725903416409a533d92398e88ce665476f275081d0d7d42f9c4951999e5/librt-0.8.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:039b9f2c506bd0ab0f8725aa5ba339c6f0cd19d3b514b50d134789809c24285d", size = 209991 },
+ { url = "https://files.pythonhosted.org/packages/30/7a/8d908a152e1875c9f8eac96c97a480df425e657cdb47854b9efaa4998889/librt-0.8.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5bb54f1205a3a6ab41a6fd71dfcdcbd278670d3a90ca502a30d9da583105b6f7", size = 224476 },
+ { url = "https://files.pythonhosted.org/packages/a8/b8/a22c34f2c485b8903a06f3fe3315341fe6876ef3599792344669db98fcff/librt-0.8.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:05bd41cdee35b0c59c259f870f6da532a2c5ca57db95b5f23689fcb5c9e42440", size = 217518 },
+ { url = "https://files.pythonhosted.org/packages/79/6f/5c6fea00357e4f82ba44f81dbfb027921f1ab10e320d4a64e1c408d035d9/librt-0.8.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:adfab487facf03f0d0857b8710cf82d0704a309d8ffc33b03d9302b4c64e91a9", size = 225116 },
+ { url = "https://files.pythonhosted.org/packages/f2/a0/95ced4e7b1267fe1e2720a111685bcddf0e781f7e9e0ce59d751c44dcfe5/librt-0.8.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:153188fe98a72f206042be10a2c6026139852805215ed9539186312d50a8e972", size = 217751 },
+ { url = "https://files.pythonhosted.org/packages/93/c2/0517281cb4d4101c27ab59472924e67f55e375bc46bedae94ac6dc6e1902/librt-0.8.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:dd3c41254ee98604b08bd5b3af5bf0a89740d4ee0711de95b65166bf44091921", size = 218378 },
+ { url = "https://files.pythonhosted.org/packages/43/e8/37b3ac108e8976888e559a7b227d0ceac03c384cfd3e7a1c2ee248dbae79/librt-0.8.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e0d138c7ae532908cbb342162b2611dbd4d90c941cd25ab82084aaf71d2c0bd0", size = 241199 },
+ { url = "https://files.pythonhosted.org/packages/4b/5b/35812d041c53967fedf551a39399271bbe4257e681236a2cf1a69c8e7fa1/librt-0.8.1-cp312-cp312-win32.whl", hash = "sha256:43353b943613c5d9c49a25aaffdba46f888ec354e71e3529a00cca3f04d66a7a", size = 54917 },
+ { url = "https://files.pythonhosted.org/packages/de/d1/fa5d5331b862b9775aaf2a100f5ef86854e5d4407f71bddf102f4421e034/librt-0.8.1-cp312-cp312-win_amd64.whl", hash = "sha256:ff8baf1f8d3f4b6b7257fcb75a501f2a5499d0dda57645baa09d4d0d34b19444", size = 62017 },
+ { url = "https://files.pythonhosted.org/packages/c7/7c/c614252f9acda59b01a66e2ddfd243ed1c7e1deab0293332dfbccf862808/librt-0.8.1-cp312-cp312-win_arm64.whl", hash = "sha256:0f2ae3725904f7377e11cc37722d5d401e8b3d5851fb9273d7f4fe04f6b3d37d", size = 52441 },
+ { url = "https://files.pythonhosted.org/packages/c5/3c/f614c8e4eaac7cbf2bbdf9528790b21d89e277ee20d57dc6e559c626105f/librt-0.8.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7e6bad1cd94f6764e1e21950542f818a09316645337fd5ab9a7acc45d99a8f35", size = 66529 },
+ { url = "https://files.pythonhosted.org/packages/ab/96/5836544a45100ae411eda07d29e3d99448e5258b6e9c8059deb92945f5c2/librt-0.8.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cf450f498c30af55551ba4f66b9123b7185362ec8b625a773b3d39aa1a717583", size = 68669 },
+ { url = "https://files.pythonhosted.org/packages/06/53/f0b992b57af6d5531bf4677d75c44f095f2366a1741fb695ee462ae04b05/librt-0.8.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:eca45e982fa074090057132e30585a7e8674e9e885d402eae85633e9f449ce6c", size = 199279 },
+ { url = "https://files.pythonhosted.org/packages/f3/ad/4848cc16e268d14280d8168aee4f31cea92bbd2b79ce33d3e166f2b4e4fc/librt-0.8.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c3811485fccfda840861905b8c70bba5ec094e02825598bb9d4ca3936857a04", size = 210288 },
+ { url = "https://files.pythonhosted.org/packages/52/05/27fdc2e95de26273d83b96742d8d3b7345f2ea2bdbd2405cc504644f2096/librt-0.8.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e4af413908f77294605e28cfd98063f54b2c790561383971d2f52d113d9c363", size = 224809 },
+ { url = "https://files.pythonhosted.org/packages/7a/d0/78200a45ba3240cb042bc597d6f2accba9193a2c57d0356268cbbe2d0925/librt-0.8.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5212a5bd7fae98dae95710032902edcd2ec4dc994e883294f75c857b83f9aba0", size = 218075 },
+ { url = "https://files.pythonhosted.org/packages/af/72/a210839fa74c90474897124c064ffca07f8d4b347b6574d309686aae7ca6/librt-0.8.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e692aa2d1d604e6ca12d35e51fdc36f4cda6345e28e36374579f7ef3611b3012", size = 225486 },
+ { url = "https://files.pythonhosted.org/packages/a3/c1/a03cc63722339ddbf087485f253493e2b013039f5b707e8e6016141130fa/librt-0.8.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:4be2a5c926b9770c9e08e717f05737a269b9d0ebc5d2f0060f0fe3fe9ce47acb", size = 218219 },
+ { url = "https://files.pythonhosted.org/packages/58/f5/fff6108af0acf941c6f274a946aea0e484bd10cd2dc37610287ce49388c5/librt-0.8.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:fd1a720332ea335ceb544cf0a03f81df92abd4bb887679fd1e460976b0e6214b", size = 218750 },
+ { url = "https://files.pythonhosted.org/packages/71/67/5a387bfef30ec1e4b4f30562c8586566faf87e47d696768c19feb49e3646/librt-0.8.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:93c2af9e01e0ef80d95ae3c720be101227edae5f2fe7e3dc63d8857fadfc5a1d", size = 241624 },
+ { url = "https://files.pythonhosted.org/packages/d4/be/24f8502db11d405232ac1162eb98069ca49c3306c1d75c6ccc61d9af8789/librt-0.8.1-cp313-cp313-win32.whl", hash = "sha256:086a32dbb71336627e78cc1d6ee305a68d038ef7d4c39aaff41ae8c9aa46e91a", size = 54969 },
+ { url = "https://files.pythonhosted.org/packages/5c/73/c9fdf6cb2a529c1a092ce769a12d88c8cca991194dfe641b6af12fa964d2/librt-0.8.1-cp313-cp313-win_amd64.whl", hash = "sha256:e11769a1dbda4da7b00a76cfffa67aa47cfa66921d2724539eee4b9ede780b79", size = 62000 },
+ { url = "https://files.pythonhosted.org/packages/d3/97/68f80ca3ac4924f250cdfa6e20142a803e5e50fca96ef5148c52ee8c10ea/librt-0.8.1-cp313-cp313-win_arm64.whl", hash = "sha256:924817ab3141aca17893386ee13261f1d100d1ef410d70afe4389f2359fea4f0", size = 52495 },
+ { url = "https://files.pythonhosted.org/packages/c9/6a/907ef6800f7bca71b525a05f1839b21f708c09043b1c6aa77b6b827b3996/librt-0.8.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:6cfa7fe54fd4d1f47130017351a959fe5804bda7a0bc7e07a2cdbc3fdd28d34f", size = 66081 },
+ { url = "https://files.pythonhosted.org/packages/1b/18/25e991cd5640c9fb0f8d91b18797b29066b792f17bf8493da183bf5caabe/librt-0.8.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:228c2409c079f8c11fb2e5d7b277077f694cb93443eb760e00b3b83cb8b3176c", size = 68309 },
+ { url = "https://files.pythonhosted.org/packages/a4/36/46820d03f058cfb5a9de5940640ba03165ed8aded69e0733c417bb04df34/librt-0.8.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7aae78ab5e3206181780e56912d1b9bb9f90a7249ce12f0e8bf531d0462dd0fc", size = 196804 },
+ { url = "https://files.pythonhosted.org/packages/59/18/5dd0d3b87b8ff9c061849fbdb347758d1f724b9a82241aa908e0ec54ccd0/librt-0.8.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:172d57ec04346b047ca6af181e1ea4858086c80bdf455f61994c4aa6fc3f866c", size = 206907 },
+ { url = "https://files.pythonhosted.org/packages/d1/96/ef04902aad1424fd7299b62d1890e803e6ab4018c3044dca5922319c4b97/librt-0.8.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6b1977c4ea97ce5eb7755a78fae68d87e4102e4aaf54985e8b56806849cc06a3", size = 221217 },
+ { url = "https://files.pythonhosted.org/packages/6d/ff/7e01f2dda84a8f5d280637a2e5827210a8acca9a567a54507ef1c75b342d/librt-0.8.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:10c42e1f6fd06733ef65ae7bebce2872bcafd8d6e6b0a08fe0a05a23b044fb14", size = 214622 },
+ { url = "https://files.pythonhosted.org/packages/1e/8c/5b093d08a13946034fed57619742f790faf77058558b14ca36a6e331161e/librt-0.8.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4c8dfa264b9193c4ee19113c985c95f876fae5e51f731494fc4e0cf594990ba7", size = 221987 },
+ { url = "https://files.pythonhosted.org/packages/d3/cc/86b0b3b151d40920ad45a94ce0171dec1aebba8a9d72bb3fa00c73ab25dd/librt-0.8.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:01170b6729a438f0dedc4a26ed342e3dc4f02d1000b4b19f980e1877f0c297e6", size = 215132 },
+ { url = "https://files.pythonhosted.org/packages/fc/be/8588164a46edf1e69858d952654e216a9a91174688eeefb9efbb38a9c799/librt-0.8.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:7b02679a0d783bdae30d443025b94465d8c3dc512f32f5b5031f93f57ac32071", size = 215195 },
+ { url = "https://files.pythonhosted.org/packages/f5/f2/0b9279bea735c734d69344ecfe056c1ba211694a72df10f568745c899c76/librt-0.8.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:190b109bb69592a3401fe1ffdea41a2e73370ace2ffdc4a0e8e2b39cdea81b78", size = 237946 },
+ { url = "https://files.pythonhosted.org/packages/e9/cc/5f2a34fbc8aeb35314a3641f9956fa9051a947424652fad9882be7a97949/librt-0.8.1-cp314-cp314-win32.whl", hash = "sha256:e70a57ecf89a0f64c24e37f38d3fe217a58169d2fe6ed6d70554964042474023", size = 50689 },
+ { url = "https://files.pythonhosted.org/packages/a0/76/cd4d010ab2147339ca2b93e959c3686e964edc6de66ddacc935c325883d7/librt-0.8.1-cp314-cp314-win_amd64.whl", hash = "sha256:7e2f3edca35664499fbb36e4770650c4bd4a08abc1f4458eab9df4ec56389730", size = 57875 },
+ { url = "https://files.pythonhosted.org/packages/84/0f/2143cb3c3ca48bd3379dcd11817163ca50781927c4537345d608b5045998/librt-0.8.1-cp314-cp314-win_arm64.whl", hash = "sha256:0d2f82168e55ddefd27c01c654ce52379c0750ddc31ee86b4b266bcf4d65f2a3", size = 48058 },
+ { url = "https://files.pythonhosted.org/packages/d2/0e/9b23a87e37baf00311c3efe6b48d6b6c168c29902dfc3f04c338372fd7db/librt-0.8.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2c74a2da57a094bd48d03fa5d196da83d2815678385d2978657499063709abe1", size = 68313 },
+ { url = "https://files.pythonhosted.org/packages/db/9a/859c41e5a4f1c84200a7d2b92f586aa27133c8243b6cac9926f6e54d01b9/librt-0.8.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a355d99c4c0d8e5b770313b8b247411ed40949ca44e33e46a4789b9293a907ee", size = 70994 },
+ { url = "https://files.pythonhosted.org/packages/4c/28/10605366ee599ed34223ac2bf66404c6fb59399f47108215d16d5ad751a8/librt-0.8.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2eb345e8b33fb748227409c9f1233d4df354d6e54091f0e8fc53acdb2ffedeb7", size = 220770 },
+ { url = "https://files.pythonhosted.org/packages/af/8d/16ed8fd452dafae9c48d17a6bc1ee3e818fd40ef718d149a8eff2c9f4ea2/librt-0.8.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9be2f15e53ce4e83cc08adc29b26fb5978db62ef2a366fbdf716c8a6c8901040", size = 235409 },
+ { url = "https://files.pythonhosted.org/packages/89/1b/7bdf3e49349c134b25db816e4a3db6b94a47ac69d7d46b1e682c2c4949be/librt-0.8.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:785ae29c1f5c6e7c2cde2c7c0e148147f4503da3abc5d44d482068da5322fd9e", size = 246473 },
+ { url = "https://files.pythonhosted.org/packages/4e/8a/91fab8e4fd2a24930a17188c7af5380eb27b203d72101c9cc000dbdfd95a/librt-0.8.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1d3a7da44baf692f0c6aeb5b2a09c5e6fc7a703bca9ffa337ddd2e2da53f7732", size = 238866 },
+ { url = "https://files.pythonhosted.org/packages/b9/e0/c45a098843fc7c07e18a7f8a24ca8496aecbf7bdcd54980c6ca1aaa79a8e/librt-0.8.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5fc48998000cbc39ec0d5311312dda93ecf92b39aaf184c5e817d5d440b29624", size = 250248 },
+ { url = "https://files.pythonhosted.org/packages/82/30/07627de23036640c952cce0c1fe78972e77d7d2f8fd54fa5ef4554ff4a56/librt-0.8.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:e96baa6820280077a78244b2e06e416480ed859bbd8e5d641cf5742919d8beb4", size = 240629 },
+ { url = "https://files.pythonhosted.org/packages/fb/c1/55bfe1ee3542eba055616f9098eaf6eddb966efb0ca0f44eaa4aba327307/librt-0.8.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:31362dbfe297b23590530007062c32c6f6176f6099646bb2c95ab1b00a57c382", size = 239615 },
+ { url = "https://files.pythonhosted.org/packages/2b/39/191d3d28abc26c9099b19852e6c99f7f6d400b82fa5a4e80291bd3803e19/librt-0.8.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cc3656283d11540ab0ea01978378e73e10002145117055e03722417aeab30994", size = 263001 },
+ { url = "https://files.pythonhosted.org/packages/b9/eb/7697f60fbe7042ab4e88f4ee6af496b7f222fffb0a4e3593ef1f29f81652/librt-0.8.1-cp314-cp314t-win32.whl", hash = "sha256:738f08021b3142c2918c03692608baed43bc51144c29e35807682f8070ee2a3a", size = 51328 },
+ { url = "https://files.pythonhosted.org/packages/7c/72/34bf2eb7a15414a23e5e70ecb9440c1d3179f393d9349338a91e2781c0fb/librt-0.8.1-cp314-cp314t-win_amd64.whl", hash = "sha256:89815a22daf9c51884fb5dbe4f1ef65ee6a146e0b6a8df05f753e2e4a9359bf4", size = 58722 },
+ { url = "https://files.pythonhosted.org/packages/b2/c8/d148e041732d631fc76036f8b30fae4e77b027a1e95b7a84bb522481a940/librt-0.8.1-cp314-cp314t-win_arm64.whl", hash = "sha256:bf512a71a23504ed08103a13c941f763db13fb11177beb3d9244c98c29fb4a61", size = 48755 },
+]
+
[[package]]
name = "markdown-it-py"
version = "4.0.0"
@@ -1397,6 +1474,54 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/23/d8/f15b40611c2d5753d1abb0ca0da0c75348daf1252220e5dda2867bd81062/msgspec-0.19.0-cp313-cp313-win_amd64.whl", hash = "sha256:317050bc0f7739cb30d257ff09152ca309bf5a369854bbf1e57dffc310c1f20f", size = 187432 },
]
+[[package]]
+name = "mypy"
+version = "1.19.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "librt", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "mypy-extensions" },
+ { name = "pathspec" },
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/f5/db/4efed9504bc01309ab9c2da7e352cc223569f05478012b5d9ece38fd44d2/mypy-1.19.1.tar.gz", hash = "sha256:19d88bb05303fe63f71dd2c6270daca27cb9401c4ca8255fe50d1d920e0eb9ba", size = 3582404 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ef/47/6b3ebabd5474d9cdc170d1342fbf9dddc1b0ec13ec90bf9004ee6f391c31/mypy-1.19.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d8dfc6ab58ca7dda47d9237349157500468e404b17213d44fc1cb77bce532288", size = 13028539 },
+ { url = "https://files.pythonhosted.org/packages/5c/a6/ac7c7a88a3c9c54334f53a941b765e6ec6c4ebd65d3fe8cdcfbe0d0fd7db/mypy-1.19.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e3f276d8493c3c97930e354b2595a44a21348b320d859fb4a2b9f66da9ed27ab", size = 12083163 },
+ { url = "https://files.pythonhosted.org/packages/67/af/3afa9cf880aa4a2c803798ac24f1d11ef72a0c8079689fac5cfd815e2830/mypy-1.19.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2abb24cf3f17864770d18d673c85235ba52456b36a06b6afc1e07c1fdcd3d0e6", size = 12687629 },
+ { url = "https://files.pythonhosted.org/packages/2d/46/20f8a7114a56484ab268b0ab372461cb3a8f7deed31ea96b83a4e4cfcfca/mypy-1.19.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a009ffa5a621762d0c926a078c2d639104becab69e79538a494bcccb62cc0331", size = 13436933 },
+ { url = "https://files.pythonhosted.org/packages/5b/f8/33b291ea85050a21f15da910002460f1f445f8007adb29230f0adea279cb/mypy-1.19.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f7cee03c9a2e2ee26ec07479f38ea9c884e301d42c6d43a19d20fb014e3ba925", size = 13661754 },
+ { url = "https://files.pythonhosted.org/packages/fd/a3/47cbd4e85bec4335a9cd80cf67dbc02be21b5d4c9c23ad6b95d6c5196bac/mypy-1.19.1-cp311-cp311-win_amd64.whl", hash = "sha256:4b84a7a18f41e167f7995200a1d07a4a6810e89d29859df936f1c3923d263042", size = 10055772 },
+ { url = "https://files.pythonhosted.org/packages/06/8a/19bfae96f6615aa8a0604915512e0289b1fad33d5909bf7244f02935d33a/mypy-1.19.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a8174a03289288c1f6c46d55cef02379b478bfbc8e358e02047487cad44c6ca1", size = 13206053 },
+ { url = "https://files.pythonhosted.org/packages/a5/34/3e63879ab041602154ba2a9f99817bb0c85c4df19a23a1443c8986e4d565/mypy-1.19.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffcebe56eb09ff0c0885e750036a095e23793ba6c2e894e7e63f6d89ad51f22e", size = 12219134 },
+ { url = "https://files.pythonhosted.org/packages/89/cc/2db6f0e95366b630364e09845672dbee0cbf0bbe753a204b29a944967cd9/mypy-1.19.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b64d987153888790bcdb03a6473d321820597ab8dd9243b27a92153c4fa50fd2", size = 12731616 },
+ { url = "https://files.pythonhosted.org/packages/00/be/dd56c1fd4807bc1eba1cf18b2a850d0de7bacb55e158755eb79f77c41f8e/mypy-1.19.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c35d298c2c4bba75feb2195655dfea8124d855dfd7343bf8b8c055421eaf0cf8", size = 13620847 },
+ { url = "https://files.pythonhosted.org/packages/6d/42/332951aae42b79329f743bf1da088cd75d8d4d9acc18fbcbd84f26c1af4e/mypy-1.19.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:34c81968774648ab5ac09c29a375fdede03ba253f8f8287847bd480782f73a6a", size = 13834976 },
+ { url = "https://files.pythonhosted.org/packages/6f/63/e7493e5f90e1e085c562bb06e2eb32cae27c5057b9653348d38b47daaecc/mypy-1.19.1-cp312-cp312-win_amd64.whl", hash = "sha256:b10e7c2cd7870ba4ad9b2d8a6102eb5ffc1f16ca35e3de6bfa390c1113029d13", size = 10118104 },
+ { url = "https://files.pythonhosted.org/packages/de/9f/a6abae693f7a0c697dbb435aac52e958dc8da44e92e08ba88d2e42326176/mypy-1.19.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e3157c7594ff2ef1634ee058aafc56a82db665c9438fd41b390f3bde1ab12250", size = 13201927 },
+ { url = "https://files.pythonhosted.org/packages/9a/a4/45c35ccf6e1c65afc23a069f50e2c66f46bd3798cbe0d680c12d12935caa/mypy-1.19.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bdb12f69bcc02700c2b47e070238f42cb87f18c0bc1fc4cdb4fb2bc5fd7a3b8b", size = 12206730 },
+ { url = "https://files.pythonhosted.org/packages/05/bb/cdcf89678e26b187650512620eec8368fded4cfd99cfcb431e4cdfd19dec/mypy-1.19.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f859fb09d9583a985be9a493d5cfc5515b56b08f7447759a0c5deaf68d80506e", size = 12724581 },
+ { url = "https://files.pythonhosted.org/packages/d1/32/dd260d52babf67bad8e6770f8e1102021877ce0edea106e72df5626bb0ec/mypy-1.19.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c9a6538e0415310aad77cb94004ca6482330fece18036b5f360b62c45814c4ef", size = 13616252 },
+ { url = "https://files.pythonhosted.org/packages/71/d0/5e60a9d2e3bd48432ae2b454b7ef2b62a960ab51292b1eda2a95edd78198/mypy-1.19.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:da4869fc5e7f62a88f3fe0b5c919d1d9f7ea3cef92d3689de2823fd27e40aa75", size = 13840848 },
+ { url = "https://files.pythonhosted.org/packages/98/76/d32051fa65ecf6cc8c6610956473abdc9b4c43301107476ac03559507843/mypy-1.19.1-cp313-cp313-win_amd64.whl", hash = "sha256:016f2246209095e8eda7538944daa1d60e1e8134d98983b9fc1e92c1fc0cb8dd", size = 10135510 },
+ { url = "https://files.pythonhosted.org/packages/de/eb/b83e75f4c820c4247a58580ef86fcd35165028f191e7e1ba57128c52782d/mypy-1.19.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:06e6170bd5836770e8104c8fdd58e5e725cfeb309f0a6c681a811f557e97eac1", size = 13199744 },
+ { url = "https://files.pythonhosted.org/packages/94/28/52785ab7bfa165f87fcbb61547a93f98bb20e7f82f90f165a1f69bce7b3d/mypy-1.19.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:804bd67b8054a85447c8954215a906d6eff9cabeabe493fb6334b24f4bfff718", size = 12215815 },
+ { url = "https://files.pythonhosted.org/packages/0a/c6/bdd60774a0dbfb05122e3e925f2e9e846c009e479dcec4821dad881f5b52/mypy-1.19.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21761006a7f497cb0d4de3d8ef4ca70532256688b0523eee02baf9eec895e27b", size = 12740047 },
+ { url = "https://files.pythonhosted.org/packages/32/2a/66ba933fe6c76bd40d1fe916a83f04fed253152f451a877520b3c4a5e41e/mypy-1.19.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:28902ee51f12e0f19e1e16fbe2f8f06b6637f482c459dd393efddd0ec7f82045", size = 13601998 },
+ { url = "https://files.pythonhosted.org/packages/e3/da/5055c63e377c5c2418760411fd6a63ee2b96cf95397259038756c042574f/mypy-1.19.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:481daf36a4c443332e2ae9c137dfee878fcea781a2e3f895d54bd3002a900957", size = 13807476 },
+ { url = "https://files.pythonhosted.org/packages/cd/09/4ebd873390a063176f06b0dbf1f7783dd87bd120eae7727fa4ae4179b685/mypy-1.19.1-cp314-cp314-win_amd64.whl", hash = "sha256:8bb5c6f6d043655e055be9b542aa5f3bdd30e4f3589163e85f93f3640060509f", size = 10281872 },
+ { url = "https://files.pythonhosted.org/packages/8d/f4/4ce9a05ce5ded1de3ec1c1d96cf9f9504a04e54ce0ed55cfa38619a32b8d/mypy-1.19.1-py3-none-any.whl", hash = "sha256:f1235f5ea01b7db5468d53ece6aaddf1ad0b88d9e7462b86ef96fe04995d7247", size = 2471239 },
+]
+
+[[package]]
+name = "mypy-extensions"
+version = "1.1.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963 },
+]
+
[[package]]
name = "nbclient"
version = "0.10.2"
@@ -1665,6 +1790,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/16/32/f8e3c85d1d5250232a5d3477a2a28cc291968ff175caeadaf3cc19ce0e4a/parso-0.8.5-py2.py3-none-any.whl", hash = "sha256:646204b5ee239c396d040b90f9e272e9a8017c630092bf59980beb62fd033887", size = 106668 },
]
+[[package]]
+name = "pathspec"
+version = "1.0.4"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/fa/36/e27608899f9b8d4dff0617b2d9ab17ca5608956ca44461ac14ac48b44015/pathspec-1.0.4.tar.gz", hash = "sha256:0210e2ae8a21a9137c0d470578cb0e595af87edaa6ebf12ff176f14a02e0e645", size = 131200 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ef/3c/2c197d226f9ea224a9ab8d197933f9da0ae0aac5b6e0f884e2b8d9c8e9f7/pathspec-1.0.4-py3-none-any.whl", hash = "sha256:fb6ae2fd4e7c921a165808a552060e722767cfa526f99ca5156ed2ce45a5c723", size = 55206 },
+]
+
[[package]]
name = "pexpect"
version = "4.9.0"
@@ -2743,6 +2877,30 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/00/c0/8f5d070730d7836adc9c9b6408dec68c6ced86b304a9b26a14df072a6e8c/traitlets-5.14.3-py3-none-any.whl", hash = "sha256:b74e89e397b1ed28cc831db7aea759ba6640cb3de13090ca145426688ff1ac4f", size = 85359 },
]
+[[package]]
+name = "ty"
+version = "0.0.24"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/7a/96/652a425030f95dc2c9548d9019e52502e17079e1daeefbc4036f1c0905b4/ty-0.0.24.tar.gz", hash = "sha256:9fe42f6b98207bdaef51f71487d6d087f2cb02555ee3939884d779b2b3cc8bfc", size = 5354286 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/da/e5/34457ee11708e734ba81ad65723af83030e484f961e281d57d1eecf08951/ty-0.0.24-py3-none-linux_armv6l.whl", hash = "sha256:1ab4f1f61334d533a3fdf5d9772b51b1300ac5da4f3cdb0be9657a3ccb2ce3e7", size = 10394877 },
+ { url = "https://files.pythonhosted.org/packages/44/81/bc9a1b1a87f43db15ab64ad781a4f999734ec3b470ad042624fa875b20e6/ty-0.0.24-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:facbf2c4aaa6985229e08f8f9bf152215eb078212f22b5c2411f35386688ab42", size = 10211109 },
+ { url = "https://files.pythonhosted.org/packages/e4/63/cfc805adeaa61d63ba3ea71127efa7d97c40ba36d97ee7bd957341d05107/ty-0.0.24-py3-none-macosx_11_0_arm64.whl", hash = "sha256:b6d2a3b6d4470c483552a31e9b368c86f154dcc964bccb5406159dc9cd362246", size = 9694769 },
+ { url = "https://files.pythonhosted.org/packages/33/09/edc220726b6ec44a58900401f6b27140997ef15026b791e26b69a6e69eb5/ty-0.0.24-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0c94c25d0500939fd5f8f16ce41cbed5b20528702c1d649bf80300253813f0a2", size = 10176287 },
+ { url = "https://files.pythonhosted.org/packages/f8/bf/cbe2227be711e65017655d8ee4d050f4c92b113fb4dc4c3bd6a19d3a86d8/ty-0.0.24-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:89cbe7bc7df0fab02dbd8cda79b737df83f1ef7fb573b08c0ee043dc68cffb08", size = 10214832 },
+ { url = "https://files.pythonhosted.org/packages/af/1d/d15803ee47e9143d10e10bd81ccc14761d08758082bda402950685f0ddfe/ty-0.0.24-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:db2c5d269bcc9b764850c99f457b5018a79b3ef40ecfbc03344e65effd6cf743", size = 10709892 },
+ { url = "https://files.pythonhosted.org/packages/36/12/6db0d86c477147f67b9052de209421d76c3e855197b000c25fcbbe86b3a2/ty-0.0.24-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ba44512db5b97c3bbd59d93e11296e8548d0c9a3bdd1280de36d7ff22d351896", size = 11280872 },
+ { url = "https://files.pythonhosted.org/packages/1b/fc/155fe83a97c06d33ccc9e0f428258b32df2e08a428300c715d34757f0111/ty-0.0.24-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a52b7f589c3205512a9c50ba5b2b1e8c0698b72e51b8b9285c90420c06f1cae8", size = 11060520 },
+ { url = "https://files.pythonhosted.org/packages/ac/f1/32c05a1c4c3c2a95c5b7361dee03a9bf1231d4ad096b161c838b45bce5a0/ty-0.0.24-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7981df5c709c054da4ac5d7c93f8feb8f45e69e829e4461df4d5f0988fe67d04", size = 10791455 },
+ { url = "https://files.pythonhosted.org/packages/17/2c/53c1ea6bedfa4d4ab64d4de262d8f5e405ecbffefd364459c628c0310d33/ty-0.0.24-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:b2860151ad95a00d0f0280b8fef79900d08dcd63276b57e6e5774f2c055979c5", size = 10156708 },
+ { url = "https://files.pythonhosted.org/packages/45/39/7d2919cf194707169474d80720a5f3d793e983416f25e7ffcf80504c9df2/ty-0.0.24-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:5674a1146d927ab77ff198a88e0c4505134ced342a0e7d1beb4a076a728b7496", size = 10236263 },
+ { url = "https://files.pythonhosted.org/packages/cf/7f/48eac722f2fd12a5b7aae0effdcb75c46053f94b783d989e3ef0d7380082/ty-0.0.24-py3-none-musllinux_1_2_i686.whl", hash = "sha256:438ecbf1608a9b16dd84502f3f1b23ef2ef32bbd0ab3e0ca5a82f0e0d1cd41ea", size = 10402559 },
+ { url = "https://files.pythonhosted.org/packages/75/e0/8cf868b9749ce1e5166462759545964e95b02353243594062b927d8bff2a/ty-0.0.24-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:ddeed3098dd92a83964e7aa7b41e509ba3530eb539fc4cd8322ff64a09daf1f5", size = 10893684 },
+ { url = "https://files.pythonhosted.org/packages/17/9f/f54bf3be01d2c2ed731d10a5afa3324dc66f987a6ae0a4a6cbfa2323d080/ty-0.0.24-py3-none-win32.whl", hash = "sha256:83013fb3a4764a8f8bcc6ca11ff8bdfd8c5f719fc249241cb2b8916e80778eb1", size = 9781542 },
+ { url = "https://files.pythonhosted.org/packages/fb/49/c004c5cc258b10b3a145666e9a9c28ae7678bc958c8926e8078d5d769081/ty-0.0.24-py3-none-win_amd64.whl", hash = "sha256:748a60eb6912d1cf27aaab105ffadb6f4d2e458a3fcadfbd3cf26db0d8062eeb", size = 10764801 },
+ { url = "https://files.pythonhosted.org/packages/e2/59/006a074e185bfccf5e4c026015245ab4fcd2362b13a8d24cf37a277909a9/ty-0.0.24-py3-none-win_arm64.whl", hash = "sha256:280a3d31e86d0721947238f17030c33f0911cae851d108ea9f4e3ab12a5ed01f", size = 10194093 },
+]
+
[[package]]
name = "typing-extensions"
version = "4.15.0"
From 40b6931f4fadc54bfcb2d8c35fa77c45e31d0db1 Mon Sep 17 00:00:00 2001
From: Vignesh Venkatasubramanian Vaidyanathan
<62492557+VigneshVSV@users.noreply.github.com>
Date: Fri, 27 Mar 2026 19:04:22 +0100
Subject: [PATCH 02/15] make ruff more strict for docstrings
---
hololinked/client/proxy.py | 228 ++++++++++++++++++++++------------
hololinked/client/security.py | 101 +++++++++++----
pyproject.toml | 14 ++-
3 files changed, 237 insertions(+), 106 deletions(-)
diff --git a/hololinked/client/proxy.py b/hololinked/client/proxy.py
index 59321cef..226207c6 100644
--- a/hololinked/client/proxy.py
+++ b/hololinked/client/proxy.py
@@ -1,3 +1,5 @@
+"""Implementation of procedural/scripting client for Thing."""
+
from typing import Any, Callable
import structlog
@@ -8,10 +10,22 @@
class ObjectProxy:
"""
- Procedural/scripting client for `Thing`. Once connected to a server, properties, methods and events are loaded and
- dynamically populated. Can be used with any supported protocol binding.
+ Procedural/scripting client for `Thing`.
+
+ [Documentation](https://docs.hololinked.dev/beginners-guide/articles/object-proxy/)
+
+ Once connected to a server, properties, methods and events are loaded and
+ dynamically populated. One instance can be used with any supported protocol, however only one protocol at a time.
Use `ClientFactory` to create an instance of this class instead of directly creating it.
+
+ ```python
+ from hololinked.client import ClientFactory
+
+ http_client = ClientFactory.http(url="http://example.com/thing")
+ zmq_client = ClientFactory.zmq(access_point="IPC", server_id="example_server", thing_id="example_thing")
+ mqtt_client = ClientFactory.mqtt(broker_url="mqtt://example.com", thing_id="example_thing")
+ ```
"""
_own_attrs = frozenset(
@@ -31,15 +45,22 @@ class ObjectProxy:
"_security",
]
)
+ """
+ Attributes of ObjectProxy that are not dynamically populated from the server and are used for internal logic
+ of the client. Dynamic properties are not supported unless `allow_foreign_attributes` is set to `True`.
+ """
__allowed_attribute_types__ = (
ConsumedThingProperty,
ConsumedThingAction,
ConsumedThingEvent,
)
+ """Allowed types for dynamically populated attributes from the server."""
def __init__(self, id: str, **kwargs) -> None:
"""
+ Initialize the client with given id and other optional parameters.
+
Parameters
----------
id: str
@@ -118,7 +139,7 @@ def __hash__(self) -> int:
def invoke_action(self, name: str, *args, **kwargs) -> Any:
"""
- invoke an action specified by name on the server with positional/keyword arguments
+ Invoke an action specified by name on the served Thing with positional/keyword arguments.
Parameters
----------
@@ -131,9 +152,9 @@ def invoke_action(self, name: str, *args, **kwargs) -> Any:
schedule an action invokation but collect the reply later using a reply id.
only accepted as keyword argument.
*args: Any
- arguments for the action
+ arguments for the action.
**kwargs: dict[str, Any]
- keyword arguments for the action
+ keyword arguments for the action.
Returns
-------
@@ -161,29 +182,30 @@ def invoke_action(self, name: str, *args, **kwargs) -> Any:
async def async_invoke_action(self, name: str, *args, **kwargs) -> Any:
"""
- async(io) call an action specified by name on the server with positional/keyword
- arguments. `noblock` and `oneway` are not supported for async calls.
+ async(io) call an action specified by name on the served Thing with positional/keyword arguments.
+
+ `noblock` and `oneway` are not supported for async calls.
Parameters
----------
name: str
- name of the action
+ name of the action.
*args: Any
- arguments for the action
+ arguments for the action.
**kwargs: dict[str, Any]
- keyword arguments for the action
+ keyword arguments for the action.
Returns
-------
Any
- return value of the action call
+ return value of the action call.
Raises
------
AttributeError
- if action with specified name not found in the Thing Description
+ if action with specified name not found in the Thing Description.
Exception
- server raised exception are propagated
+ server raised exception are propagated.
"""
action = getattr(self, name, None) # type: ConsumedThingAction
if not isinstance(action, ConsumedThingAction):
@@ -192,21 +214,26 @@ async def async_invoke_action(self, name: str, *args, **kwargs) -> Any:
def read_property(self, name: str, noblock: bool = False) -> Any:
"""
- read property specified by name on server.
+ Read property specified by name on the served Thing.
Parameters
----------
name: str
- name of the property
+ name of the property.
noblock: bool, default False
- request the property but collect the reply/value later using a reply id
+ request the property but collect the reply/value later using a reply id.
+
+ Returns
+ -------
+ Any
+ value of the property or a message id if `noblock` is True.
Raises
------
AttributeError
- if no property with specified name found in the Thing Description
+ if no property with specified name found in the Thing Description.
Exception
- server raised exception are propagated
+ server raised exception are propagated.
"""
prop = self.__dict__.get(name, None) # type: ConsumedThingProperty
if not isinstance(prop, ConsumedThingProperty):
@@ -218,26 +245,26 @@ def read_property(self, name: str, noblock: bool = False) -> Any:
def write_property(self, name: str, value: Any, oneway: bool = False, noblock: bool = False) -> None:
"""
- write property specified by name on server with given value.
+ Write property specified by name on the served Thing with given value.
Parameters
----------
name: str
- name of the property
+ name of the property.
value: Any
- value of property to be set
+ value of property to be set.
oneway: bool, default False
only send an instruction to write the property but do not fetch the reply.
- (irrespective of whether write was successful or not)
+ (irrespective of whether write was successful or not).
noblock: bool, default False
- request the write property but collect the reply later using a reply id
+ request the write property but collect the reply later using a reply id.
Raises
------
AttributeError
- if no property with specified name found in the Thing Description
+ if no property with specified name found in the Thing Description.
Exception
- server raised exception are propagated
+ server raised exception are propagated.
"""
prop = self.__dict__.get(name, None) # type: ConsumedThingProperty
if not isinstance(prop, ConsumedThingProperty):
@@ -251,20 +278,26 @@ def write_property(self, name: str, value: Any, oneway: bool = False, noblock: b
async def async_read_property(self, name: str) -> Any:
"""
- async(io) read property specified by name on server.
+ async(io) read property specified by name on the served Thing.
+
`noblock` and `oneway` are not supported for async calls.
Parameters
----------
- name: Any
- name of the property to fetch
+ name: str
+ name of the property to fetch.
+
+ Returns
+ -------
+ Any
+ value of the property.
Raises
------
AttributeError
- if no property with specified name found in the Thing Description
+ if no property with specified name found in the Thing Description.
Exception
- server raised exception are propagated
+ server raised exception are propagated.
"""
prop = self.__dict__.get(name, None) # type: ConsumedThingProperty
if not isinstance(prop, ConsumedThingProperty):
@@ -273,22 +306,23 @@ async def async_read_property(self, name: str) -> Any:
async def async_write_property(self, name: str, value: Any) -> None:
"""
- async(io) write property specified by name on server with specified value.
+ async(io) write property specified by name on the served Thing with specified value.
+
`noblock` and `oneway` are not supported for async calls.
Parameters
----------
name: str
- name of the property
+ name of the property.
value: Any
- value of the property to be written
+ value of the property to be written.
Raises
------
AttributeError
- if no property with specified name found in the Thing Description
+ if no property with specified name found in the Thing Description.
Exception
- server raised exception are propagated
+ server raised exception are propagated.
"""
prop = self.__dict__.get(name, None) # type: ConsumedThingProperty
if not isinstance(prop, ConsumedThingProperty):
@@ -297,26 +331,27 @@ async def async_write_property(self, name: str, value: Any) -> None:
def read_multiple_properties(self, names: list[str], noblock: bool = False) -> dict[str, Any]:
"""
- read properties specified by list of names.
+ Read properties from the served Thing specified by list of names.
Parameters
----------
names: List[str]
- names of properties to be fetched
+ names of properties to be fetched.
noblock: bool, default False
- request the fetch but collect the reply later using a reply id
+ request the fetch but collect the reply later using a reply id.
Returns
-------
dict[str, Any]
- dictionary with names as keys and values corresponding to those keys
+ dictionary with names as keys and values corresponding to those keys.
Raises
------
- AttributeError
- if no property with specified name found in the Thing Description
+ RuntimeError
+ if internal `_get_properties` method is not found, which means client did not load server resources
+ correctly.
Exception
- server raised exception are propagated
+ server raised exception are propagated.
"""
method = getattr(self, "_get_properties", None) # type: ConsumedThingAction
if not method:
@@ -333,7 +368,7 @@ def write_multiple_properties(
**properties: dict[str, Any],
) -> None:
"""
- write properties whose name is specified as keyword arguments
+ Write properties onto the served Thing whose name is specified as keyword arguments.
Parameters
----------
@@ -347,8 +382,11 @@ def write_multiple_properties(
Raises
------
- AttributeError
- if no property with specified name found in the Thing Description
+ ValueError
+ if no properties are given to be written
+ RuntimeError
+ if internal `_set_properties` method is not found, which means client did not load server resources
+ correctly.
Exception
server raised exception are propagated
"""
@@ -366,17 +404,27 @@ def write_multiple_properties(
async def async_read_multiple_properties(self, names: list[str]) -> dict[str, Any]:
"""
- async(io) read properties specified by list of names. `noblock` reads are not supported for asyncio.
+ async(io) read properties from the served Thing specified by list of names.
+
+ `noblock` reads are not supported for asyncio.
Parameters
----------
names: List[str]
- names of properties to be fetched
+ names of properties to be fetched.
Returns
-------
dict[str, Any]
- dictionary with property names as keys and values corresponding to those keys
+ dictionary with property names as keys and values corresponding to those keys.
+
+ Raises
+ ------
+ RuntimeError
+ if internal `_get_properties` method is not found, which means client did not load server resources
+ correctly.
+ Exception
+ server raised exception are propagated.
"""
# TODO, actually noblock could be fine for async calls too
method = getattr(self, "_get_properties", None) # type: ConsumedThingAction
@@ -386,19 +434,22 @@ async def async_read_multiple_properties(self, names: list[str]) -> dict[str, An
async def async_write_multiple_properties(self, **properties: dict[str, Any]) -> None:
"""
- async(io) write properties whose name is specified by keys of a dictionary
+ async(io) write properties whose name is specified by keys of a dictionary.
Parameters
----------
properties: dict[str, Any]
- name and value of properties to be written
+ name and value of properties to be written.
Raises
------
- AttributeError
- if no property with specified name found in the Thing Description
+ ValueError
+ if no properties are given to be written.
+ RuntimeError
+ if internal `_set_properties` method is not found, which means client did not load server resources
+ correctly.
Exception
- server raised exception are propagated
+ server raised exception are propagated.
"""
if len(properties) == 0:
raise ValueError("no properties given to set_properties")
@@ -416,26 +467,28 @@ def observe_property(
deserialize: bool = True,
) -> None:
"""
- observe a property specified by name for change events.
+ Observe a property specified by name from the served Thing for change events.
+
+ This method returns immediately after subscribing to the change events of the property.
Parameters
----------
name: str
- name of the property
+ name of the property.
callbacks: Callable | List[Callable]
- one or more callbacks that will be executed when the property changes
+ one or more callbacks that will be executed when the property changes.
asynch: bool
- whether the event should be listened as an asyncio task
+ whether the event should be listened as an asyncio task.
concurrent: bool
- - when asynch is `False`, whether to thread each of the callbacks otherwise the callbacks will be executed serially
- - when asynch is `True`, whether to create a new task for each callback otherwise the callbacks will be awaited serially
+ - when asynch is `False`, whether to thread each of the callbacks otherwise the callbacks will be executed serially.
+ - when asynch is `True`, whether to create a new task for each callback otherwise the callbacks will be awaited serially.
deserialize: bool
- whether to deserialize the event data before passing it to the callbacks
+ whether to deserialize the event data before passing it to the callbacks.
Raises
------
AttributeError
- if no property with specified name found in the Thing Description or if the property is not observable
+ if no property with specified name found in the Thing Description or if the property is not observable.
"""
event = getattr(self, f"{name}_change_event", None) # type: ConsumedThingEvent
if not isinstance(event, ConsumedThingEvent):
@@ -450,17 +503,17 @@ def observe_property(
def unobserve_property(self, name: str) -> None:
"""
- Unsubscribe to property specified by name.
+ Unsubscribe to property from the served Thing specified by name.
Parameters
----------
name: str
- name of the property
+ name of the property.
Raises
------
AttributeError
- if no property with specified name found in the Thing Description or if the property is not observable
+ if no property with specified name found in the Thing Description or if the property is not observable.
"""
event = getattr(self, f"{name}_change_event", None) # type: ConsumedThingEvent
if not isinstance(event, ConsumedThingEvent):
@@ -477,28 +530,29 @@ def subscribe_event(
# create_new_connection: bool = False,
) -> None:
"""
- Subscribe to event specified by name. Events are listened in separate threads and supplied callbacks are
- are also called in those threads.
+ Subscribe to event specified by name.
+
+ This method returns immediately after subscribing to the event.
Parameters
----------
name: str
name of the event, either the object name used in the server or the name specified in the name argument of
- the Event object
+ the Event object.
callbacks: Callable | List[Callable]
- one or more callbacks that will be executed when the event is received
+ one or more callbacks that will be executed when the event is received.
asynch: bool
- whether the event should be listened as an asyncio task
+ whether the event should be listened as an asyncio task.
concurrent: bool
- - when asynch is `False`, whether to thread the callbacks otherwise the callbacks will be executed serially
- - when asynch is `True`, whether to create a new task for each callback otherwise the callbacks will be awaited serially
+ - when asynch is `False`, whether to thread the callbacks otherwise the callbacks will be executed serially.
+ - when asynch is `True`, whether to create a new task for each callback otherwise the callbacks will be awaited serially.
deserialize: bool
- whether to deserialize the event data before passing it to the callbacks
+ whether to deserialize the event data before passing it to the callbacks.
Raises
------
AttributeError
- if no event with specified name is found
+ if no event with specified name is found.
"""
event = getattr(self, name, None) # type: ConsumedThingEvent
if not isinstance(event, ConsumedThingEvent):
@@ -516,17 +570,17 @@ def subscribe_event(
def unsubscribe_event(self, name: str) -> None:
"""
- Unsubscribe to event specified by name.
+ Unsubscribe to event from served Thing specified by name.
Parameters
----------
name: str
- name of the event
+ name of the event.
Raises
------
AttributeError
- if no event with specified name is found
+ if no event with specified name is found.
"""
event = getattr(self, name, None) # type: ConsumedThingEvent
if not isinstance(event, ConsumedThingEvent):
@@ -535,7 +589,7 @@ def unsubscribe_event(self, name: str) -> None:
def read_reply(self, message_id: str, timeout: float | None = 5.0) -> Any:
"""
- read reply of no block calls of an action or a property read/write.
+ Read reply of no block calls of an action or no block calls of a property read/write.
Parameters
----------
@@ -543,6 +597,16 @@ def read_reply(self, message_id: str, timeout: float | None = 5.0) -> Any:
id returned by the no block call
timeout: float, optional, default 5.0
time to wait for a reply before raising TimeoutError. None waits indefinitely.
+
+ Returns
+ -------
+ Any
+ reply of the action or property.
+
+ Raises
+ ------
+ ValueError
+ if given message id is not found.
"""
obj = self._noblock_messages.get(message_id, None)
if not obj:
@@ -551,27 +615,27 @@ def read_reply(self, message_id: str, timeout: float | None = 5.0) -> Any:
@property
def properties(self) -> list[ConsumedThingProperty]:
- """list of properties that were consumed from the Thing Description"""
+ """List of properties that were consumed from the Thing Description."""
return [prop for prop in self.__dict__.values() if isinstance(prop, ConsumedThingProperty)]
@property
def actions(self) -> list[ConsumedThingAction]:
- """list of actions that were consumed from the Thing Description"""
+ """List of actions that were consumed from the Thing Description."""
return [action for action in self.__dict__.values() if isinstance(action, ConsumedThingAction)]
@property
def events(self) -> list[ConsumedThingEvent]:
- """list of events that were consumed from the Thing Description"""
+ """List of events that were consumed from the Thing Description."""
return [event for event in self.__dict__.values() if isinstance(event, ConsumedThingEvent)]
@property
def thing_id(self) -> str:
- """thing ID this client is connected to"""
+ """Thing ID this client is connected to."""
return self.td.get("id", None)
@property
def TD(self) -> dict[str, Any]:
- """Thing Description of the consuimed thing"""
+ """Thing Description of the consumed thing."""
return self.td
diff --git a/hololinked/client/security.py b/hololinked/client/security.py
index 2aebf85e..f54497ca 100644
--- a/hololinked/client/security.py
+++ b/hololinked/client/security.py
@@ -1,3 +1,5 @@
+"""Implementation of security schemes for authentication and authorization to be used by clients."""
+
import base64
import threading
import time
@@ -10,9 +12,10 @@
class BasicSecurity(BaseModel):
"""
- Basic Security Scheme with username and password. The credentials are added into the `Authorization` header.
- Normally, you can instantiate this indirectly through the `ClientFactory` by passing `username` and `password`
- parameters, if the protocol supports it.
+ Basic Security Scheme with username and password.
+
+ The credentials are added into the `Authorization` header. Normally, you can instantiate this indirectly through the
+ `ClientFactory` by passing `username` and `password` parameters, if the protocol supports it.
```python
client = ClientFactory.http(
@@ -36,6 +39,8 @@ class BasicSecurity(BaseModel):
def __init__(self, username: str, password: str, use_base64: bool = True) -> None:
"""
+ Initialize BasicSecurity with username and password.
+
Parameters
----------
username: str
@@ -53,13 +58,19 @@ def __init__(self, username: str, password: str, use_base64: bool = True) -> Non
@property
def http_header(self) -> str:
- """Value for the Authorization header - contains the credentials"""
+ """
+ Value for the Authorization header.
+
+ Contains the credentials prefixed with `Basic `, if necessary with base64 encoding.
+ """
return self._credentials
class APIKeySecurity(BaseModel):
"""
- API Key Security Scheme. The API key is added into a header named `X-API-Key`.
+ API Key Security Scheme.
+
+ The API key is added into a header named `X-API-Key`.
```python
client = ClientFactory.http(
@@ -80,30 +91,56 @@ class APIKeySecurity(BaseModel):
@property
def http_header(self) -> str:
+ """Value for the API key header."""
return self.value
class ROPC(BaseModel):
+ """Resource Owner Password Credentials (ROPC) token response."""
+
access_token: str
+ """The access token issued by the authorization server."""
scope: str
+ """The scope of the access token."""
refresh_token: str | None = None
+ """Token to refresh the access token when it expires, if provided by the authorization server."""
expires_in: int | None = None
+ """The lifetime in seconds of the access token."""
token_type: str | None = None
+ """
+ Type of token, access token or ID token.
+
+ Usually one needs the access token. However, in restricted cases, the ID token may be sufficient
+ and philosophically used to identify the user.
+ """
id_token: str | None = None
+ """ID token issued by the authorization server, if provided."""
class OAuthDirectAccessGrant(BaseModel):
"""
- OAuth2 Direct Access Grant Security Scheme. Implements Resource Owner Password Credentials (ROPC) flow - in simple
- terms, plain username and password authentication without the general features of OAuth2. Please implement other
- flows on your own for applications with a web interface. There is no intention to provide a complete OAuth2 client
- implementation in this library.
+ OAuth2 Direct Access Grant Security Scheme.
+
+ Implements Resource Owner Password Credentials (ROPC) flow - in simple terms, plain username and password
+ authentication without the general features of OAuth2. Please implement other flows on your own for applications
+ with a web interface. There is no intention to provide a complete OAuth2 client implementation in this library.
+
+ Warning
+ -------
+ This flow is not recommended for production use due to security risks, and should only be used in trusted
+ environments.
+
+ Note: The implementation class is `OAuth2Security`, which is instantiated indirectly through the `ClientFactory`.
"""
token_endpoint: str
+ """The token endpoint URL for obtaining tokens. Required if `oidc_config_url` is not provided."""
client_id: str
+ """client ID"""
client_secret: str | None = None
+ """client secret, recommended to create a client with a client secret"""
revocation_endpoint: str | None = None
+ """The token revocation endpoint URL, required if you want to support logout functionality."""
username: str
password: str
@@ -141,13 +178,19 @@ def __init__(
Set to False if you are using self-signed certificates in development or testing environments or using
a local provider.
kwargs:
- client_id: str
- The client ID for authentication, required for most OIDC providers.
- client_secret: str
- The client secret for authentication, required for some OIDC providers.
- revocation_endpoint: str
+ additional keyword arguments, currently supports:
+
+ - `client_id`: `str`
+ The client ID.
+ - `client_secret`: `str`
+ The client secret.
+ - `revocation_endpoint`: `str`
The token revocation endpoint URL, required if you want to support logout functionality.
- If not provided, logout functionality will not be available.
+
+ Raises
+ ------
+ ValueError
+ If neither `oidc_config_url` nor `token_endpoint` is provided.
"""
client_id = kwargs.get("client_id", None)
client_secret = kwargs.get("client_secret", None)
@@ -174,11 +217,16 @@ def __init__(
class OAuth2Security:
"""
- OAuth2 Security Scheme, supporting only direct access grant or Resource Owner Password Credentials (ROPC) flow.
- Please implement other flows on your own for applications with a web interface.
+ Implementation class for OAuth2 direct access grant security scheme.
+
+ Please refer to docs of `OAuthDirectAccessGrant` for details.
"""
http_header_name: str = "Authorization"
+ """
+ Name of the HTTP header to use for authentication, default is `Authorization`.
+ Override this if the server expects the token in a different header.
+ """
def __init__(
self,
@@ -197,9 +245,11 @@ def __init__(
The fraction of token expiration time to wait before refreshing tokens, by default 0.75,
which means refreshing tokens when 75% of the token expiration time has passed.
kwargs:
- sync_http_client: httpx.Client
+ additional keyword arguments, currently supports:
+
+ - `sync_http_client`: `httpx.Client`
The http client to use for synchronous requests, by default a new httpx.Client with 10s timeout.
- async_http_client: httpx.AsyncClient
+ - `async_http_client`: `httpx.AsyncClient`
The http client to use for asynchronous requests, by default a new httpx.AsyncClient with 10s timeout.
Unused currently, optional.
"""
@@ -213,12 +263,13 @@ def __init__(
@property
def http_header(self) -> str:
+ """Value for the Authorization header, containing the access token."""
if not self.tokens:
return ""
return f"Bearer {self.tokens.access_token}"
def login(self) -> None:
- """login with username and password and obtain tokens"""
+ """Login with username and password and obtain tokens."""
body = dict(
grant_type=self.oidc_settings.grant_type,
client_id=self.oidc_settings.client_id,
@@ -248,7 +299,7 @@ def login(self) -> None:
self._refresh_thread.start()
def logout(self) -> None:
- """logout and invalidate tokens"""
+ """Logout and invalidate tokens."""
if not self.tokens or not self.oidc_settings.revocation_endpoint:
return
body = dict(
@@ -268,7 +319,11 @@ def logout(self) -> None:
self._refresh = False
def refresh_tokens(self) -> None:
- """refresh tokens, even forcibly by relogin if necessary"""
+ """
+ Refresh tokens, even forcibly by relogin if necessary.
+
+ Call this method if authentication fails against the resource server.
+ """
if not self.tokens:
return
if not self.tokens.refresh_token:
@@ -304,7 +359,7 @@ def refresh_tokens(self) -> None:
self.login()
def _refresh_tokens_in_background(self) -> None:
- """background thread to refresh tokens periodically"""
+ """Background thread to refresh tokens periodically."""
if not self.tokens:
return
if not self.tokens.expires_in:
diff --git a/pyproject.toml b/pyproject.toml
index 070e8489..82a1a7d6 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -118,7 +118,19 @@ exclude = [
]
[tool.ruff.lint]
-extend-select = ["I"]
+preview = true
+extend-select = [
+ "I", # isort
+ "D", # pydocstyle
+ "DOC"
+]
+
+[tool.ruff.lint.per-file-ignores]
+"hololinked/client/abstractions.py" = ["DOC502"]
+"hololinked/client/proxy.py" = ["DOC502"]
+
+[tool.ruff.lint.pydocstyle]
+convention = "numpy"
[tool.ruff.lint.isort]
lines-between-types = 1
From 9c63fb009ef9e2bebda67cec9ae81f80c56dfadf Mon Sep 17 00:00:00 2001
From: Vignesh Venkatasubramanian Vaidyanathan
<62492557+VigneshVSV@users.noreply.github.com>
Date: Fri, 27 Mar 2026 20:32:11 +0100
Subject: [PATCH 03/15] do ty tuff factory.py
---
hololinked/client/exceptions.py | 5 +-
hololinked/client/factory.py | 208 +++++++++++++-----
.../client/http/consumed_interactions.py | 6 +-
.../client/zmq/consumed_interactions.py | 6 +-
pyproject.toml | 1 +
uv.lock | 26 +++
6 files changed, 184 insertions(+), 68 deletions(-)
diff --git a/hololinked/client/exceptions.py b/hololinked/client/exceptions.py
index cb996d8a..418dc77d 100644
--- a/hololinked/client/exceptions.py
+++ b/hololinked/client/exceptions.py
@@ -1,3 +1,6 @@
+"""Exceptions."""
+
+
class ReplyNotArrivedError(Exception):
"""Exception raised when a reply is not received in time."""
@@ -5,6 +8,6 @@ class ReplyNotArrivedError(Exception):
class BreakLoop(Exception):
- """raise and catch to exit a loop from within another function or method"""
+ """Raise and catch to exit a loop from within another function or method."""
pass
diff --git a/hololinked/client/factory.py b/hololinked/client/factory.py
index 9c4f922d..ecd364d2 100644
--- a/hololinked/client/factory.py
+++ b/hololinked/client/factory.py
@@ -1,8 +1,10 @@
+"""Implementation of the ClientFactory class for creating clients to interact with Things over different protocols."""
+
import ssl
import threading
import warnings
-from typing import Any
+from typing import Any, cast
import aiomqtt
import httpx
@@ -38,20 +40,27 @@
class ClientFactory:
"""
A factory class for creating clients to interact with `Thing`s over different protocols.
+
This object is not meant to be instantiated, but rather provides class methods for creating clients.
+ [Documentation](https://docs.hololinked.dev/beginners-guide/articles/object-proxy/#__tabbed_1_1)
+
+ Example:
+
```python
- zmq_client = ClientFactory.zmq(server_id="server1", thing_id="thing1", access_point="ipc:///tmp/thing1")
+ from hololinked.client import ClientFactory
+
+ zmq_client = ClientFactory.zmq(server_id="server1", thing_id="thing1", access_point="IPC")
http_client = ClientFactory.http(url="https://example.com/thing-description")
- mqtt_client = ClientFactory.mqtt(hostname="broker.example.com", port=8883, thing_id="thing1", username="user", password="pass")
+ mqtt_client = ClientFactory.mqtt(hostname="broker.example.com", port=8883, thing_id="thing1")
```
"""
__wrapper_assignments__ = ("__name__", "__qualname__", "__doc__")
+ """Dunder attributes to be assigned onto dynamically populated properties, actions and events."""
- @classmethod
+ @staticmethod
def zmq(
- self,
server_id: str,
thing_id: str,
access_point: str = ZMQ_TRANSPORTS.IPC,
@@ -60,6 +69,46 @@ def zmq(
"""
Create a ZMQ client for the specified server and thing.
+ [Documentation](https://docs.hololinked.dev/beginners-guide/articles/object-proxy/#__tabbed_1_1)
+
+ Provide the server ID and thing ID used to start the `Thing` instance, apart from the protocol or access point
+ of the ZMQ transport mechanism. If a shorthand method like `Thing.run_with_zmq_server()` was used without
+ specifying the server ID, the thing ID is the server ID by default.
+
+ ```python
+ thing = Thing("thing1")
+ thing.run_with_zmq_server(access_point="IPC", forked=True)
+
+ client = ClientFactory.zmq(
+ server_id="thing1",
+ thing_id="thing1",
+ access_point="IPC"
+ )
+ ```
+
+ ```python
+ thing = Thing("thing1")
+ thing.run_with_zmq_server(access_point="IPC", server_id="thing1-server", forked=True)
+
+ client = ClientFactory.zmq(
+ server_id="thing1-server",
+ thing_id="thing1",
+ access_point="IPC"
+ )
+ ```
+
+ ```python
+ thing = Thing("thing1")
+ server = ZMQServer(thing, id="thing1-server", access_point="tcp://*:3569")
+ server.run(forked=True)
+
+ client = ClientFactory.zmq(
+ server_id="thing1-server",
+ thing_id="thing1",
+ access_point="tcp://localhost:3569"
+ )
+ ```
+
Parameters
----------
server_id: str
@@ -74,9 +123,10 @@ def zmq(
- `logger`: `structlog.stdlib.BoundLogger`, optional.
A custom logger instance to use for logging
- `ignore_TD_errors`: `bool`, default `False`.
- Whether to ignore errors while fetching the Thing Description (TD)
+ Whether to ignore errors while fetching the Thing Description (TD). Supported only for this runtime,
+ not against a 3rd party Web of Things (WoT) implementation.
- `skip_interaction_affordances`: `list[str]`, default `[]`.
- A list of interaction names to skip (property, action or event names)
+ A list of interaction names to skip (any of property, action or event names)
- `invokation_timeout`: `float`, optional, default `5.0`.
The timeout for invokation requests (in seconds)
- `execution_timeout`: `float`, optional, default `5.0`.
@@ -85,7 +135,7 @@ def zmq(
Returns
-------
ObjectProxy
- An ObjectProxy instance representing the remote Thing
+ An `ObjectProxy` instance representing the remote `Thing` with ZMQ protocol.
"""
id = kwargs.get("id", f"{server_id}|{thing_id}|{access_point}|{uuid_hex()}")
@@ -136,7 +186,7 @@ def zmq(
# add properties
for name in TD.get("properties", []):
- affordance = PropertyAffordance.from_TD(name, TD)
+ affordance = cast(PropertyAffordance, PropertyAffordance.from_TD(name, TD))
consumed_property = ZMQProperty(
resource=affordance,
sync_client=sync_zmq_client,
@@ -146,17 +196,17 @@ def zmq(
execution_timeout=execution_timeout,
logger=logger,
)
- self.add_property(object_proxy, consumed_property)
+ ClientFactory.add_property(object_proxy, consumed_property)
if hasattr(affordance, "observable") and affordance.observable:
consumed_observable = ZMQEvent(
resource=affordance,
owner_inst=object_proxy,
logger=logger,
)
- self.add_event(object_proxy, consumed_observable)
+ ClientFactory.add_event(object_proxy, consumed_observable)
# add actions
for action in TD.get("actions", []):
- affordance = ActionAffordance.from_TD(action, TD)
+ affordance = cast(ActionAffordance, ActionAffordance.from_TD(action, TD))
consumed_action = ZMQAction(
resource=affordance,
sync_client=sync_zmq_client,
@@ -166,16 +216,16 @@ def zmq(
execution_timeout=execution_timeout,
logger=logger,
)
- self.add_action(object_proxy, consumed_action)
+ ClientFactory.add_action(object_proxy, consumed_action)
# add events
for event in TD.get("events", []):
- affordance = EventAffordance.from_TD(event, TD)
+ affordance = cast(EventAffordance, EventAffordance.from_TD(event, TD))
consumed_event = ZMQEvent(
resource=affordance,
owner_inst=object_proxy,
logger=logger,
)
- self.add_event(object_proxy, consumed_event)
+ ClientFactory.add_event(object_proxy, consumed_event)
# add top level form handlers (for ZMQ even if said form exists or not)
for opname, ophandler in zip(
["_get_properties", "_set_properties"],
@@ -195,11 +245,31 @@ def zmq(
)
return object_proxy
- @classmethod
+ @staticmethod
def http(self, url: str, **kwargs) -> ObjectProxy:
"""
Create a HTTP client using the Thing Description (TD) available at the specified URL.
+ [Documentation](https://docs.hololinked.dev/beginners-guide/articles/object-proxy/#__tabbed_1_1)
+
+ ```python
+ client = ClientFactory.http(
+ url="https://example.com/thing-description",
+ username="user",
+ password="pass"
+ )
+ ```
+
+ ```python
+ thing = Thing("thing1")
+ thing.run_with_http_server(port=8080, forked=True)
+
+ client = ClientFactory.http(
+ url="http://localhost:8080/thing1/resources/wot-td",
+ ignore_TD_errors=True
+ )
+ ```
+
Parameters
----------
url: str
@@ -212,17 +282,21 @@ def http(self, url: str, **kwargs) -> ObjectProxy:
- `ignore_TD_errors`: `bool`, default `False`.
Whether to ignore errors while fetching the Thing Description (TD)
- `skip_interaction_affordances`: `list[str]`, default `[]`.
- A list of interaction names to skip (property, action or event names)
+ A list of interaction names to skip (any of property, action or event names)
- `invokation_timeout`: `float`, optional, default `5.0`.
- The timeout for operation invokation (in seconds)
+ The timeout for operation invokation (in seconds), for example when invoking an action or
+ reading/writing a property. This is a server-side timeout.
- `execution_timeout`: `float`, optional, default `5.0`.
- The timeout for operation execution (in seconds)
+ The timeout for operation execution (in seconds) - the time it waits for an operation to be completed
+ after invokation before timing out. This is a server-side timeout.
- `connect_timeout`: `float`, optional, default `10.0`.
- The timeout for establishing a HTTP connection (in seconds)
+ The timeout for establishing a HTTP connection (in seconds). This is a client-side timeout.
- `request_timeout`: `float`, optional, default `60.0`.
- The timeout for completing a HTTP request (in seconds)
- - `security`: `BasicSecurity` | `APIKeySecurity`, optional.
- The security scheme to use for authentication
+ The timeout for completing a HTTP request (in seconds) after the connection is established.
+ This is a client-side timeout.
+ - `security`: `BasicSecurity` | `APIKeySecurity` | `OAuthDirectAccessGrant`, optional.
+ The security scheme to use for authentication. Not all schemes are supported for all protocols.
+ See [here](https://docs.hololinked.dev/introduction/use-cases/).
- `username`: `str`, optional.
The username for HTTP Basic Authentication, shortcut for creating a `BasicSecurity` instance
- `password`: `str`, optional.
@@ -231,9 +305,8 @@ def http(self, url: str, **kwargs) -> ObjectProxy:
Returns
-------
ObjectProxy
- An ObjectProxy instance representing the remote Thing
+ An `ObjectProxy` instance representing the remote Thing with HTTP protocol.
"""
-
# config
skip_interaction_affordances = kwargs.get("skip_interaction_affordances", [])
invokation_timeout = kwargs.get("invokation_timeout", 5.0)
@@ -309,7 +382,7 @@ def http(self, url: str, **kwargs) -> ObjectProxy:
object_proxy = ObjectProxy(id, td=TD, logger=logger, security=security, **kwargs)
for name in TD.get("properties", []):
- affordance = PropertyAffordance.from_TD(name, TD)
+ affordance = cast(PropertyAffordance, PropertyAffordance.from_TD(name, TD))
consumed_property = HTTPProperty(
resource=affordance,
sync_client=req_rep_sync_client,
@@ -319,7 +392,7 @@ def http(self, url: str, **kwargs) -> ObjectProxy:
owner_inst=object_proxy,
logger=logger,
)
- self.add_property(object_proxy, consumed_property)
+ ClientFactory.add_property(object_proxy, consumed_property)
if affordance.observable:
consumed_event = HTTPEvent(
resource=affordance,
@@ -330,9 +403,9 @@ def http(self, url: str, **kwargs) -> ObjectProxy:
owner_inst=object_proxy,
logger=logger,
)
- self.add_event(object_proxy, consumed_event)
+ ClientFactory.add_event(object_proxy, consumed_event)
for action in TD.get("actions", []):
- affordance = ActionAffordance.from_TD(action, TD)
+ affordance = cast(ActionAffordance, ActionAffordance.from_TD(action, TD))
consumed_action = HTTPAction(
resource=affordance,
sync_client=req_rep_sync_client,
@@ -342,9 +415,9 @@ def http(self, url: str, **kwargs) -> ObjectProxy:
owner_inst=object_proxy,
logger=logger,
)
- self.add_action(object_proxy, consumed_action)
+ ClientFactory.add_action(object_proxy, consumed_action)
for event in TD.get("events", []):
- affordance = EventAffordance.from_TD(event, TD)
+ affordance = cast(EventAffordance, EventAffordance.from_TD(event, TD))
consumed_event = HTTPEvent(
resource=affordance,
sync_client=sse_sync_client,
@@ -354,47 +427,60 @@ def http(self, url: str, **kwargs) -> ObjectProxy:
owner_inst=object_proxy,
logger=logger,
)
- self.add_event(object_proxy, consumed_event)
+ ClientFactory.add_event(object_proxy, consumed_event)
return object_proxy
- @classmethod
+ @staticmethod
def mqtt(
- self,
hostname: str,
port: int,
thing_id: str,
protocol_version: MQTTProtocolVersion = MQTTProtocolVersion.MQTTv5,
qos: int = 1,
- username: str = None,
- password: str = None,
- ssl_context: ssl.SSLContext = None,
+ username: str | None = None,
+ password: str | None = None,
+ ssl_context: ssl.SSLContext | None = None,
**kwargs,
) -> ObjectProxy:
"""
- Create an MQTT client for the specified broker.
+ Create an MQTT client against the specified broker for the specified thing ID.
+
+ [Documentation](https://docs.hololinked.dev/beginners-guide/articles/object-proxy/#__tabbed_1_1)
Parameters
----------
hostname: str
- The hostname of the MQTT broker
+ The hostname of the MQTT broker.
port: int
- The port of the MQTT broker
+ The port of the MQTT broker.
thing_id: str
- The ID of the thing to interact with
+ The ID of the thing to consume events from.
protocol_version: paho.mqtt.client.MQTTProtocolVersion
- The MQTT protocol version (e.g., MQTTv5)
+ The MQTT protocol version (e.g., MQTTv5).
qos: int
- The Quality of Service level for MQTT messages (0, 1, or 2)
+ The Quality of Service level for MQTT messages (0, 1, or 2).
username: str, optional
- The username for authenticating with MQTT broker
+ The username for authenticating with MQTT broker.
password: str, optional
- The password for authenticating with MQTT broker
+ The password for authenticating with MQTT broker.
+ ssl_context: ssl.SSLContext, optional
+ Secure sockets layer for encrypted communication with the MQTT broker.
kwargs:
Additional configuration options:
- `logger`: `structlog.stdlib.BoundLogger`, optional.
A custom logger instance to use for logging
+
+ Returns
+ -------
+ ObjectProxy
+ An `ObjectProxy` instance representing the remote `Thing` with MQTT protocol.
+
+ Raises
+ ------
+ TimeoutError
+ If the Thing Description (TD) could not be fetched within the timeout period.
"""
id = kwargs.get("id", f"mqtt-client|{hostname}:{port}|{uuid_hex()}")
logger = kwargs.get("logger", structlog.get_logger()).bind(
@@ -417,7 +503,7 @@ def fetch_td(client: PahoMQTTClient, userdata, message: MQTTMessage) -> None:
def on_connect(
client: PahoMQTTClient,
userdata: Any,
- flags: Any,
+ connect_flags: Any,
reason_code: list,
properties: dict[str, Any],
) -> None: # TODO fix signature
@@ -436,8 +522,8 @@ def on_connect(
sync_client.tls_set_context(ssl_context)
elif kwargs.get("ca_certs", None):
sync_client.tls_set(ca_certs=kwargs.get("ca_certs", None))
- sync_client.on_connect = on_connect
- sync_client.on_message = fetch_td
+ setattr(sync_client, "on_connect", on_connect)
+ setattr(sync_client, "on_message", fetch_td)
sync_client.connect(hostname, port)
sync_client.loop_start()
@@ -464,7 +550,7 @@ def on_connect(
object_proxy = ObjectProxy(id=id, logger=logger, td=TD)
for name in TD.get("properties", []):
- affordance = PropertyAffordance.from_TD(name, TD)
+ affordance = cast(PropertyAffordance, PropertyAffordance.from_TD(name, TD))
consumed_property = MQTTConsumer(
sync_client=sync_client,
async_client=async_client,
@@ -473,9 +559,9 @@ def on_connect(
logger=logger,
owner_inst=object_proxy,
)
- self.add_event(object_proxy, consumed_property)
+ ClientFactory.add_event(object_proxy, consumed_property)
for name in TD.get("events", []):
- affordance = EventAffordance.from_TD(name, TD)
+ affordance = cast(EventAffordance, EventAffordance.from_TD(name, TD))
consumed_event = MQTTConsumer(
sync_client=sync_client,
async_client=async_client,
@@ -484,13 +570,13 @@ def on_connect(
logger=logger,
owner_inst=object_proxy,
)
- self.add_event(object_proxy, consumed_event)
+ ClientFactory.add_event(object_proxy, consumed_event)
return object_proxy
- @classmethod
- def add_action(self, client, action: ConsumedThingAction) -> None:
- """add action to client instance"""
+ @staticmethod
+ def add_action(client, action: ConsumedThingAction) -> None:
+ """Add action to the client instance."""
setattr(action, "__name__", action.resource.name)
setattr(action, "__qualname__", f"{client.__class__.__name__}.{action.resource.name}")
setattr(
@@ -500,9 +586,9 @@ def add_action(self, client, action: ConsumedThingAction) -> None:
)
setattr(client, action.resource.name, action)
- @classmethod
- def add_property(self, client, property: ConsumedThingProperty) -> None:
- """add property to client instance"""
+ @staticmethod
+ def add_property(client, property: ConsumedThingProperty) -> None:
+ """Add property to the client instance."""
setattr(property, "__name__", property.resource.name)
setattr(property, "__qualname__", f"{client.__class__.__name__}.{property.resource.name}")
setattr(
@@ -513,9 +599,9 @@ def add_property(self, client, property: ConsumedThingProperty) -> None:
)
setattr(client, property.resource.name, property)
- @classmethod
- def add_event(cls, client, event: ConsumedThingEvent) -> None:
- """add event to client instance"""
+ @staticmethod
+ def add_event(client, event: ConsumedThingEvent) -> None:
+ """Add event to the client instance."""
setattr(event, "__name__", event.resource.name)
setattr(event, "__qualname__", f"{client.__class__.__name__}.{event.resource.name}")
setattr(
diff --git a/hololinked/client/http/consumed_interactions.py b/hololinked/client/http/consumed_interactions.py
index 426aa769..db55f146 100644
--- a/hololinked/client/http/consumed_interactions.py
+++ b/hololinked/client/http/consumed_interactions.py
@@ -286,7 +286,7 @@ class HTTPProperty(ConsumedThingProperty, HTTPConsumedAffordanceMixin):
def __init__(
self,
- resource: ActionAffordance,
+ resource: PropertyAffordance,
sync_client: httpx.Client = None,
async_client: httpx.AsyncClient = None,
invokation_timeout: int = 5,
@@ -578,7 +578,7 @@ async def aiter_lines_interruptible(self, resp: httpx.Response, stop: asyncio.Ev
return
def iter_lines_interruptible(self, resp: httpx.Response, stop: threading.Event) -> Iterator[str]:
- """iterate lines from an httpx streaming response, but stop immediately when `stop` is set"""
+ """Iterate lines from an httpx streaming response, but stop immediately when `stop` is set"""
it = resp.iter_lines()
# Using a dedicated stream scope inside the thread
while not stop.is_set():
@@ -591,7 +591,7 @@ def iter_lines_interruptible(self, resp: httpx.Response, stop: threading.Event)
yield next_line
def decode_chunk(self, line: str, event_data: "SSE") -> None:
- """decode a single line of an SSE stream into the given SSE event_data object"""
+ """Decode a single line of an SSE stream into the given SSE event_data object"""
if line is None or line.startswith(":"): # comment/heartbeat
return
diff --git a/hololinked/client/zmq/consumed_interactions.py b/hololinked/client/zmq/consumed_interactions.py
index b5acfe64..cf1d32a2 100644
--- a/hololinked/client/zmq/consumed_interactions.py
+++ b/hololinked/client/zmq/consumed_interactions.py
@@ -90,7 +90,7 @@ def __init__(
def get_last_return_value(self, response: ResponseMessage, raise_exception: bool = False) -> Any:
"""
- cached return value of the last operation performed.
+ Cached return value of the last operation performed.
Parameters
----------
@@ -113,7 +113,7 @@ def get_last_return_value(self, response: ResponseMessage, raise_exception: bool
@property
def last_zmq_response(self) -> ResponseMessage:
- """cache of last message received for this property"""
+ """Cache of last message received for this property"""
return self._last_zmq_response
def read_reply(self, message_id: str, timeout: int = None) -> Any:
@@ -420,7 +420,7 @@ class ZMQEvent(ConsumedThingEvent, ZMQConsumedAffordanceMixin):
def __init__(
self,
- resource: EventAffordance,
+ resource: EventAffordance | PropertyAffordance,
logger: structlog.stdlib.BoundLogger,
owner_inst: Any,
**kwargs,
diff --git a/pyproject.toml b/pyproject.toml
index 82a1a7d6..bc9d61a9 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -74,6 +74,7 @@ dev = [
"pre-commit>=4.5.0",
"mypy>=1.19.1",
"ty>=0.0.24",
+ "pdoc>=16.0.0",
]
test = [
"requests==2.32.3",
diff --git a/uv.lock b/uv.lock
index 24dc5dc3..0cadc895 100644
--- a/uv.lock
+++ b/uv.lock
@@ -760,6 +760,7 @@ dev = [
{ name = "mypy" },
{ name = "numpy" },
{ name = "pandas" },
+ { name = "pdoc" },
{ name = "pip" },
{ name = "pre-commit" },
{ name = "ruff" },
@@ -810,6 +811,7 @@ dev = [
{ name = "mypy", specifier = ">=1.19.1" },
{ name = "numpy", specifier = ">=2.0.0" },
{ name = "pandas", specifier = "==2.2.3" },
+ { name = "pdoc", specifier = ">=16.0.0" },
{ name = "pip", specifier = ">=25.2" },
{ name = "pre-commit", specifier = ">=4.5.0" },
{ name = "ruff", specifier = ">=0.12.10" },
@@ -1341,6 +1343,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321 },
]
+[[package]]
+name = "markdown2"
+version = "2.5.5"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/e4/ae/07d4a5fcaa5509221287d289323d75ac8eda5a5a4ac9de2accf7bbcc2b88/markdown2-2.5.5.tar.gz", hash = "sha256:001547e68f6e7fcf0f1cb83f7e82f48aa7d48b2c6a321f0cd20a853a8a2d1664", size = 157249 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/43/af/4b3891eb0a49d6cfd5cbf3e9bf514c943afc2b0f13e2c57cc57cd88ecc21/markdown2-2.5.5-py3-none-any.whl", hash = "sha256:be798587e09d1f52d2e4d96a649c4b82a778c75f9929aad52a2c95747fa26941", size = 56250 },
+]
+
[[package]]
name = "markupsafe"
version = "3.0.3"
@@ -1799,6 +1810,21 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ef/3c/2c197d226f9ea224a9ab8d197933f9da0ae0aac5b6e0f884e2b8d9c8e9f7/pathspec-1.0.4-py3-none-any.whl", hash = "sha256:fb6ae2fd4e7c921a165808a552060e722767cfa526f99ca5156ed2ce45a5c723", size = 55206 },
]
+[[package]]
+name = "pdoc"
+version = "16.0.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "jinja2" },
+ { name = "markdown2" },
+ { name = "markupsafe" },
+ { name = "pygments" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/ac/fe/ab3f34a5fb08c6b698439a2c2643caf8fef0d61a86dd3fdcd5501c670ab8/pdoc-16.0.0.tar.gz", hash = "sha256:fdadc40cc717ec53919e3cd720390d4e3bcd40405cb51c4918c119447f913514", size = 111890 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/16/a1/56a17b7f9e18c2bb8df73f3833345d97083b344708b97bab148fdd7e0b82/pdoc-16.0.0-py3-none-any.whl", hash = "sha256:070b51de2743b9b1a4e0ab193a06c9e6c12cf4151cf9137656eebb16e8556628", size = 100014 },
+]
+
[[package]]
name = "pexpect"
version = "4.9.0"
From 54505f15b424309b1d440857574510c9a5bed6ac Mon Sep 17 00:00:00 2001
From: Vignesh Venkatasubramanian Vaidyanathan
<62492557+VigneshVSV@users.noreply.github.com>
Date: Sat, 28 Mar 2026 10:33:09 +0100
Subject: [PATCH 04/15] commit
---
hololinked/client/proxy.py | 4 ++--
hololinked/core/thing.py | 8 +++++---
2 files changed, 7 insertions(+), 5 deletions(-)
diff --git a/hololinked/client/proxy.py b/hololinked/client/proxy.py
index 226207c6..98929a06 100644
--- a/hololinked/client/proxy.py
+++ b/hololinked/client/proxy.py
@@ -4,8 +4,8 @@
import structlog
-from .abstractions import ConsumedThingAction, ConsumedThingEvent, ConsumedThingProperty
-from .security import APIKeySecurity, BasicSecurity # noqa: F401
+from hololinked.client.abstractions import ConsumedThingAction, ConsumedThingEvent, ConsumedThingProperty
+from hololinked.client.security import APIKeySecurity, BasicSecurity # noqa: F401
class ObjectProxy:
diff --git a/hololinked/core/thing.py b/hololinked/core/thing.py
index 08c03d85..6c9c0055 100644
--- a/hololinked/core/thing.py
+++ b/hololinked/core/thing.py
@@ -185,7 +185,7 @@ def __setattr__(self, __name: str, __value: Any) -> None:
@property
def sub_things(self) -> dict[str, "Thing"]:
- """other `Thing`s' that are composed within this `Thing`."""
+ """Other `Thing`s' that are composed within this `Thing`."""
things = dict()
for name, subthing in inspect._getmembers(
self,
@@ -202,7 +202,7 @@ def sub_things(self) -> dict[str, "Thing"]:
@action()
def get_thing_model(self, ignore_errors: bool = False, skip_names: list[str] = []):
"""
- generate the [Thing Model](https://www.w3.org/TR/wot-thing-description11/#introduction-tm) of the object.
+ Generate the [Thing Model](https://www.w3.org/TR/wot-thing-description11/#introduction-tm) of the object.
The model is a JSON that describes the object's properties, actions, events and their metadata, without the
protocol information. The model can be used by a client to understand the object's capabilities.
@@ -268,6 +268,8 @@ def run_with_zmq_server(
- context: `zmq.asyncio.Context`, optional,
ZMQ context object to be used for creating sockets. If not supplied, a global shared context is used.
For INPROC, either do not supply context or use the same context across all threads.
+ - server_id: `str`, optional,
+ an identifier for the server
"""
from ..server.server import parse_params, run
@@ -396,7 +398,7 @@ def exit(self) -> None:
@action()
def ping(self) -> None:
"""
- ping to see if it is alive. Successful when action succeeds with no return value and
+ Ping to see if it is alive. Successful when action succeeds with no return value and
no timeout or exception raised on the client side.
"""
pass
From 92c84e558c4c80a4a8df285b978128f2d22221eb Mon Sep 17 00:00:00 2001
From: Vignesh Venkatasubramanian Vaidyanathan
<62492557+VigneshVSV@users.noreply.github.com>
Date: Sat, 11 Apr 2026 11:43:40 +0200
Subject: [PATCH 05/15] cleanup abstractions file
---
hololinked/client/abstractions.py | 187 ++++++++++++++++--------------
hololinked/client/exceptions.py | 50 +++++++-
hololinked/client/factory.py | 23 ++--
hololinked/client/security.py | 13 +++
pyproject.toml | 1 +
5 files changed, 172 insertions(+), 102 deletions(-)
diff --git a/hololinked/client/abstractions.py b/hololinked/client/abstractions.py
index 60c943ad..e99facd0 100644
--- a/hololinked/client/abstractions.py
+++ b/hololinked/client/abstractions.py
@@ -1,4 +1,8 @@
"""
+Abstractions of property, action and events for a client.
+
+Inspired by wotpy repository, needs to be wrapped with descriptors.
+
MIT License
Copyright (c) 2018 CTIC Centro Tecnologico
@@ -21,10 +25,8 @@
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
"""
-# copied from wotpy repository
import asyncio
-import builtins
import threading
from dataclasses import dataclass
@@ -32,15 +34,14 @@
import structlog
-from ..constants import Operations
-from ..td import ActionAffordance, EventAffordance, PropertyAffordance
-from ..td.forms import Form
-from ..utils import get_current_async_loop
+from hololinked.constants import Operations
+from hololinked.td import ActionAffordance, EventAffordance, PropertyAffordance
+from hololinked.td.forms import Form
+from hololinked.utils import get_current_async_loop
-class ConsumedThingAction:
+class ConsumedThingAction: # noqa: D101 # Dont add class doc otherwise it will conflict with __doc__ in slots
# Client side action call abstraction. Subclasss from here to implement protocol specific action call.
- # Dont add class doc otherwise __doc__ in slots will conflict with class variable
def __init__(
self,
@@ -49,11 +50,13 @@ def __init__(
logger: structlog.stdlib.BoundLogger,
) -> None:
"""
+ Initialize a consumed thing action.
+
Parameters
----------
resource: ActionAffordance
dataclass TD fragment representing the action (must have forms).
- owner_inst: Any
+ owner_inst: ObjectProxy
instance of the owning consumed Thing or `ObjectProxy`
logger: structlog.stdlib.BoundLogger
logger instance
@@ -66,18 +69,18 @@ def __init__(
self.schema_validator = None # schema_validator
def get_last_return_value(self, raise_exception: bool = False) -> Any:
- """retrieve return value of the last call to the action"""
+ """Retrieve return value of the last call to the action."""
raise NotImplementedError("implement get_last_return_value per protocol")
last_return_value = property(
fget=get_last_return_value,
doc="cached return value of the last call to the method",
)
- """cached return value of the last call to the method"""
+ """cached return value of the last call to the method."""
def __call__(self, *args, **kwargs) -> Any:
"""
- Invoke action/method on server
+ Invoke action/method on server.
Parameters
----------
@@ -95,7 +98,9 @@ def __call__(self, *args, **kwargs) -> Any:
async def async_call(self, *args, **kwargs) -> Any:
"""
- async invoke action on server - asynchronous at the network level, may not necessarily be at the server level.
+ Async invoke action/method on server.
+
+ Asynchronous at the network level, may not necessarily be at the server level.
Parameters
----------
@@ -113,8 +118,9 @@ async def async_call(self, *args, **kwargs) -> Any:
def oneway(self, *args, **kwargs) -> None:
"""
- Only invokes the action on the server and does not wait for reply,
- neither does the server reply to this invokation.
+ Only invokes the action on the server and does not wait for reply.
+
+ Neither does the server (need to) reply to this invokation as any responses are not processed.
Parameters
----------
@@ -127,8 +133,9 @@ def oneway(self, *args, **kwargs) -> None:
def noblock(self, *args, **kwargs) -> str:
"""
- Invoke the action and collect the reply later. A message ID must be returned by the server to identify the
- invokation.
+ Invoke the action and collect the reply later.
+
+ A message ID must be returned by the server to identify the invokation.
Parameters
----------
@@ -162,21 +169,22 @@ def read_reply(self, message_id: str, timeout: float | int | None = None) -> Any
"""
raise NotImplementedError("implement action read_reply per protocol")
- def __hash__(self):
+ def __hash__(self): # noqa: D105
return hash(self.resource.name)
- def __eq__(self, other):
+ def __eq__(self, other): # noqa: D105
if not isinstance(other, ConsumedThingAction):
return False
return self.resource.name == other.resource.name
-class ConsumedThingProperty:
+class ConsumedThingProperty: # noqa: D101 # Dont add class doc otherwise it will conflict with __doc__ in slots
# property get set abstraction
- # Dont add doc otherwise __doc__ in slots will conflict with class variable
def __init__(self, resource: PropertyAffordance, owner_inst: Any, logger: structlog.stdlib.BoundLogger) -> None:
"""
+ Initialize a consumed thing property.
+
Parameters
----------
resource: PropertyAffordance
@@ -194,7 +202,11 @@ def __init__(self, resource: PropertyAffordance, owner_inst: Any, logger: struct
@property # i.e. cannot have setter
def last_read_value(self) -> Any:
- """cache of last read value"""
+ """
+ Cache of last read value, updated on each get/read call.
+
+ Does not necessarily reflect the current value on the server.
+ """
raise NotImplementedError("implement last_read_value per protocol")
def set(self, value: Any) -> None:
@@ -221,8 +233,9 @@ def get(self) -> Any:
async def async_set(self, value: Any) -> None:
"""
- Async set or write property value - asynchronous at the network level,
- may not necessarily be at the server level.
+ Async set or write property value.
+
+ Asynchronous at the network level, may not necessarily be at the server level.
Parameters
----------
@@ -233,8 +246,9 @@ async def async_set(self, value: Any) -> None:
async def async_get(self) -> Any:
"""
- Async get or read property value - asynchronous at the network level,
- may not necessarily be at the server level.
+ Async get or read property value.
+
+ Asynchronous at the network level, may not necessarily be at the server level.
Returns
-------
@@ -245,8 +259,10 @@ async def async_get(self) -> Any:
def noblock_get(self) -> str:
"""
- Get or read property value without blocking, i.e. make a request and collect it later
- and the method returns immediately. Server must return a message ID to identify the request.
+ Get or read property value without blocking.
+
+ Make a request and collect it later and the method returns immediately. Server must return a message ID to
+ identify the request.
Returns
-------
@@ -257,8 +273,10 @@ def noblock_get(self) -> str:
def noblock_set(self, value: Any) -> str:
"""
- Set or write property value without blocking, i.e. make a request and collect it later
- and the method returns immediately. Server must return a message ID to identify the request.
+ Set or write property value without blocking.
+
+ Make a request and collect it later and the method returns immediately. Server must return a message ID to
+ identify the request.
Parameters
----------
@@ -274,8 +292,9 @@ def noblock_set(self, value: Any) -> str:
def oneway_set(self, value: Any) -> None:
"""
- Set property value without waiting for acknowledgement. The server also does not send any reply.
- There is no guarantee that the property value was set.
+ Set property value without waiting for acknowledgement.
+
+ The server also does not (need to) send any reply. There is no guarantee that the property value was set.
Parameters
----------
@@ -286,7 +305,7 @@ def oneway_set(self, value: Any) -> None:
def observe(self, *callbacks: Callable) -> None:
"""
- Observe property value changes
+ Observe property value changes.
Parameters
----------
@@ -297,7 +316,7 @@ def observe(self, *callbacks: Callable) -> None:
raise NotImplementedError("implement property observe per protocol")
def unobserve(self) -> None:
- """Stop observing property value changes"""
+ """Stop observing property value changes."""
# looks like this will be unused, observe property is done via ConsumedThingEvent
raise NotImplementedError("implement property unobserve per protocol")
@@ -320,9 +339,8 @@ def read_reply(self, message_id: str, timeout: float | int | None = None) -> Any
raise NotImplementedError("implement property read_reply per protocol")
-class ConsumedThingEvent:
+class ConsumedThingEvent: # noqa: D101 # Dont add class doc otherwise it will conflict with __doc__ in slots
# event subscription
- # Dont add class doc otherwise __doc__ in slots will conflict with class variable
def __init__(
self,
@@ -331,6 +349,8 @@ def __init__(
owner_inst: Any,
) -> None:
"""
+ Initialize a consumed thing event.
+
Parameters
----------
resource: EventAffordance
@@ -358,7 +378,7 @@ def subscribe(
# create_new_connection: bool = False,
) -> None:
"""
- subscribe to the event
+ Subscribe to the event.
Parameters
----------
@@ -371,6 +391,11 @@ def subscribe(
- threading - if `True`, each callback is called in a separate thread, if `False` they are called sequentially.
deserialize: bool
if `False`, event payload is passed to the callbacks as raw bytes, if `True` it is deserialized
+
+ Raises
+ ------
+ ValueError
+ if no form is found for the event subscription
"""
op = Operations.observeproperty if isinstance(self.resource, PropertyAffordance) else Operations.subscribeevent
form = self.resource.retrieve_form(op, None)
@@ -387,16 +412,17 @@ def subscribe(
_thread = threading.Thread(target=self.listen, args=(form, callbacks, concurrent, deserialize), daemon=True)
_thread.start()
- def unsubscribe(self):
- """unsubscribe from the event"""
+ def unsubscribe(self) -> None:
+ """Unsubscribe from the event."""
self._subscribed.clear()
# self._sync_callbacks.clear()
# self._async_callbacks.clear()
- def listen(self, form: Form, callbacks: list[Callable], concurrent: bool = True, deserialize: bool = True):
+ def listen(self, form: Form, callbacks: list[Callable], concurrent: bool = True, deserialize: bool = True) -> None:
"""
- Listen to events and call the callbacks. This method needs to be invoked by the `subscribe()` method
- in threaded mode. Use `async_listen()` for asyncio mode.
+ Listen to events and call the callbacks in threaded mode.
+
+ This method needs to be invoked by the `subscribe()` method. Use `async_listen()` for asyncio mode.
Parameters
----------
@@ -412,11 +438,16 @@ def listen(self, form: Form, callbacks: list[Callable], concurrent: bool = True,
raise NotImplementedError("implement listen per protocol")
async def async_listen(
- self, form: Form, callbacks: list[Callable], concurrent: bool = True, deserialize: bool = True
- ):
+ self,
+ form: Form,
+ callbacks: list[Callable],
+ concurrent: bool = True,
+ deserialize: bool = True,
+ ) -> None:
"""
- Listen to events and call the callbacks. This method needs to be invoked by the `subscribe()` method
- in asyncio mode. Use `listen()` for threaded mode.
+ Listen to events and call the callbacks.
+
+ This method needs to be invoked by the `subscribe()` method in asyncio mode. Use `listen()` for threaded mode.
Parameters
----------
@@ -433,7 +464,7 @@ async def async_listen(
def schedule_callbacks(self, callbacks: list[Callable], event_data: Any, concurrent: bool = False) -> None:
"""
- schedule the callbacks to be called with the event data
+ Schedule the callbacks to be called with the event data.
Parameters
----------
@@ -442,7 +473,7 @@ def schedule_callbacks(self, callbacks: list[Callable], event_data: Any, concurr
event_data: Any
event data to pass to the callbacks
concurrent: bool
- whether to run each callback in a separate thread
+ whether to run each callback in a separate thread concurrently
"""
for cb in callbacks:
try:
@@ -456,7 +487,7 @@ def schedule_callbacks(self, callbacks: list[Callable], event_data: Any, concurr
async def async_schedule_callbacks(self, callbacks, event_data: Any, concurrent: bool = False) -> None:
"""
- async schedule the callbacks to be called with the event data
+ Async schedule the callbacks to be called with the event data.
Parameters
----------
@@ -465,7 +496,7 @@ async def async_schedule_callbacks(self, callbacks, event_data: Any, concurrent:
event_data: Any
event data to pass to the callbacks
concurrent: bool
- whether to run each callback in a separate thread
+ whether to run each callback in a separate asyncio task concurrently
"""
loop = get_current_async_loop()
for cb in callbacks:
@@ -485,55 +516,24 @@ async def async_schedule_callbacks(self, callbacks, event_data: Any, concurrent:
def add_callbacks(self, callbacks: list[Callable] | Callable, asynch: bool = False) -> None:
"""
- add callbacks to the event
+ Add callbacks to the event.
Parameters
----------
*callbacks: list[Callable] | Callable
callback or list of callbacks to add
"""
+ # for logic, see tag v0.3.2
raise NotImplementedError(
- "logic error - cannot add callbacks to reuse event subscription. Unsubscribe and resubscribe with new callbacks"
+ "cannot add callbacks currrently to reuse event subscription."
+ + " Unsubscribe and resubscribe with new callbacks"
)
- # for logic, see tag v0.3.2
-
-
-def raise_local_exception(error_message: dict[str, Any]) -> None:
- """
- raises an exception on client side using an exception from server, using a mapping based on exception type
- (currently only python built-in exceptions supported). If the exception type is not found, a generic `Exception` is raised.
- Server traceback is added to the exception notes. Client creates its own traceback which is not usually the cause of the error.
-
- Parameters
- ----------
- error_message: dict[str, Any]
- exception dictionary made by server with following keys - `type`, `message`, `traceback`, `notes`
- """
- if isinstance(error_message, Exception):
- raise error_message from None
- elif isinstance(error_message, dict) and "exception" in error_message.keys():
- error_message = error_message["exception"]
- message = error_message["message"]
- exc = getattr(builtins, error_message["type"], None)
- if exc is None:
- ex = Exception(message)
- else:
- ex = exc(message)
- error_message["traceback"][0] = f"Server {error_message['traceback'][0]}"
- ex.__notes__ = error_message["traceback"][0:-1]
- raise ex from None
- elif isinstance(error_message, str) and error_message in ["invokation", "execution"]:
- raise TimeoutError(
- f"{error_message[0].upper()}{error_message[1:]} timeout occured. "
- + "Server did not respond within specified timeout"
- ) from None
- raise RuntimeError("unknown error occurred on server side") from None
@dataclass
class SSE:
"""
- dataclass representing a server sent event and the argument used to invoke event callbacks.
+ dataclass representing a server sent event and the data used to invoke event callbacks.
Attributes
----------
@@ -549,18 +549,25 @@ class SSE:
__slots__ = ("event", "data", "id", "retry")
- def __init__(self):
+ def __init__(self) -> None:
self.clear()
- def clear(self):
- """reset to default/empty values"""
+ def clear(self) -> None:
+ """Reset to default/empty values."""
self.event = "message" # type: str
self.data = "" # type: Any
self.id = None # type: str | None
self.retry = None # type: int | None
def flush(self) -> dict[str, Any] | None:
- """obtain the event payload as dictionary and reset to default values"""
+ """
+ Obtain the event payload as dictionary and reset to default values.
+
+ Returns
+ -------
+ dict[str, Any] | None
+ dictionary with keys - `event`, `data`, `id`, `retry` if event has data or id, None otherwise
+ """
if not self.data and self.id is None:
return None
payload = {
diff --git a/hololinked/client/exceptions.py b/hololinked/client/exceptions.py
index 418dc77d..4ffccc87 100644
--- a/hololinked/client/exceptions.py
+++ b/hololinked/client/exceptions.py
@@ -1,4 +1,8 @@
-"""Exceptions."""
+"""Client side exceptions. Not fully formalized."""
+
+import builtins
+
+from typing import Any
class ReplyNotArrivedError(Exception):
@@ -11,3 +15,47 @@ class BreakLoop(Exception):
"""Raise and catch to exit a loop from within another function or method."""
pass
+
+
+def raise_local_exception(error_message: dict[str, Any] | str) -> None: # noqa
+ """
+ Raise an exception on client side using an exception type from the server.
+
+ A mapping based on exception type is used, and only python built-in exceptions supported. If the exception type
+ is not found, a generic `Exception` is raised. Cross language exceptions are not supported and will be raised
+ as a generic exception. Server traceback is added to the exception notes. Client creates its own traceback which is
+ not usually the cause of the error.
+
+ Parameters
+ ----------
+ error_message: dict[str, Any]
+ exception dictionary made by server with following keys - `type`, `message`, `traceback`, `notes`
+
+ Raises
+ ------
+ Exception
+ exception based on the server error message, with server traceback in the exception notes
+ RuntimeError
+ if the error message is not in the expected format, string, dict or native Exception instance.
+ TimeoutError
+ if the error message is a string indicating a timeout error
+ """
+ if isinstance(error_message, Exception):
+ raise error_message from None
+ elif isinstance(error_message, dict) and "exception" in error_message.keys():
+ error_message = error_message["exception"]
+ message = error_message["message"]
+ exc = getattr(builtins, error_message["type"], None)
+ if exc is None:
+ ex = Exception(message)
+ else:
+ ex = exc(message)
+ error_message["traceback"][0] = f"Server {error_message['traceback'][0]}"
+ ex.__notes__ = error_message["traceback"][0:-1]
+ raise ex from None
+ elif isinstance(error_message, str) and error_message in ["invokation", "execution"]:
+ raise TimeoutError(
+ f"{error_message[0].upper()}{error_message[1:]} timeout occured. "
+ + "Server did not respond within specified timeout"
+ ) from None
+ raise RuntimeError("unknown error occurred on server side") from None
diff --git a/hololinked/client/factory.py b/hololinked/client/factory.py
index ecd364d2..be1e482b 100644
--- a/hololinked/client/factory.py
+++ b/hololinked/client/factory.py
@@ -13,6 +13,18 @@
from paho.mqtt.client import CallbackAPIVersion, MQTTMessage, MQTTProtocolVersion
from paho.mqtt.client import Client as PahoMQTTClient
+from hololinked.client.http.consumed_interactions import HTTPAction, HTTPEvent, HTTPProperty
+from hololinked.client.mqtt.consumed_interactions import MQTTConsumer # only one type for now
+from hololinked.client.proxy import ObjectProxy
+from hololinked.client.security import BasicSecurity, OAuth2Security, OAuthDirectAccessGrant
+from hololinked.client.zmq.consumed_interactions import (
+ ReadMultipleProperties,
+ WriteMultipleProperties,
+ ZMQAction,
+ ZMQEvent,
+ ZMQProperty,
+)
+
from ..constants import ZMQ_TRANSPORTS
from ..core import Thing
from ..core.zmq import AsyncZMQClient, SyncZMQClient
@@ -24,17 +36,6 @@
)
from ..utils import uuid_hex
from .abstractions import ConsumedThingAction, ConsumedThingEvent, ConsumedThingProperty
-from .http.consumed_interactions import HTTPAction, HTTPEvent, HTTPProperty
-from .mqtt.consumed_interactions import MQTTConsumer # only one type for now
-from .proxy import ObjectProxy
-from .security import BasicSecurity, OAuth2Security, OAuthDirectAccessGrant
-from .zmq.consumed_interactions import (
- ReadMultipleProperties,
- WriteMultipleProperties,
- ZMQAction,
- ZMQEvent,
- ZMQProperty,
-)
class ClientFactory:
diff --git a/hololinked/client/security.py b/hololinked/client/security.py
index f54497ca..9bb6e881 100644
--- a/hololinked/client/security.py
+++ b/hololinked/client/security.py
@@ -130,6 +130,19 @@ class OAuthDirectAccessGrant(BaseModel):
This flow is not recommended for production use due to security risks, and should only be used in trusted
environments.
+ ```python
+ client = ClientFactory.http(
+ url="http://localhost:9000/my-thing/resources/wot-td",
+ security=OAuthDirectAccessGrant(
+ username=os.getenv("USERNAME", "admin"),
+ password=os.getenv("PASSWORD", "adminpass"),
+ oidc_config_url="https://example.com/.well-known/openid-configuration",
+ client_id="my-client-id",
+ client_secret=os.getenv("CLIENT_SECRET", "my-client-secret"),
+ )
+ )
+ ```
+
Note: The implementation class is `OAuth2Security`, which is instantiated indirectly through the `ClientFactory`.
"""
diff --git a/pyproject.toml b/pyproject.toml
index bc9d61a9..58026e4b 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -129,6 +129,7 @@ extend-select = [
[tool.ruff.lint.per-file-ignores]
"hololinked/client/abstractions.py" = ["DOC502"]
"hololinked/client/proxy.py" = ["DOC502"]
+"hololinked/client/exceptions.py" = ["DOC502"]
[tool.ruff.lint.pydocstyle]
convention = "numpy"
From 92c50afa47d309653dd2f14d57abe90f2d2d4d25 Mon Sep 17 00:00:00 2001
From: Vignesh Venkatasubramanian Vaidyanathan
<62492557+VigneshVSV@users.noreply.github.com>
Date: Sat, 11 Apr 2026 14:13:14 +0200
Subject: [PATCH 06/15] ty all other files
---
hololinked/client/abstractions.py | 26 +-
hololinked/client/http/__init__.py | 1 +
.../client/http/consumed_interactions.py | 172 ++++++++-----
hololinked/client/mqtt/__init__.py | 1 +
.../client/mqtt/consumed_interactions.py | 40 ++-
hololinked/client/zmq/__init__.py | 1 +
.../client/zmq/consumed_interactions.py | 238 +++++++++++++-----
hololinked/core/zmq/brokers.py | 92 ++++---
hololinked/td/interaction_affordance.py | 14 +-
9 files changed, 386 insertions(+), 199 deletions(-)
diff --git a/hololinked/client/abstractions.py b/hololinked/client/abstractions.py
index e99facd0..afe7681d 100644
--- a/hololinked/client/abstractions.py
+++ b/hololinked/client/abstractions.py
@@ -344,7 +344,7 @@ class ConsumedThingEvent: # noqa: D101 # Dont add class doc otherwise it will c
def __init__(
self,
- resource: EventAffordance,
+ resource: EventAffordance | PropertyAffordance,
logger: structlog.stdlib.BoundLogger,
owner_inst: Any,
) -> None:
@@ -353,8 +353,8 @@ def __init__(
Parameters
----------
- resource: EventAffordance
- dataclass object representing the event
+ resource: EventAffordance | PropertyAffordance
+ dataclass object representing the event or an observable property
logger: structlog.stdlib.BoundLogger
logger instance
owner_inst: Any
@@ -399,17 +399,27 @@ def subscribe(
"""
op = Operations.observeproperty if isinstance(self.resource, PropertyAffordance) else Operations.subscribeevent
form = self.resource.retrieve_form(op, None)
- callbacks = callbacks if isinstance(callbacks, (list, tuple)) else [callbacks]
+ cbs = (
+ callbacks
+ if isinstance(callbacks, list)
+ else list(callbacks)
+ if isinstance(callbacks, tuple)
+ else [callbacks]
+ )
# if not create_new_connection:
# see tag v0.3.2 for logic
if form is None:
raise ValueError(f"No form found for {op} operation for {self.resource.name}")
if asynch:
get_current_async_loop().call_soon(
- lambda: asyncio.create_task(self.async_listen(form, callbacks, concurrent, deserialize))
+ lambda: asyncio.create_task(self.async_listen(form, cbs, concurrent, deserialize)) # type: ignore
)
else:
- _thread = threading.Thread(target=self.listen, args=(form, callbacks, concurrent, deserialize), daemon=True)
+ _thread = threading.Thread(
+ target=self.listen,
+ args=(form, cbs, concurrent, deserialize),
+ daemon=True,
+ )
_thread.start()
def unsubscribe(self) -> None:
@@ -483,7 +493,7 @@ def schedule_callbacks(self, callbacks: list[Callable], event_data: Any, concurr
threading.Thread(target=cb, args=(event_data,)).start()
except Exception as ex:
self.logger.error(f"Error occurred in callback {cb}: {ex}")
- self.logger.exception(ex)
+ self.logger.exception(str(ex))
async def async_schedule_callbacks(self, callbacks, event_data: Any, concurrent: bool = False) -> None:
"""
@@ -512,7 +522,7 @@ async def async_schedule_callbacks(self, callbacks, event_data: Any, concurrent:
cb(event_data)
except Exception as ex:
self.logger.error(f"Error occurred in callback {cb}: {ex}")
- self.logger.exception(ex)
+ self.logger.exception(str(ex))
def add_callbacks(self, callbacks: list[Callable] | Callable, asynch: bool = False) -> None:
"""
diff --git a/hololinked/client/http/__init__.py b/hololinked/client/http/__init__.py
index e69de29b..e17fd64c 100644
--- a/hololinked/client/http/__init__.py
+++ b/hololinked/client/http/__init__.py
@@ -0,0 +1 @@
+"""HTTP Protocol Binding for client."""
diff --git a/hololinked/client/http/consumed_interactions.py b/hololinked/client/http/consumed_interactions.py
index db55f146..e2a0541c 100644
--- a/hololinked/client/http/consumed_interactions.py
+++ b/hololinked/client/http/consumed_interactions.py
@@ -1,6 +1,4 @@
-"""
-Classes that contain the client logic for the HTTP protocol.
-"""
+"""Concrete implementation of HTTP based consumed property, action or event."""
import asyncio
import contextlib
@@ -13,20 +11,20 @@
import httpx
import structlog
-from ...constants import Operations
-from ...serializers import Serializers
-from ...td.forms import Form
-from ...td.interaction_affordance import (
- ActionAffordance,
- EventAffordance,
- PropertyAffordance,
-)
-from ..abstractions import (
+from hololinked.client.abstractions import (
SSE,
ConsumedThingAction,
ConsumedThingEvent,
ConsumedThingProperty,
- raise_local_exception,
+)
+from hololinked.client.exceptions import raise_local_exception
+from hololinked.constants import Operations
+from hololinked.serializers import Serializers
+from hololinked.td.forms import Form
+from hololinked.td.interaction_affordance import (
+ ActionAffordance,
+ EventAffordance,
+ PropertyAffordance,
)
@@ -35,32 +33,34 @@ class HTTPConsumedAffordanceMixin:
def __init__(
self,
+ sync_client: httpx.Client,
+ async_client: httpx.AsyncClient,
invokation_timeout: int = 5,
execution_timeout: int = 5,
- sync_client: httpx.Client = None,
- async_client: httpx.AsyncClient = None,
) -> None:
"""
+ Initialize the HTTP consumed affordance mixin.
+
Parameters
----------
+ sync_client: httpx.Client
+ synchronous HTTP client
+ async_client: httpx.AsyncClient
+ asynchronous HTTP client
invokation_timeout: int
timeout for invokation of an operation, other timeouts are specified while creating the client
in `ClientFactory`
execution_timeout: int
timeout for execution of an operation, other timeouts are specified while creating the client
in `ClientFactory`
- sync_client: httpx.Client
- synchronous HTTP client
- async_client: httpx.AsyncClient
- asynchronous HTTP client
"""
super().__init__()
- self._invokation_timeout = invokation_timeout
- self._execution_timeout = execution_timeout
self._sync_http_client = sync_client
self._async_http_client = async_client
+ self._invokation_timeout = invokation_timeout
+ self._execution_timeout = execution_timeout
- from .. import ObjectProxy # noqa: F401
+ from hololinked.client import ObjectProxy # noqa: F401
self.owner_inst: ObjectProxy
@@ -71,9 +71,10 @@ def get_body_from_response(
raise_exception: bool = True,
) -> Any:
"""
- Extracts and deserializes the body from an HTTP response.
+ Extract and deserialize the body from an HTTP response.
+
Only 200 to 300 status codes, and 304 are considered successful.
- Other response codes raise an error or return None.
+ Other response codes raise an error or return None, whichever is appropriate.
Parameters
----------
@@ -84,6 +85,11 @@ def get_body_from_response(
raise_exception: bool
Whether to raise an exception if the response body contains an exception
+ Raises
+ ------
+ ValueError
+ If the content type of the response is not supported
+
Returns
-------
Any
@@ -106,12 +112,19 @@ def get_body_from_response(
def _merge_auth_headers(self, base: dict[str, str]) -> dict[str, str]:
"""
- Merge authentication headers into the base headers. The security scheme must be available on the owner object.
+ Merge authentication headers into the base headers.
+
+ The security scheme must be available on the owner object.
Parameters
----------
base: dict[str, str]
The base headers to merge into
+
+ Returns
+ -------
+ dict[str, str]
+ The merged headers with authentication headers included if available
"""
headers = base or {}
@@ -124,7 +137,9 @@ def _merge_auth_headers(self, base: dict[str, str]) -> dict[str, str]:
def create_http_request(self, form: Form, default_method: str, body: bytes | None = None) -> httpx.Request:
"""
- Creates a HTTP request object from the given form and body. Adds authentication headers if available.
+ Create a HTTP request object from the given form and body.
+
+ Adds authentication headers if available.
Parameters
----------
@@ -147,9 +162,9 @@ def create_http_request(self, form: Form, default_method: str, body: bytes | Non
headers=self._merge_auth_headers({"Content-Type": form.contentType or "application/json"}),
)
- def read_reply(self, form: Form, message_id: str, timeout: float = None) -> Any:
+ def read_reply(self, form: Form, message_id: str, timeout: float | None = None) -> Any:
"""
- Read the reply for a non-blocking action
+ Read the reply for a non-blocking action.
Parameters
----------
@@ -157,8 +172,8 @@ def read_reply(self, form: Form, message_id: str, timeout: float = None) -> Any:
The form to use for reading the reply
message_id: str
The message ID of the no-block request previously made
- timeout: float
- The timeout for waiting for the reply
+ timeout: float | None
+ The timeout for waiting for the reply, defaults to the invokation timeout of the client if not specified
Returns
-------
@@ -179,14 +194,16 @@ class HTTPAction(ConsumedThingAction, HTTPConsumedAffordanceMixin):
def __init__(
self,
resource: ActionAffordance,
- sync_client: httpx.Client = None,
- async_client: httpx.AsyncClient = None,
+ sync_client: httpx.Client,
+ async_client: httpx.AsyncClient,
+ logger: structlog.stdlib.BoundLogger,
+ owner_inst: Any = None,
invokation_timeout: int = 5,
execution_timeout: int = 5,
- owner_inst: Any = None,
- logger: structlog.stdlib.BoundLogger = None,
) -> None:
"""
+ Initialize the HTTP consumed action.
+
Parameters
----------
resource: ActionAffordance
@@ -195,16 +212,16 @@ def __init__(
synchronous HTTP client
async_client: httpx.AsyncClient
asynchronous HTTP client
+ logger: structlog.stdlib.BoundLogger
+ Logger instance
+ owner_inst: Any
+ The parent object that owns this consumer
invokation_timeout: int
timeout for invokation of an operation, other timeouts are specified while creating the client
in `ClientFactory`
execution_timeout: int
timeout for execution of an operation, other timeouts are specified while creating the client
in `ClientFactory`
- owner_inst: Any
- The parent object that owns this consumer
- logger: structlog.stdlib.BoundLogger
- Logger instance
"""
ConsumedThingAction.__init__(self=self, resource=resource, owner_inst=owner_inst, logger=logger)
HTTPConsumedAffordanceMixin.__init__(
@@ -240,7 +257,6 @@ def __call__(self, *args, **kwargs):
return self.get_body_from_response(response, form)
def oneway(self, *args, **kwargs):
- """Invoke the action without waiting for a response."""
form = deepcopy(self.resource.retrieve_form(Operations.invokeaction, None))
if form is None:
raise ValueError(f"No form found for invokeAction operation for {self.resource.name}")
@@ -256,7 +272,6 @@ def oneway(self, *args, **kwargs):
return None
def noblock(self, *args, **kwargs) -> str:
- """Invoke the action in non-blocking mode."""
form = deepcopy(self.resource.retrieve_form(Operations.invokeaction, None))
if form is None:
raise ValueError(f"No form found for invokeAction operation for {self.resource.name}")
@@ -287,14 +302,16 @@ class HTTPProperty(ConsumedThingProperty, HTTPConsumedAffordanceMixin):
def __init__(
self,
resource: PropertyAffordance,
- sync_client: httpx.Client = None,
- async_client: httpx.AsyncClient = None,
+ sync_client: httpx.Client,
+ async_client: httpx.AsyncClient,
+ logger: structlog.stdlib.BoundLogger,
+ owner_inst: Any = None,
invokation_timeout: int = 5,
execution_timeout: int = 5,
- owner_inst: Any = None,
- logger: structlog.stdlib.BoundLogger = None,
) -> None:
"""
+ Initialize the HTTP property consumer.
+
Parameters
----------
resource: PropertyAffordance
@@ -333,7 +350,6 @@ def get(self) -> Any:
return self.get_body_from_response(response, form)
def set(self, value: Any) -> None:
- """Synchronous set of the property value."""
if self.resource.readOnly:
raise NotImplementedError("This property is not writable")
form = self.resource.retrieve_form(Operations.writeproperty, None)
@@ -433,14 +449,16 @@ class HTTPEvent(ConsumedThingEvent, HTTPConsumedAffordanceMixin):
def __init__(
self,
resource: EventAffordance | PropertyAffordance,
- sync_client: httpx.Client = None,
- async_client: httpx.AsyncClient = None,
+ sync_client: httpx.Client,
+ async_client: httpx.AsyncClient,
+ owner_inst: Any,
+ logger: structlog.stdlib.BoundLogger,
invokation_timeout: int = 5,
execution_timeout: int = 5,
- owner_inst: Any = None,
- logger: structlog.stdlib.BoundLogger = None,
) -> None:
"""
+ Initialize the HTTP event consumer.
+
Parameters
----------
resource: EventAffordance | PropertyAffordance
@@ -469,7 +487,7 @@ def __init__(
execution_timeout=execution_timeout,
)
- def listen(self, form: Form, callbacks: list[Callable], concurrent: bool = False, deserialize: bool = True) -> None:
+ def listen(self, form: Form, callbacks: list[Callable], concurrent: bool = True, deserialize: bool = True) -> None:
serializer = Serializers.content_types.get(form.contentType or "application/json")
callback_id = threading.get_ident()
@@ -509,11 +527,11 @@ async def async_listen(
self,
form: Form,
callbacks: list[Callable],
- concurrent: bool = False,
+ concurrent: bool = True,
deserialize: bool = True,
) -> None:
serializer = Serializers.content_types.get(form.contentType or "application/json")
- callback_id = asyncio.current_task().get_name()
+ callback_id = asyncio.current_task().get_name() # type: ignore
try:
async with self._async_http_client.stream(
@@ -525,7 +543,7 @@ async def async_listen(
interrupting_event = asyncio.Event()
self._subscribed[callback_id] = (True, interrupting_event, resp)
event_data = SSE()
- async for line in self.aiter_lines_interruptible(resp, interrupting_event, resp):
+ async for line in self.aiter_lines_interruptible(resp, interrupting_event):
try:
if not self._subscribed.get(callback_id, (False, None))[0] or interrupting_event.is_set():
# when value is popped, consider unsubscribed
@@ -550,12 +568,25 @@ async def async_listen(
async def aiter_lines_interruptible(self, resp: httpx.Response, stop: asyncio.Event) -> AsyncIterator[str]:
"""
Yield lines from an httpx streaming response, but stop immediately when `stop` is set.
+
Works by racing the next __anext__() call against stop.wait().
+
+ Parameters
+ ----------
+ resp: httpx.Response
+ The HTTP response object to read lines from
+ stop: asyncio.Event
+ The event to wait on for stopping the iteration
+
+ Yields
+ ------
+ str
+ The next line from the response, until stop is set or the stream ends
"""
it = resp.aiter_lines()
while not stop.is_set():
try:
- next_line = asyncio.create_task(it.__anext__())
+ next_line = asyncio.create_task(it.__anext__()) # type: ignore
stopper = asyncio.create_task(stop.wait())
done, pending = await asyncio.wait({next_line, stopper}, return_when=asyncio.FIRST_COMPLETED)
@@ -578,7 +609,21 @@ async def aiter_lines_interruptible(self, resp: httpx.Response, stop: asyncio.Ev
return
def iter_lines_interruptible(self, resp: httpx.Response, stop: threading.Event) -> Iterator[str]:
- """Iterate lines from an httpx streaming response, but stop immediately when `stop` is set"""
+ """
+ Iterate lines from an httpx streaming response, but stop immediately when `stop` is set.
+
+ Parameters
+ ----------
+ resp: httpx.Response
+ The HTTP response object to read lines from
+ stop: threading.Event
+ The event to wait on for stopping the iteration
+
+ Yields
+ ------
+ str
+ The next line from the response, until stop is set or the stream ends
+ """
it = resp.iter_lines()
# Using a dedicated stream scope inside the thread
while not stop.is_set():
@@ -591,7 +636,16 @@ def iter_lines_interruptible(self, resp: httpx.Response, stop: threading.Event)
yield next_line
def decode_chunk(self, line: str, event_data: "SSE") -> None:
- """Decode a single line of an SSE stream into the given SSE event_data object"""
+ """
+ Decode a single line of an SSE stream into the given SSE event_data object.
+
+ Parameters
+ ----------
+ line: str
+ The line from the SSE stream to decode
+ event_data: SSE
+ The SSE event data object to populate
+ """
if line is None or line.startswith(":"): # comment/heartbeat
return
@@ -618,4 +672,8 @@ def unsubscribe(self) -> None:
return super().unsubscribe()
-__all__ = [HTTPProperty.__name__, HTTPAction.__name__, HTTPEvent.__name__]
+__all__ = [
+ "HTTPProperty",
+ "HTTPAction",
+ "HTTPEvent",
+]
diff --git a/hololinked/client/mqtt/__init__.py b/hololinked/client/mqtt/__init__.py
index e69de29b..f4eaf9a4 100644
--- a/hololinked/client/mqtt/__init__.py
+++ b/hololinked/client/mqtt/__init__.py
@@ -0,0 +1 @@
+"""MQTT Protocol Binding for client."""
diff --git a/hololinked/client/mqtt/consumed_interactions.py b/hololinked/client/mqtt/consumed_interactions.py
index 3cc53d10..86c3da5c 100644
--- a/hololinked/client/mqtt/consumed_interactions.py
+++ b/hololinked/client/mqtt/consumed_interactions.py
@@ -1,3 +1,5 @@
+"""Concrete implementation of MQTT based consumed property or event."""
+
from typing import Any, Callable
import aiomqtt
@@ -6,13 +8,13 @@
from paho.mqtt.client import Client as PahoMQTTClient
from paho.mqtt.client import MQTTMessage
-from ...serializers import BaseSerializer, Serializers # noqa: F401
-from ...td.forms import Form
-from ...td.interaction_affordance import EventAffordance, PropertyAffordance
-from ..abstractions import SSE, ConsumedThingEvent
+from hololinked.client.abstractions import SSE, ConsumedThingEvent
+from hololinked.serializers import BaseSerializer, Serializers # noqa: F401
+from hololinked.td.forms import Form
+from hololinked.td.interaction_affordance import EventAffordance, PropertyAffordance
-class MQTTConsumer(ConsumedThingEvent):
+class MQTTConsumer(ConsumedThingEvent): # noqa: D101
# An MQTT event consumer, both sync and async,
# please dont add classdoc
@@ -26,6 +28,8 @@ def __init__(
owner_inst: Any,
) -> None:
"""
+ Initialize the MQTT consumer.
+
Parameters
----------
sync_client: PahoMQTTClient
@@ -47,7 +51,13 @@ def __init__(
self.async_client = async_client
self.subscribed = True
- def listen(self, form: Form, callbacks: list[Callable], concurrent: bool, deserialize: bool) -> None:
+ def listen( # noqa: D102
+ self,
+ form: Form,
+ callbacks: list[Callable],
+ concurrent: bool = True,
+ deserialize: bool = True,
+ ) -> None:
# This method is called from a different thread but also finishes quickly, we wont redo this way
# for the time being.
topic = form.mqv_topic or f"{self.resource.thing_id}/{self.resource.name}"
@@ -67,18 +77,24 @@ def on_topic_message(client: PahoMQTTClient, userdata, message: MQTTMessage):
f"Error deserializing MQTT message for topic {topic}, "
+ f"passing payload as it is. message: {ex}"
)
- self.logger.exception(ex)
+ self.logger.exception(str(ex))
event_data = SSE()
event_data.data = payload
event_data.id = message.mid
self.schedule_callbacks(callbacks=callbacks, event_data=event_data, concurrent=concurrent)
except Exception as ex:
self.logger.error(f"Error handling MQTT message for topic {topic}: {ex}")
- self.logger.exception(ex)
+ self.logger.exception(str(ex))
self.sync_client.message_callback_add(topic, on_topic_message)
- async def async_listen(self, form: Form, callbacks: list[Callable], concurrent: bool, deserialize: bool) -> None:
+ async def async_listen( # noqa: D102
+ self,
+ form: Form,
+ callbacks: list[Callable],
+ concurrent: bool = True,
+ deserialize: bool = True,
+ ) -> None:
topic = form.mqv_topic or f"{self.resource.thing_id}/{self.resource.name}"
try:
await self.async_client.__aenter__()
@@ -104,16 +120,16 @@ async def async_listen(self, form: Form, callbacks: list[Callable], concurrent:
f"Error deserializing MQTT message for topic {topic}, "
+ f"passing payload as it is. message: {ex}"
)
- self.logger.exception(ex)
+ self.logger.exception(str(ex))
event_data = SSE()
event_data.data = payload
event_data.id = message.mid
await self.async_schedule_callbacks(callbacks=callbacks, event_data=event_data, concurrent=concurrent)
except Exception as ex:
self.logger.error(f"Error handling MQTT message for topic {topic}: {ex}")
- self.logger.exception(ex)
+ self.logger.exception(str(ex))
self.async_client.unsubscribe(topic)
- def unsubscribe(self) -> None:
+ def unsubscribe(self) -> None: # noqa: D102
self.subscribed = False
self.sync_client.message_callback_remove(f"{self.resource.thing_id}/{self.resource.name}")
diff --git a/hololinked/client/zmq/__init__.py b/hololinked/client/zmq/__init__.py
index e69de29b..2c6dbdca 100644
--- a/hololinked/client/zmq/__init__.py
+++ b/hololinked/client/zmq/__init__.py
@@ -0,0 +1 @@
+"""ZMQ Protocol Binding for client."""
diff --git a/hololinked/client/zmq/consumed_interactions.py b/hololinked/client/zmq/consumed_interactions.py
index cf1d32a2..500b500e 100644
--- a/hololinked/client/zmq/consumed_interactions.py
+++ b/hololinked/client/zmq/consumed_interactions.py
@@ -1,3 +1,5 @@
+"""Concrete implementation of ZMQ based consumed property, action or event."""
+
import asyncio
import threading
import traceback
@@ -8,13 +10,14 @@
import structlog
-from ...client.abstractions import (
+from hololinked.client.abstractions import (
SSE,
ConsumedThingAction,
ConsumedThingEvent,
ConsumedThingProperty,
- raise_local_exception,
)
+from hololinked.client.exceptions import raise_local_exception
+
from ...constants import Operations
from ...core import Action, Thing # noqa: F401
from ...core.zmq.brokers import (
@@ -63,15 +66,17 @@ class ZMQConsumedAffordanceMixin:
def __init__(
self,
sync_client: SyncZMQClient,
- async_client: AsyncZMQClient | None = None,
+ async_client: AsyncZMQClient,
**kwargs,
) -> None:
"""
+ Initialize this mixin, which offers common functionalities for ZMQ based affordances.
+
Parameters
----------
- sync_client: SyncZMQClient
+ sync_client: SyncZMQClient | None
synchronous ZMQ client
- async_client: AsyncZMQClient
+ async_client: AsyncZMQClient | None
asynchronous ZMQ client for async calls
kwargs:
additional keyword arguments:
@@ -88,9 +93,13 @@ def __init__(
self._thing_execution_context = dict(fetch_execution_logs=False)
self._last_zmq_response = None # type: ResponseMessage | None
+ from hololinked.client import ObjectProxy
+
+ self.owner_inst: ObjectProxy
+
def get_last_return_value(self, response: ResponseMessage, raise_exception: bool = False) -> Any:
"""
- Cached return value of the last operation performed.
+ Get cached return value of the last operation performed.
Parameters
----------
@@ -98,6 +107,16 @@ def get_last_return_value(self, response: ResponseMessage, raise_exception: bool
last response message received from the server
raise_exception: bool
whether to raise exception if the last response was an error message
+
+ Returns
+ -------
+ Any
+ As the title says
+
+ Raises
+ ------
+ RuntimeError
+ if response is None, meaning no operation was performed yet
"""
if response is None:
raise RuntimeError("No last response available. Did you make an operation?")
@@ -112,21 +131,43 @@ def get_last_return_value(self, response: ResponseMessage, raise_exception: bool
return payload
@property
- def last_zmq_response(self) -> ResponseMessage:
- """Cache of last message received for this property"""
+ def last_zmq_response(self) -> ResponseMessage | None:
+ """Cache of last ZMQ message received."""
return self._last_zmq_response
- def read_reply(self, message_id: str, timeout: int = None) -> Any:
+ def read_reply(self, message_id: str, timeout: float | int | None = None) -> Any:
+ """
+ Read the reply of the action call which was scheduled with `noblock`.
+
+ Parameters
+ ----------
+ message_id: str
+ id of the request or message (UUID4 as string)
+ timeout: float | int | None
+ timeout in seconds to wait for the reply, None means wait indefinitely
+
+ Raises
+ ------
+ RuntimeError
+ if the message_id does not belong to this property or action
+ ReplyNotArrivedError
+ if the reply did not arrive within the timeout
+
+ Returns
+ -------
+ Any
+ reply of the action call
+ """
if self.owner_inst._noblock_messages.get(message_id) != self:
raise RuntimeError(f"Message ID {message_id} does not belong to this property.")
- response = self._sync_zmq_client.recv_response(message_id=message_id)
+ response = self._sync_zmq_client.recv_response(message_id=message_id.encode())
if not response:
raise ReplyNotArrivedError(f"could not fetch reply within timeout for message id '{message_id}'")
self._last_zmq_response = response
return ZMQConsumedAffordanceMixin.get_last_return_value(self, response, True)
-class ZMQAction(ZMQConsumedAffordanceMixin, ConsumedThingAction):
+class ZMQAction(ZMQConsumedAffordanceMixin, ConsumedThingAction): # noqa: D101
# ZMQ method call abstraction
# Dont add doc otherwise __doc__ in slots will conflict with class variable
@@ -140,18 +181,27 @@ def __init__(
**kwargs,
) -> None:
"""
+ Initialize a ZMQAction instance.
+
Parameters
----------
resource: ActionAffordance
dataclass object representing the action
sync_client: SyncZMQClient
synchronous ZMQ client
- async_zmq_client: AsyncZMQClient
+ async_client: AsyncZMQClient
asynchronous ZMQ client for async calls
owner_inst: Any
the parent object that owns this action
logger: structlog.stdlib.BoundLogger
logger instance
+ kwargs:
+ additional keyword arguments:
+
+ - `invokation_timeout`: float, default 5.0
+ timeout for invokation of action or property read/write
+ - `execution_timeout`: float, default 5.0
+ timeout for execution of action or property read/write
"""
ConsumedThingAction.__init__(self, resource=resource, owner_inst=owner_inst, logger=logger)
ZMQConsumedAffordanceMixin.__init__(self, sync_client=sync_client, async_client=async_client, **kwargs)
@@ -162,13 +212,11 @@ def __init__(
doc="cached return value of the last call to the method",
)
- def __call__(self, *args, **kwargs) -> Any:
+ def __call__(self, *args, **kwargs) -> Any: # noqa: D102
if len(args) > 0:
kwargs["__args__"] = args
- elif self.schema_validator:
- self.schema_validator.validate(kwargs)
- form = self.resource.retrieve_form(Operations.invokeaction, Form())
- # works over ThingModel, there can be a default empty form
+ form = self.resource.retrieve_form(Operations.invokeaction, Form()) # works over ThingModel,
+ # so there can be a default empty form
response = self._sync_zmq_client.execute(
thing_id=self.resource.thing_id,
objekt=self.resource.name,
@@ -181,15 +229,11 @@ def __call__(self, *args, **kwargs) -> Any:
thing_execution_context=self._thing_execution_context,
)
self._last_zmq_response = response
- return ZMQConsumedAffordanceMixin.get_last_return_value(self, response, True)
+ return ZMQConsumedAffordanceMixin.get_last_return_value(self, response, raise_exception=True)
- async def async_call(self, *args, **kwargs) -> Any:
- if not self._async_zmq_client:
- raise RuntimeError("async calls not possible as async_mixin was not set True at __init__()")
+ async def async_call(self, *args, **kwargs) -> Any: # noqa: D102
if len(args) > 0:
kwargs["__args__"] = args
- elif self.schema_validator:
- self.schema_validator.validate(kwargs)
response = await self._async_zmq_client.async_execute(
thing_id=self.resource.thing_id,
objekt=self.resource.name,
@@ -206,13 +250,11 @@ async def async_call(self, *args, **kwargs) -> Any:
thing_execution_context=self._thing_execution_context,
)
self._last_zmq_response = response
- return ZMQConsumedAffordanceMixin.get_last_return_value(self, response, True)
+ return ZMQConsumedAffordanceMixin.get_last_return_value(self, response, raise_exception=True)
- def oneway(self, *args, **kwargs) -> None:
+ def oneway(self, *args, **kwargs) -> None: # noqa: D102
if len(args) > 0:
kwargs["__args__"] = args
- elif self.schema_validator:
- self.schema_validator.validate(kwargs)
self._sync_zmq_client.send_request(
thing_id=self.resource.thing_id,
objekt=self.resource.name,
@@ -230,11 +272,9 @@ def oneway(self, *args, **kwargs) -> None:
thing_execution_context=self._thing_execution_context,
)
- def noblock(self, *args, **kwargs) -> str:
+ def noblock(self, *args, **kwargs) -> str: # noqa: D102
if len(args) > 0:
kwargs["__args__"] = args
- elif self.schema_validator:
- self.schema_validator.validate(kwargs)
msg_id = self._sync_zmq_client.send_request(
thing_id=self.resource.thing_id,
objekt=self.resource.name,
@@ -249,12 +289,12 @@ def noblock(self, *args, **kwargs) -> str:
execution_timeout=self._execution_timeout,
),
thing_execution_context=self._thing_execution_context,
- )
+ ).decode()
self.owner_inst._noblock_messages[msg_id] = self
return msg_id
-class ZMQProperty(ZMQConsumedAffordanceMixin, ConsumedThingProperty):
+class ZMQProperty(ZMQConsumedAffordanceMixin, ConsumedThingProperty): # noqa: D101
# property get set abstraction
# Dont add doc otherwise __doc__ in slots will conflict with class variable
@@ -268,6 +308,8 @@ def __init__(
**kwargs,
) -> None:
"""
+ Initialize a ZMQProperty instance.
+
Parameters
----------
resource: PropertyAffordance
@@ -280,6 +322,14 @@ def __init__(
the parent object that owns this property
logger: structlog.stdlib.BoundLogger
logger instance for logging
+
+ kwargs:
+ additional keyword arguments:
+
+ - `invokation_timeout`: float, default 5.0
+ timeout for invokation of action or property read/write
+ - `execution_timeout`: float, default 5.0
+ timeout for execution of action or property read/write
"""
ConsumedThingProperty.__init__(self, resource=resource, owner_inst=owner_inst, logger=logger)
ZMQConsumedAffordanceMixin.__init__(self, sync_client=sync_client, async_client=async_client, **kwargs)
@@ -290,7 +340,7 @@ def __init__(
doc="cached return value of the last call to the method",
)
- def set(self, value: Any) -> None:
+ def set(self, value: Any) -> None: # noqa: D102
response = self._sync_zmq_client.execute(
thing_id=self.resource.thing_id,
objekt=self.resource.name,
@@ -309,7 +359,7 @@ def set(self, value: Any) -> None:
self._last_zmq_response = response
ZMQConsumedAffordanceMixin.get_last_return_value(self, response, True)
- def get(self) -> Any:
+ def get(self) -> Any: # noqa: D102
response = self._sync_zmq_client.execute(
thing_id=self.resource.thing_id,
objekt=self.resource.name,
@@ -323,9 +373,7 @@ def get(self) -> Any:
self._last_zmq_response = response
return ZMQConsumedAffordanceMixin.get_last_return_value(self, response, True)
- async def async_set(self, value: Any) -> None:
- if not self._async_zmq_client:
- raise RuntimeError("async calls not possible as async_mixin was not set at __init__()")
+ async def async_set(self, value: Any) -> None: # noqa: D102
response = await self._async_zmq_client.async_execute(
thing_id=self.resource.thing_id,
objekt=self.resource.name,
@@ -344,9 +392,7 @@ async def async_set(self, value: Any) -> None:
self._last_zmq_response = response
ZMQConsumedAffordanceMixin.get_last_return_value(self, response, True)
- async def async_get(self) -> Any:
- if not self._async_zmq_client:
- raise RuntimeError("async calls not possible as async_mixin was not set at __init__()")
+ async def async_get(self) -> Any: # noqa: D102
response = await self._async_zmq_client.async_execute(
thing_id=self.resource.thing_id,
objekt=self.resource.name,
@@ -360,7 +406,7 @@ async def async_get(self) -> Any:
self._last_zmq_response = response
return ZMQConsumedAffordanceMixin.get_last_return_value(self, response, True)
- def oneway_set(self, value: Any) -> None:
+ def oneway_set(self, value: Any) -> None: # noqa: D102
self._sync_zmq_client.send_request(
thing_id=self.resource.thing_id,
objekt=self.resource.name,
@@ -377,7 +423,7 @@ def oneway_set(self, value: Any) -> None:
),
)
- def noblock_get(self) -> str:
+ def noblock_get(self) -> str: # noqa: D102
msg_id = self._sync_zmq_client.send_request(
thing_id=self.resource.thing_id,
objekt=self.resource.name,
@@ -387,11 +433,11 @@ def noblock_get(self) -> str:
execution_timeout=self._execution_timeout,
),
thing_execution_context=self._thing_execution_context,
- )
+ ).decode()
self.owner_inst._noblock_messages[msg_id] = self
return msg_id
- def noblock_set(self, value: Any) -> None:
+ def noblock_set(self, value: Any) -> str: # noqa: D102
msg_id = self._sync_zmq_client.send_request(
thing_id=self.resource.thing_id,
objekt=self.resource.name,
@@ -406,17 +452,15 @@ def noblock_set(self, value: Any) -> None:
execution_timeout=self._execution_timeout,
),
thing_execution_context=self._thing_execution_context,
- )
+ ).decode()
self.owner_inst._noblock_messages[msg_id] = self
return msg_id
-class ZMQEvent(ConsumedThingEvent, ZMQConsumedAffordanceMixin):
+class ZMQEvent(ConsumedThingEvent, ZMQConsumedAffordanceMixin): # noqa: D101
# Dont add class doc otherwise __doc__ in slots will conflict with class variable
- __slots__ = [
- "_subscribed",
- ]
+ __slots__ = ["_subscribed"]
def __init__(
self,
@@ -425,10 +469,28 @@ def __init__(
owner_inst: Any,
**kwargs,
) -> None:
+ """
+ Initialize a ZMQEvent instance.
+
+ Parameters
+ ----------
+ resource: EventAffordance | PropertyAffordance
+ dataclass object representing the event or property to subscribe to
+ logger: structlog.stdlib.BoundLogger
+ logger instance for logging
+ owner_inst: Any
+ the parent object that owns this event
+ """
ConsumedThingEvent.__init__(self, resource=resource, logger=logger, owner_inst=owner_inst)
- ZMQConsumedAffordanceMixin.__init__(self, sync_client=None, async_client=None, **kwargs)
+ ZMQConsumedAffordanceMixin.__init__(self, sync_client=None, async_client=None, **kwargs) # type: ignore
- def listen(self, form: Form, callbacks: list[Callable], concurrent: bool, deserialize: bool) -> None:
+ def listen( # noqa: D102
+ self,
+ form: Form,
+ callbacks: list[Callable],
+ concurrent: bool = True,
+ deserialize: bool = True,
+ ) -> None:
sync_event_client = EventConsumer(
id=f"{self.resource.thing_id}|{self.resource.name}|sync|{uuid.uuid4().hex[:8]}",
event_unique_identifier=f"{self.resource.thing_id}/{self.resource.name}",
@@ -461,7 +523,13 @@ def listen(self, form: Form, callbacks: list[Callable], concurrent: bool, deseri
category=RuntimeWarning,
)
- async def async_listen(self, form: Form, callbacks: list[Callable], concurrent: bool, deserialize: bool) -> None:
+ async def async_listen( # noqa: D102
+ self,
+ form: Form,
+ callbacks: list[Callable],
+ concurrent: bool = True,
+ deserialize: bool = True,
+ ) -> None:
async_event_client = AsyncEventConsumer(
id=f"{self.resource.thing_id}|{self.resource.name}|async|{uuid.uuid4().hex[:8]}",
event_unique_identifier=f"{self.resource.thing_id}/{self.resource.name}",
@@ -469,7 +537,7 @@ async def async_listen(self, form: Form, callbacks: list[Callable], concurrent:
logger=self.logger,
)
async_event_client.subscribe()
- task_id = asyncio.current_task().get_name()
+ task_id = asyncio.current_task().get_name() # type: ignore
self._subscribed[task_id] = (True, async_event_client)
while True:
try:
@@ -485,7 +553,7 @@ async def async_listen(self, form: Form, callbacks: list[Callable], concurrent:
await self.async_schedule_callbacks(callbacks, event_data, concurrent)
except BreakLoop:
break
- except Exception as ex:
+ except Exception as ex: # noqa: BLE001
# traceback.print_exc()
# if "There is no current event loop in thread" and not self._subscribed:
# # TODO: some minor bug here within the umq receive loop when the loop is interrupted
@@ -493,29 +561,46 @@ async def async_listen(self, form: Form, callbacks: list[Callable], concurrent:
# pass
# else:
warnings.warn(
- f"Uncaught exception from {self.resource.name} event - {str(ex)}\n{traceback.print_exc()}",
+ f"Uncaught exception from {self.resource.name} event - {ex!s}\n{traceback.print_exc()}",
category=RuntimeWarning,
)
- def unsubscribe(self) -> None:
- for task_id, (subscribed, client) in self._subscribed.items():
+ def unsubscribe(self) -> None: # noqa: D102
+ for subscribed, client in self._subscribed.values():
if client:
client.stop_polling()
return super().unsubscribe()
class WriteMultipleProperties(ZMQAction):
- """
- Read and write multiple properties at once
- """
+ """Read and write multiple properties at once."""
def __init__(
self,
sync_client: SyncZMQClient,
- async_client: AsyncZMQClient | None = None,
+ async_client: AsyncZMQClient,
owner_inst: Any = None,
**kwargs,
) -> None:
+ """
+ Initialize a WriteMultipleProperties instance.
+
+ Parameters
+ ----------
+ sync_client: SyncZMQClient
+ synchronous ZMQ client
+ async_client: AsyncZMQClient
+ asynchronous ZMQ client for async calls
+ owner_inst: Any
+ the parent object that owns this action
+ kwargs:
+ additional keyword arguments:
+
+ - `invokation_timeout`: float, default 5.0
+ timeout for invokation of action or property read/write
+ - `execution_timeout`: float, default 5.0
+ timeout for execution of action or property read/write
+ """
action = Thing._set_properties # type: Action
resource = action.to_affordance(Thing)
resource._thing_id = owner_inst.thing_id
@@ -529,17 +614,34 @@ def __init__(
class ReadMultipleProperties(ZMQAction):
- """
- Read multiple properties at once
- """
+ """Read multiple properties at once."""
def __init__(
self,
sync_client: SyncZMQClient,
- async_client: AsyncZMQClient | None = None,
+ async_client: AsyncZMQClient,
owner_inst: Any = None,
**kwargs,
) -> None:
+ """
+ Initialize a ReadMultipleProperties instance.
+
+ Parameters
+ ----------
+ sync_client: SyncZMQClient
+ synchronous ZMQ client
+ async_client: AsyncZMQClient
+ asynchronous ZMQ client for async calls
+ owner_inst: Any
+ the parent object that owns this action
+ kwargs:
+ additional keyword arguments:
+
+ - `invokation_timeout`: float, default 5.0
+ timeout for invokation of action or property read/write
+ - `execution_timeout`: float, default 5.0
+ timeout for execution of action or property read/write
+ """
action = Thing._get_properties # type: Action
resource = action.to_affordance(Thing)
resource._thing_id = owner_inst.thing_id
@@ -553,7 +655,7 @@ def __init__(
__all__ = [
- ZMQAction.__name__,
- ZMQProperty.__name__,
- ZMQEvent.__name__,
+ "ZMQAction",
+ "ZMQEvent",
+ "ZMQProperty",
]
diff --git a/hololinked/core/zmq/brokers.py b/hololinked/core/zmq/brokers.py
index e1a0438b..0f3404f9 100644
--- a/hololinked/core/zmq/brokers.py
+++ b/hololinked/core/zmq/brokers.py
@@ -439,7 +439,6 @@ def __init__(
- `logger`: logger instance to use. If None, a default logger is created.
"""
-
super().__init__(id=id, **kwargs)
self.create_socket(
server_id=id,
@@ -456,7 +455,7 @@ def __init__(
@property
def poll_timeout(self) -> int:
- """socket polling timeout in milliseconds greater than 0"""
+ """Socket polling timeout in milliseconds greater than 0"""
return self._poll_timeout
@poll_timeout.setter
@@ -585,7 +584,7 @@ async def async_send_response_with_message_type(
async def poll_requests(self) -> list[RequestMessage]:
"""
- poll for messages with specified timeout (`poll_timeout`) and return if any messages are available.
+ Poll for messages with specified timeout (`poll_timeout`) and return if any messages are available.
This method can be stopped from another method in a different thread or asyncio task (not in the same thread though).
Returns
@@ -619,7 +618,7 @@ async def poll_requests(self) -> list[RequestMessage]:
return messages
def stop_polling(self) -> None:
- """stop polling and unblock `poll_messages()` method"""
+ """Stop polling and unblock `poll_messages()` method"""
self.stop_poll = True
async def _handshake(self, request_message: RequestMessage) -> None:
@@ -698,7 +697,7 @@ async def _handle_error_message(self, request_message: RequestMessage, exception
)
def exit(self) -> None:
- """unregister socket from poller and terminate socket. context is not terminated as it may be shared."""
+ """Unregister socket from poller and terminate socket. context is not terminated as it may be shared."""
try:
BaseZMQ.exit(self)
self.poller.unregister(self.socket)
@@ -757,7 +756,7 @@ def deregister_server(self, server: AsyncZMQServer) -> None:
@property
def poll_timeout(self) -> int:
- """socket polling timeout in milliseconds greater than 0"""
+ """Socket polling timeout in milliseconds greater than 0"""
return self._poll_timeout
@poll_timeout.setter
@@ -771,7 +770,7 @@ def poll_timeout(self, value) -> None:
async def async_recv_request(self, id: str) -> RequestMessage:
"""
- receive message for server specified by id
+ Receive message for server specified by id
Parameters
----------
@@ -787,7 +786,7 @@ async def async_recv_request(self, id: str) -> RequestMessage:
async def async_recv_requests(self, id: str) -> list[RequestMessage]:
"""
- receive all available messages for server specified by id
+ Receive all available messages for server specified by id
Parameters
----------
@@ -810,7 +809,7 @@ async def async_send_response(
preserialized_payload: PreserializedData = PreserializedEmptyByte,
) -> None:
"""
- send response for a request message for server specified by id
+ Send response for a request message for server specified by id
Parameters
----------
@@ -862,7 +861,7 @@ async def poll(self) -> list[RequestMessage]:
return messages
def stop_polling(self) -> None:
- """stop polling method `poll()`"""
+ """Stop polling method `poll()`"""
self.stop_poll = True
def __getitem__(self, key) -> AsyncZMQServer:
@@ -923,7 +922,7 @@ def __init__(
@property
def poll_timeout(self) -> int:
- """socket polling timeout in milliseconds greater than 0"""
+ """Socket polling timeout in milliseconds greater than 0"""
return self._poll_timeout
@poll_timeout.setter
@@ -1054,11 +1053,11 @@ def send_request(
operation: str,
payload: SerializableData = SerializableNone,
preserialized_payload: PreserializedData = PreserializedEmptyByte,
- server_execution_context: ServerExecutionContext = default_server_execution_context,
- thing_execution_context: ThingExecutionContext = default_thing_execution_context,
+ server_execution_context: ServerExecutionContext | dict = default_server_execution_context,
+ thing_execution_context: ThingExecutionContext | dict = default_thing_execution_context,
) -> bytes:
"""
- send request message to server.
+ Send request message to server.
Parameters
----------
@@ -1160,16 +1159,16 @@ def recv_response(self, message_id: bytes) -> ResponseMessage:
def execute(
self,
- thing_id: bytes,
+ thing_id: str,
objekt: str,
operation: str,
payload: SerializableData = SerializableNone,
preserialized_payload: PreserializedData = PreserializedEmptyByte,
- server_execution_context: ServerExecutionContext = default_server_execution_context,
- thing_execution_context: ThingExecutionContext = default_thing_execution_context,
+ server_execution_context: ServerExecutionContext | dict = default_server_execution_context,
+ thing_execution_context: ThingExecutionContext | dict = default_thing_execution_context,
) -> ResponseMessage:
"""
- send an operation and receive the response for it.
+ Send an operation and receive the response for it.
Parameters
----------
@@ -1206,7 +1205,7 @@ def execute(
def handshake(self, timeout: float | int = 60000) -> None:
"""
- handshake with server before sending first message
+ Handshake with server before sending first message
Parameters
----------
@@ -1300,7 +1299,7 @@ def __init__(
def handshake(self, timeout: int | None = 60000) -> None:
"""
- schedules a handshake coroutine in the running event loop
+ Schedules a handshake coroutine in the running event loop
or completes handshake synchronously if no event loop is running.
Use `handshake_complete()` async method to check if handshake is complete.
@@ -1313,7 +1312,7 @@ def handshake(self, timeout: int | None = 60000) -> None:
run_callable_somehow(self._handshake(timeout))
async def _handshake(self, timeout: float | int | None = 60000) -> None:
- """handshake with server before sending first message"""
+ """Handshake with server before sending first message"""
self._stop = False
if self._monitor_socket is not None and self._monitor_socket in self.poller:
self.poller.unregister(self._monitor_socket)
@@ -1346,7 +1345,7 @@ async def _handshake(self, timeout: float | int | None = 60000) -> None:
async def handshake_complete(self, timeout: float | int = 60000) -> None:
"""
- wait for handshake to complete
+ Wait for handshake to complete
Parameters
----------
@@ -1369,7 +1368,7 @@ async def async_send_request(
thing_execution_context: dict[str, Any] = default_thing_execution_context,
) -> str:
"""
- send request message to server.
+ Send request message to server.
Parameters
----------
@@ -1476,11 +1475,11 @@ async def async_execute(
operation: str,
payload: SerializableData = SerializableNone,
preserialized_payload: PreserializedData = PreserializedEmptyByte,
- server_execution_context: ServerExecutionContext = default_server_execution_context,
- thing_execution_context: ThingExecutionContext = default_thing_execution_context,
+ server_execution_context: ServerExecutionContext | dict = default_server_execution_context,
+ thing_execution_context: ThingExecutionContext | dict = default_thing_execution_context,
) -> ResponseMessage:
"""
- send an operation and receive the response for it.
+ Send an operation and receive the response for it.
Parameters
----------
@@ -1688,7 +1687,7 @@ def get_client_protocol(self, thing_id: str) -> str:
@property
def poll_timeout(self) -> int:
- """socket polling timeout in milliseconds greater than 0"""
+ """Socket polling timeout in milliseconds greater than 0"""
return self._poll_timeout
@poll_timeout.setter
@@ -1702,13 +1701,13 @@ def poll_timeout(self, value) -> None:
self._poll_timeout = value
async def handshake_complete(self) -> None:
- """wait for handshake to complete for all clients in the pool"""
+ """Wait for handshake to complete for all clients in the pool"""
for client in self.pool.values():
await client.handshake_complete() # sufficient to wait serially
def handshake(self, timeout: int | None = 60000) -> None:
"""
- schedules handshake coroutines for each client in the running event loop
+ Schedules handshake coroutines for each client in the running event loop
or completes handshake synchronously if no event loop is running.
Use `handshake_complete()` async method to check if handshake is complete.
@@ -1826,7 +1825,7 @@ async def async_send_request(
thing_execution_context: ThingExecutionContext = default_thing_execution_context,
) -> str:
"""
- send request message to server.
+ Send request message to server.
Parameters
----------
@@ -1929,7 +1928,7 @@ async def async_execute(
thing_execution_context: ThingExecutionContext = default_thing_execution_context,
) -> ResponseMessage:
"""
- send an operation and receive the response for it.
+ Send an operation and receive the response for it.
Parameters
----------
@@ -1970,12 +1969,12 @@ async def async_execute(
)
def start_polling(self) -> None:
- """register the server message polling loop in the asyncio event loop"""
+ """Register the server message polling loop in the asyncio event loop"""
get_current_async_loop().create_task(self.poll_responses())
def stop_polling(self):
"""
- stop polling for replies from server
+ Stop polling for replies from server
"""
self.stop_poll = True
for client in self.pool.values():
@@ -2022,7 +2021,7 @@ async def async_execute_in_all_things(
server_execution_context: ServerExecutionContext = default_server_execution_context,
thing_execution_context: ThingExecutionContext = default_thing_execution_context,
) -> dict[str, ResponseMessage]:
- """execute the same operation in all `Thing`s"""
+ """Execute the same operation in all `Thing`s"""
return await self.async_execute_in_all(
objekt=objekt,
operation=operation,
@@ -2087,7 +2086,7 @@ def __init__(self, initial_number_of_events: int) -> None:
def pop(self) -> asyncio.Event:
"""
- pop an event, new one is created if nothing left in pool
+ Pop an event, new one is created if nothing left in pool
"""
try:
event = self.pool.pop(0)
@@ -2099,7 +2098,7 @@ def pop(self) -> asyncio.Event:
def completed(self, event: asyncio.Event) -> None:
"""
- put an event back into the pool
+ Put an event back into the pool
"""
self.pool.append(event)
@@ -2143,7 +2142,7 @@ def __init__(
def register(self, event: "EventDispatcher") -> None:
"""
- register event with a specific (unique) name
+ Register event with a specific (unique) name
Parameters
----------
@@ -2158,7 +2157,7 @@ def register(self, event: "EventDispatcher") -> None:
def unregister(self, event: "EventDispatcher") -> None:
"""
- unregister event with a specific (unique) name
+ Unregister event with a specific (unique) name
Parameters
----------
@@ -2176,7 +2175,7 @@ def unregister(self, event: "EventDispatcher") -> None:
def publish(self, event, data: Any) -> None:
"""
- publish an event with given unique name.
+ Publish an event with given unique name.
Parameters
----------
@@ -2267,7 +2266,6 @@ def __init__(
- `poll_timeout`: `int`, socket polling timeout in milliseconds greater than 0.
- `server_id`: `str`, id of the PUB socket server, usually not necessary as `access_point` is sufficient.
"""
-
if isinstance(self, BaseSyncZMQ):
self.context = context or global_config.zmq_context()
self.poller = zmq.Poller()
@@ -2310,7 +2308,7 @@ def __init__(
self._stop = False
def subscribe(self) -> None:
- """subscribe to the event at the PUB socket"""
+ """Subscribe to the event at the PUB socket"""
self.socket.setsockopt(zmq.SUBSCRIBE, self.event_unique_identifier)
# pair sockets cannot be polled unforunately, so we use router
# if self.socket in self.poller._map:
@@ -2321,13 +2319,13 @@ def subscribe(self) -> None:
self.poller.register(self.interruptor, zmq.POLLIN)
def stop_polling(self) -> None:
- """stop polling for events when `receive()` is called"""
+ """Stop polling for events when `receive()` is called"""
self._stop = True
@property
def interrupt_message(self) -> EventMessage:
"""
- craft an interrupt message to be sent to the interruptor socket, if `stop_polling()` is not sufficient as
+ Craft an interrupt message to be sent to the interruptor socket, if `stop_polling()` is not sufficient as
the poll timeout is infinite. Used internally by `interrupt()` method.
"""
return EventMessage.craft_from_arguments(
@@ -2358,7 +2356,7 @@ class EventConsumer(BaseEventConsumer, BaseSyncZMQ):
def receive(self, timeout: float | None = 1000, raise_interrupt_as_exception: bool = False) -> EventMessage | None:
"""
- receive event with given timeout
+ Receive event with given timeout
Parameters
----------
@@ -2402,7 +2400,7 @@ def receive(self, timeout: float | None = 1000, raise_interrupt_as_exception: bo
def interrupt(self):
"""
- interrupts the event consumer. Generally should be used for exiting this object if there is no poll
+ Interrupts the event consumer. Generally should be used for exiting this object if there is no poll
period/infinite polling. Otherwise please use stop_polling().
"""
self.interrupting_peer.send_multipart(self.interrupt_message.byte_array)
@@ -2417,7 +2415,7 @@ async def receive(
raise_interrupt_as_exception: bool = False,
) -> EventMessage | None:
"""
- receive event with given timeout
+ Receive event with given timeout
Parameters
----------
@@ -2466,7 +2464,7 @@ async def receive(
async def interrupt(self):
"""
- interrupts the event consumer. Generally should be used for exiting this object if there is no poll
+ Interrupts the event consumer. Generally should be used for exiting this object if there is no poll
period/infinite polling. Otherwise please use stop_polling().
"""
await self.interrupting_peer.send_multipart(self.interrupt_message.byte_array)
diff --git a/hololinked/td/interaction_affordance.py b/hololinked/td/interaction_affordance.py
index e289b2ac..b5b12c04 100644
--- a/hololinked/td/interaction_affordance.py
+++ b/hololinked/td/interaction_affordance.py
@@ -106,7 +106,7 @@ def name(self) -> str:
return self._name
@property
- def thing_id(self) -> str | None:
+ def thing_id(self) -> str:
"""ID of the `Thing` instance owning the interaction affordance, if available, otherwise None"""
return self._thing_id
@@ -116,12 +116,12 @@ def thing_cls(self) -> ThingMeta:
return self._thing_cls
def build(self) -> None:
- """populate the fields of the schema for the specific interaction affordance"""
+ """Populate the fields of the schema for the specific interaction affordance"""
raise NotImplementedError("build must be implemented in subclass of InteractionAffordance")
def retrieve_form(self, op: str, default: Any = None) -> Form:
"""
- retrieve form for a certain operation, return default if not found
+ Retrieve form for a certain operation, return default if not found
Parameters
----------
@@ -145,7 +145,7 @@ def retrieve_form(self, op: str, default: Any = None) -> Form:
def pop_form(self, op: str, default: Any = None) -> Form:
"""
- retrieve and remove form for a certain operation, return default if not found
+ Retrieve and remove form for a certain operation, return default if not found
Parameters
----------
@@ -174,7 +174,7 @@ def generate(
owner: Thing,
) -> "PropertyAffordance | ActionAffordance | EventAffordance":
"""
- build the schema for the specific interaction affordance as an instance of this class.
+ Build the schema for the specific interaction affordance as an instance of this class.
Use the `json()` method to get the JSON representation of the schema.
Note that this method is different from build() method as its supposed to be used as a classmethod
@@ -196,7 +196,7 @@ def generate(
@classmethod
def from_TD(cls, name: str, TD: JSON) -> "PropertyAffordance | ActionAffordance | EventAffordance":
"""
- populate the schema from the TD and return it as an instance of this class.
+ Populate the schema from the TD and return it as an instance of this class.
Parameters
----------
@@ -235,7 +235,7 @@ def register_descriptor(
descriptor: Property | Action | Event,
schema_generator: "InteractionAffordance",
) -> None:
- """register a custom schema generator for a descriptor"""
+ """Register a custom schema generator for a descriptor"""
if not isinstance(descriptor, (Property, Action, Event)):
raise TypeError(
"custom schema generator can also be registered for Property." + f" Given type {type(descriptor)}"
From 92176d29a99ef910d10d7ce5e7fa30e0c5dfb063 Mon Sep 17 00:00:00 2001
From: Vignesh Venkatasubramanian Vaidyanathan
<62492557+VigneshVSV@users.noreply.github.com>
Date: Sat, 11 Apr 2026 15:34:26 +0200
Subject: [PATCH 07/15] ruff and ty client simultaneously
---
hololinked/client/__init__.py | 2 +
hololinked/client/abstractions.py | 38 ++++--
hololinked/client/exceptions.py | 8 +-
hololinked/client/factory.py | 57 ++++----
.../client/http/consumed_interactions.py | 90 +++++++-----
.../client/mqtt/consumed_interactions.py | 33 +++--
hololinked/client/proxy.py | 70 +++++-----
hololinked/client/security.py | 36 ++---
.../client/zmq/consumed_interactions.py | 128 +++++++++---------
pyproject.toml | 3 +
10 files changed, 262 insertions(+), 203 deletions(-)
diff --git a/hololinked/client/__init__.py b/hololinked/client/__init__.py
index 35077813..f6c28f88 100644
--- a/hololinked/client/__init__.py
+++ b/hololinked/client/__init__.py
@@ -1,3 +1,5 @@
+"""expose client objects per protocol using the Thing Description."""
+
from ..config import global_config # noqa: F401
from .factory import ClientFactory as ClientFactory
from .proxy import ObjectProxy as ObjectProxy
diff --git a/hololinked/client/abstractions.py b/hololinked/client/abstractions.py
index afe7681d..375dae8c 100644
--- a/hololinked/client/abstractions.py
+++ b/hololinked/client/abstractions.py
@@ -29,8 +29,9 @@
import asyncio
import threading
+from collections.abc import Callable
from dataclasses import dataclass
-from typing import Any, Callable
+from typing import TYPE_CHECKING, Any
import structlog
@@ -43,6 +44,13 @@
class ConsumedThingAction: # noqa: D101 # Dont add class doc otherwise it will conflict with __doc__ in slots
# Client side action call abstraction. Subclasss from here to implement protocol specific action call.
+ if TYPE_CHECKING:
+ # These are declared as __slots__ in the protocol-specific mixin subclasses.
+ # Annotated here only so type checkers resolve them on the base type.
+ __name__: str
+ __qualname__: str
+ __doc__: str | None
+
def __init__(
self,
resource: ActionAffordance,
@@ -151,7 +159,7 @@ def noblock(self, *args, **kwargs) -> str:
"""
raise NotImplementedError("implement action noblock call per protocol")
- def read_reply(self, message_id: str, timeout: float | int | None = None) -> Any:
+ def read_reply(self, message_id: str, timeout: float | None = None) -> Any:
"""
Read the reply of the action call which was scheduled with `noblock`.
@@ -181,6 +189,13 @@ def __eq__(self, other): # noqa: D105
class ConsumedThingProperty: # noqa: D101 # Dont add class doc otherwise it will conflict with __doc__ in slots
# property get set abstraction
+ if TYPE_CHECKING:
+ # These are declared as __slots__ in the protocol-specific mixin subclasses.
+ # Annotated here only so type checkers resolve them on the base type.
+ __name__: str
+ __qualname__: str
+ __doc__: str | None
+
def __init__(self, resource: PropertyAffordance, owner_inst: Any, logger: structlog.stdlib.BoundLogger) -> None:
"""
Initialize a consumed thing property.
@@ -320,7 +335,7 @@ def unobserve(self) -> None:
# looks like this will be unused, observe property is done via ConsumedThingEvent
raise NotImplementedError("implement property unobserve per protocol")
- def read_reply(self, message_id: str, timeout: float | int | None = None) -> Any:
+ def read_reply(self, message_id: str, timeout: float | None = None) -> Any:
"""
Read the reply of the property get or set which was scheduled with `noblock`.
@@ -342,6 +357,13 @@ def read_reply(self, message_id: str, timeout: float | int | None = None) -> Any
class ConsumedThingEvent: # noqa: D101 # Dont add class doc otherwise it will conflict with __doc__ in slots
# event subscription
+ if TYPE_CHECKING:
+ # These are declared as __slots__ in the protocol-specific mixin subclasses.
+ # Annotated here only so type checkers resolve them on the base type.
+ __name__: str
+ __qualname__: str
+ __doc__: str | None
+
def __init__(
self,
resource: EventAffordance | PropertyAffordance,
@@ -365,7 +387,7 @@ def __init__(
self.resource = resource
self.logger = logger
self.owner_inst = owner_inst # type: ObjectProxy
- self._subscribed = dict()
+ self._subscribed = {}
# self._sync_callbacks = []
# self._async_callbacks = []
@@ -492,8 +514,7 @@ def schedule_callbacks(self, callbacks: list[Callable], event_data: Any, concurr
else:
threading.Thread(target=cb, args=(event_data,)).start()
except Exception as ex:
- self.logger.error(f"Error occurred in callback {cb}: {ex}")
- self.logger.exception(str(ex))
+ self.logger.error(f"Error occurred in callback {cb} - {ex}", exc_info=True)
async def async_schedule_callbacks(self, callbacks, event_data: Any, concurrent: bool = False) -> None:
"""
@@ -521,8 +542,7 @@ async def async_schedule_callbacks(self, callbacks, event_data: Any, concurrent:
else:
cb(event_data)
except Exception as ex:
- self.logger.error(f"Error occurred in callback {cb}: {ex}")
- self.logger.exception(str(ex))
+ self.logger.error(f"Error occurred in callback {cb} - {ex}", exc_info=True)
def add_callbacks(self, callbacks: list[Callable] | Callable, asynch: bool = False) -> None:
"""
@@ -557,7 +577,7 @@ class SSE:
reconnection time in milliseconds, defaults to None, currently unused.
"""
- __slots__ = ("event", "data", "id", "retry")
+ __slots__ = ("data", "event", "id", "retry")
def __init__(self) -> None:
self.clear()
diff --git a/hololinked/client/exceptions.py b/hololinked/client/exceptions.py
index 4ffccc87..f744ce4c 100644
--- a/hololinked/client/exceptions.py
+++ b/hololinked/client/exceptions.py
@@ -8,16 +8,12 @@
class ReplyNotArrivedError(Exception):
"""Exception raised when a reply is not received in time."""
- pass
-
class BreakLoop(Exception):
"""Raise and catch to exit a loop from within another function or method."""
- pass
-
-def raise_local_exception(error_message: dict[str, Any] | str) -> None: # noqa
+def raise_local_exception(error_message: dict[str, Any] | str) -> None:
"""
Raise an exception on client side using an exception type from the server.
@@ -42,7 +38,7 @@ def raise_local_exception(error_message: dict[str, Any] | str) -> None: # noqa
"""
if isinstance(error_message, Exception):
raise error_message from None
- elif isinstance(error_message, dict) and "exception" in error_message.keys():
+ elif isinstance(error_message, dict) and "exception" in error_message:
error_message = error_message["exception"]
message = error_message["message"]
exc = getattr(builtins, error_message["type"], None)
diff --git a/hololinked/client/factory.py b/hololinked/client/factory.py
index be1e482b..6aee9566 100644
--- a/hololinked/client/factory.py
+++ b/hololinked/client/factory.py
@@ -13,10 +13,20 @@
from paho.mqtt.client import CallbackAPIVersion, MQTTMessage, MQTTProtocolVersion
from paho.mqtt.client import Client as PahoMQTTClient
-from hololinked.client.http.consumed_interactions import HTTPAction, HTTPEvent, HTTPProperty
-from hololinked.client.mqtt.consumed_interactions import MQTTConsumer # only one type for now
+from hololinked.client.http.consumed_interactions import (
+ HTTPAction,
+ HTTPEvent,
+ HTTPProperty,
+)
+from hololinked.client.mqtt.consumed_interactions import (
+ MQTTConsumer, # only one type for now
+)
from hololinked.client.proxy import ObjectProxy
-from hololinked.client.security import BasicSecurity, OAuth2Security, OAuthDirectAccessGrant
+from hololinked.client.security import (
+ BasicSecurity,
+ OAuth2Security,
+ OAuthDirectAccessGrant,
+)
from hololinked.client.zmq.consumed_interactions import (
ReadMultipleProperties,
WriteMultipleProperties,
@@ -157,7 +167,7 @@ def zmq(
async_zmq_client = AsyncZMQClient(f"{id}|async", server_id=server_id, logger=logger, access_point=access_point)
# Fetch the TD
- Thing.get_thing_model # type: Action
+ Thing.get_thing_model # type: Action # noqa: B018
FetchTDAffordance = Thing.get_thing_model.to_affordance()
FetchTDAffordance.override_defaults(name="get_thing_description", thing_id=thing_id)
FetchTD = ZMQAction(
@@ -247,7 +257,7 @@ def zmq(
return object_proxy
@staticmethod
- def http(self, url: str, **kwargs) -> ObjectProxy:
+ def http(url: str, **kwargs) -> ObjectProxy:
"""
Create a HTTP client using the Thing Description (TD) available at the specified URL.
@@ -514,7 +524,7 @@ def on_connect(
sync_client = PahoMQTTClient(
callback_api_version=CallbackAPIVersion.VERSION2,
client_id=id,
- clean_session=True if not protocol_version == MQTTProtocolVersion.MQTTv5 else None,
+ clean_session=True if protocol_version != MQTTProtocolVersion.MQTTv5 else None,
protocol=protocol_version,
)
if username and password:
@@ -523,8 +533,8 @@ def on_connect(
sync_client.tls_set_context(ssl_context)
elif kwargs.get("ca_certs", None):
sync_client.tls_set(ca_certs=kwargs.get("ca_certs", None))
- setattr(sync_client, "on_connect", on_connect)
- setattr(sync_client, "on_message", fetch_td)
+ sync_client.on_connect = on_connect # type: ignore[assignment]
+ sync_client.on_message = fetch_td
sync_client.connect(hostname, port)
sync_client.loop_start()
@@ -578,38 +588,27 @@ def on_connect(
@staticmethod
def add_action(client, action: ConsumedThingAction) -> None:
"""Add action to the client instance."""
- setattr(action, "__name__", action.resource.name)
- setattr(action, "__qualname__", f"{client.__class__.__name__}.{action.resource.name}")
- setattr(
- action,
- "__doc__",
- action.resource.description or "Invokes the action {} on the remote Thing".format(action.resource.name),
- )
+ action.__name__ = action.resource.name
+ action.__qualname__ = f"{client.__class__.__name__}.{action.resource.name}"
+ action.__doc__ = action.resource.description or f"Invokes the action {action.resource.name} on the remote Thing"
setattr(client, action.resource.name, action)
@staticmethod
def add_property(client, property: ConsumedThingProperty) -> None:
"""Add property to the client instance."""
- setattr(property, "__name__", property.resource.name)
- setattr(property, "__qualname__", f"{client.__class__.__name__}.{property.resource.name}")
- setattr(
- property,
- "__doc__",
- property.resource.description
- or "Represents the property {} on the remote Thing".format(property.resource.name),
+ property.__name__ = property.resource.name
+ property.__qualname__ = f"{client.__class__.__name__}.{property.resource.name}"
+ property.__doc__ = (
+ property.resource.description or f"Represents the property {property.resource.name} on the remote Thing"
)
setattr(client, property.resource.name, property)
@staticmethod
def add_event(client, event: ConsumedThingEvent) -> None:
"""Add event to the client instance."""
- setattr(event, "__name__", event.resource.name)
- setattr(event, "__qualname__", f"{client.__class__.__name__}.{event.resource.name}")
- setattr(
- event,
- "__doc__",
- event.resource.description or "Represents the event {} on the remote Thing".format(event.resource.name),
- )
+ event.__name__ = event.resource.name
+ event.__qualname__ = f"{client.__class__.__name__}.{event.resource.name}"
+ event.__doc__ = event.resource.description or f"Represents the event {event.resource.name} on the remote Thing"
if hasattr(event.resource, "observable") and event.resource.observable:
setattr(client, f"{event.resource.name}_change_event", event)
else:
diff --git a/hololinked/client/http/consumed_interactions.py b/hololinked/client/http/consumed_interactions.py
index e2a0541c..04548542 100644
--- a/hololinked/client/http/consumed_interactions.py
+++ b/hololinked/client/http/consumed_interactions.py
@@ -4,8 +4,9 @@
import contextlib
import threading
+from collections.abc import AsyncIterator, Callable, Iterator
from copy import deepcopy
-from typing import Any, AsyncIterator, Callable, Iterator
+from typing import Any
import httpcore
import httpx
@@ -31,6 +32,20 @@
class HTTPConsumedAffordanceMixin:
# Mixin class for HTTP consumed affordances
+ __slots__ = [
+ "__doc__",
+ "__name__",
+ "__qualname__",
+ "_async_http_client",
+ "_execution_timeout",
+ "_invokation_timeout",
+ "_sync_http_client",
+ "logger",
+ "owner_inst",
+ "resource",
+ "schema_validator",
+ ] # __slots__ dont support multiple inheritance
+
def __init__(
self,
sync_client: httpx.Client,
@@ -60,7 +75,7 @@ def __init__(
self._invokation_timeout = invokation_timeout
self._execution_timeout = execution_timeout
- from hololinked.client import ObjectProxy # noqa: F401
+ from hololinked.client import ObjectProxy
self.owner_inst: ObjectProxy
@@ -85,15 +100,15 @@ def get_body_from_response(
raise_exception: bool
Whether to raise an exception if the response body contains an exception
- Raises
- ------
- ValueError
- If the content type of the response is not supported
-
Returns
-------
Any
The deserialized body of the response or None
+
+ Raises
+ ------
+ ValueError
+ If the content type of the response is not supported
"""
if response.status_code >= 200 and response.status_code < 300 or response.status_code == 304:
body = response.content
@@ -130,7 +145,7 @@ def _merge_auth_headers(self, base: dict[str, str]) -> dict[str, str]:
if not self.owner_inst or self.owner_inst._security is None:
return headers
- if not any(key.lower() == self.owner_inst._security.http_header_name.lower() for key in headers.keys()):
+ if not any(key.lower() == self.owner_inst._security.http_header_name.lower() for key in headers):
headers[self.owner_inst._security.http_header_name] = self.owner_inst._security.http_header
return headers
@@ -187,7 +202,7 @@ def read_reply(self, form: Form, message_id: str, timeout: float | None = None)
return self.get_body_from_response(response, form)
-class HTTPAction(ConsumedThingAction, HTTPConsumedAffordanceMixin):
+class HTTPAction(ConsumedThingAction, HTTPConsumedAffordanceMixin): # noqa: D101
# An HTTP action, both sync and async
# please dont add classdoc
@@ -232,7 +247,7 @@ def __init__(
execution_timeout=execution_timeout,
)
- async def async_call(self, *args, **kwargs):
+ async def async_call(self, *args, **kwargs): # noqa: D102
form = self.resource.retrieve_form(Operations.invokeaction, None)
if form is None:
raise ValueError(f"No form found for invokeAction operation for {self.resource.name}")
@@ -244,7 +259,7 @@ async def async_call(self, *args, **kwargs):
response = await self._async_http_client.send(http_request)
return self.get_body_from_response(response, form)
- def __call__(self, *args, **kwargs):
+ def __call__(self, *args, **kwargs): # noqa: D102
form = self.resource.retrieve_form(Operations.invokeaction, None)
if form is None:
raise ValueError(f"No form found for invokeAction operation for {self.resource.name}")
@@ -256,7 +271,7 @@ def __call__(self, *args, **kwargs):
response = self._sync_http_client.send(http_request)
return self.get_body_from_response(response, form)
- def oneway(self, *args, **kwargs):
+ def oneway(self, *args, **kwargs): # noqa: D102
form = deepcopy(self.resource.retrieve_form(Operations.invokeaction, None))
if form is None:
raise ValueError(f"No form found for invokeAction operation for {self.resource.name}")
@@ -269,9 +284,8 @@ def oneway(self, *args, **kwargs):
response = self._sync_http_client.send(http_request)
# just to ensure the request was successful, no body expected.
self.get_body_from_response(response, form)
- return None
- def noblock(self, *args, **kwargs) -> str:
+ def noblock(self, *args, **kwargs) -> str: # noqa: D102
form = deepcopy(self.resource.retrieve_form(Operations.invokeaction, None))
if form is None:
raise ValueError(f"No form found for invokeAction operation for {self.resource.name}")
@@ -288,14 +302,14 @@ def noblock(self, *args, **kwargs) -> str:
self.owner_inst._noblock_messages[message_id] = self
return message_id
- def read_reply(self, message_id, timeout=None):
+ def read_reply(self, message_id, timeout=None): # noqa: D102
form = deepcopy(self.resource.retrieve_form(Operations.invokeaction, None))
if form is None:
raise ValueError(f"No form found for invokeAction operation for {self.resource.name}")
return HTTPConsumedAffordanceMixin.read_reply(self, form, message_id, timeout)
-class HTTPProperty(ConsumedThingProperty, HTTPConsumedAffordanceMixin):
+class HTTPProperty(ConsumedThingProperty, HTTPConsumedAffordanceMixin): # noqa: D101
# An HTTP property, both sync and async
# please dont add classdoc
@@ -339,9 +353,9 @@ def __init__(
invokation_timeout=invokation_timeout,
execution_timeout=execution_timeout,
)
- self._read_reply_op_map = dict()
+ self._read_reply_op_map = {} # when a single property has multiple forms which can be invoked noblock
- def get(self) -> Any:
+ def get(self) -> Any: # noqa: D102
form = self.resource.retrieve_form(Operations.readproperty, None)
if form is None:
raise ValueError(f"No form found for readproperty operation for {self.resource.name}")
@@ -349,7 +363,7 @@ def get(self) -> Any:
response = self._sync_http_client.send(http_request)
return self.get_body_from_response(response, form)
- def set(self, value: Any) -> None:
+ def set(self, value: Any) -> None: # noqa: D102
if self.resource.readOnly:
raise NotImplementedError("This property is not writable")
form = self.resource.retrieve_form(Operations.writeproperty, None)
@@ -361,9 +375,8 @@ def set(self, value: Any) -> None:
response = self._sync_http_client.send(http_request)
self.get_body_from_response(response, form)
# Just to ensure the request was successful, no body expected.
- return None
- async def async_get(self) -> Any:
+ async def async_get(self) -> Any: # noqa: D102
form = self.resource.retrieve_form(Operations.readproperty, None)
if form is None:
raise ValueError(f"No form found for readproperty operation for {self.resource.name}")
@@ -371,7 +384,7 @@ async def async_get(self) -> Any:
response = await self._async_http_client.send(http_request)
return self.get_body_from_response(response, form)
- async def async_set(self, value: Any) -> None:
+ async def async_set(self, value: Any) -> None: # noqa: D102
if self.resource.readOnly:
raise NotImplementedError("This property is not writable")
form = self.resource.retrieve_form(Operations.writeproperty, None)
@@ -383,9 +396,8 @@ async def async_set(self, value: Any) -> None:
response = await self._async_http_client.send(http_request)
# Just to ensure the request was successful, no body expected.
self.get_body_from_response(response, form)
- return None
- def oneway_set(self, value: Any) -> None:
+ def oneway_set(self, value: Any) -> None: # noqa: D102
if self.resource.readOnly:
raise NotImplementedError("This property is not writable")
form = deepcopy(self.resource.retrieve_form(Operations.writeproperty, None))
@@ -398,9 +410,8 @@ def oneway_set(self, value: Any) -> None:
response = self._sync_http_client.send(http_request)
# Just to ensure the request was successful, no body expected.
self.get_body_from_response(response, form, raise_exception=False)
- return None
- def noblock_get(self) -> str:
+ def noblock_get(self) -> str: # noqa: D102
form = deepcopy(self.resource.retrieve_form(Operations.readproperty, None))
if form is None:
raise ValueError(f"No form found for readproperty operation for {self.resource.name}")
@@ -414,7 +425,7 @@ def noblock_get(self) -> str:
self.owner_inst._noblock_messages[message_id] = self
return message_id
- def noblock_set(self, value) -> str:
+ def noblock_set(self, value) -> str: # noqa: D102
form = deepcopy(self.resource.retrieve_form(Operations.writeproperty, None))
if form is None:
raise ValueError(f"No form found for writeproperty operation for {self.resource.name}")
@@ -435,14 +446,14 @@ def noblock_set(self, value) -> str:
self._read_reply_op_map[message_id] = "writeproperty"
return message_id
- def read_reply(self, message_id, timeout=None) -> Any:
+ def read_reply(self, message_id, timeout=None) -> Any: # noqa: D102
form = deepcopy(self.resource.retrieve_form(op=self._read_reply_op_map.get(message_id, "readproperty")))
if form is None:
raise ValueError(f"No form found for readproperty operation for {self.resource.name}")
return HTTPConsumedAffordanceMixin.read_reply(self, form, message_id, timeout)
-class HTTPEvent(ConsumedThingEvent, HTTPConsumedAffordanceMixin):
+class HTTPEvent(ConsumedThingEvent, HTTPConsumedAffordanceMixin): # noqa: D101
# An HTTP event, both sync and async,
# please dont add classdoc
@@ -487,7 +498,13 @@ def __init__(
execution_timeout=execution_timeout,
)
- def listen(self, form: Form, callbacks: list[Callable], concurrent: bool = True, deserialize: bool = True) -> None:
+ def listen( # noqa: D102
+ self,
+ form: Form,
+ callbacks: list[Callable],
+ concurrent: bool = True,
+ deserialize: bool = True,
+ ) -> None:
serializer = Serializers.content_types.get(form.contentType or "application/json")
callback_id = threading.get_ident()
@@ -518,12 +535,12 @@ def listen(self, form: Form, callbacks: list[Callable], concurrent: bool = True,
continue
self.decode_chunk(line, event_data)
- except Exception as ex:
+ except Exception as ex: # noqa: BLE001
self.logger.error(f"Error processing SSE event: {ex}")
except (httpx.ReadError, httpcore.ReadError):
pass
- async def async_listen(
+ async def async_listen( # noqa: D102
self,
form: Form,
callbacks: list[Callable],
@@ -560,7 +577,7 @@ async def async_listen(
continue
self.decode_chunk(line, event_data)
- except Exception as ex:
+ except Exception as ex: # noqa: BLE001
self.logger.error(f"Error processing SSE event: {ex}")
except (httpx.ReadError, httpcore.ReadError):
pass
@@ -588,7 +605,7 @@ async def aiter_lines_interruptible(self, resp: httpx.Response, stop: asyncio.Ev
try:
next_line = asyncio.create_task(it.__anext__()) # type: ignore
stopper = asyncio.create_task(stop.wait())
- done, pending = await asyncio.wait({next_line, stopper}, return_when=asyncio.FIRST_COMPLETED)
+ done, _ = await asyncio.wait({next_line, stopper}, return_when=asyncio.FIRST_COMPLETED)
if stopper in done:
next_line.cancel()
@@ -650,8 +667,7 @@ def decode_chunk(self, line: str, event_data: "SSE") -> None:
return
field, _, value = line.partition(":")
- if value.startswith(" "):
- value = value[1:] # spec: single leading space is stripped
+ value = value.removeprefix(" ") # spec: single leading space is stripped
if field == "event":
event_data.event = value or "message"
@@ -673,7 +689,7 @@ def unsubscribe(self) -> None:
__all__ = [
- "HTTPProperty",
"HTTPAction",
"HTTPEvent",
+ "HTTPProperty",
]
diff --git a/hololinked/client/mqtt/consumed_interactions.py b/hololinked/client/mqtt/consumed_interactions.py
index 86c3da5c..2d2ca79c 100644
--- a/hololinked/client/mqtt/consumed_interactions.py
+++ b/hololinked/client/mqtt/consumed_interactions.py
@@ -1,6 +1,7 @@
"""Concrete implementation of MQTT based consumed property or event."""
-from typing import Any, Callable
+from collections.abc import Callable
+from typing import Any
import aiomqtt
import structlog
@@ -18,6 +19,22 @@ class MQTTConsumer(ConsumedThingEvent): # noqa: D101
# An MQTT event consumer, both sync and async,
# please dont add classdoc
+ __slots__ = [
+ "__doc__",
+ "__name__",
+ "__qualname__",
+ "async_client",
+ "logger",
+ "owner_inst",
+ "qos",
+ "resource",
+ "schema_validator",
+ "subscribed",
+ "sync_client",
+ ]
+ # __slots__ dont support multiple inheritance. Here there is no multiple inheritance but just to be consistent
+ # with other protocols which use multiple inheritance, we will keep the slots in the lowest child
+
def __init__(
self,
sync_client: PahoMQTTClient,
@@ -75,16 +92,15 @@ def on_topic_message(client: PahoMQTTClient, userdata, message: MQTTMessage):
except Exception as ex:
self.logger.error(
f"Error deserializing MQTT message for topic {topic}, "
- + f"passing payload as it is. message: {ex}"
+ + f"passing payload as it is. message - {ex}",
+ exc_info=True,
)
- self.logger.exception(str(ex))
event_data = SSE()
event_data.data = payload
event_data.id = message.mid
self.schedule_callbacks(callbacks=callbacks, event_data=event_data, concurrent=concurrent)
except Exception as ex:
- self.logger.error(f"Error handling MQTT message for topic {topic}: {ex}")
- self.logger.exception(str(ex))
+ self.logger.error(f"Error handling MQTT message for topic {topic} - {ex}", exc_info=True)
self.sync_client.message_callback_add(topic, on_topic_message)
@@ -118,16 +134,15 @@ async def async_listen( # noqa: D102
except Exception as ex:
self.logger.error(
f"Error deserializing MQTT message for topic {topic}, "
- + f"passing payload as it is. message: {ex}"
+ + f"passing payload as it is. message - {ex}",
+ exc_info=True,
)
- self.logger.exception(str(ex))
event_data = SSE()
event_data.data = payload
event_data.id = message.mid
await self.async_schedule_callbacks(callbacks=callbacks, event_data=event_data, concurrent=concurrent)
except Exception as ex:
- self.logger.error(f"Error handling MQTT message for topic {topic}: {ex}")
- self.logger.exception(str(ex))
+ self.logger.error(f"Error handling MQTT message for topic {topic} - {ex}", exc_info=True)
self.async_client.unsubscribe(topic)
def unsubscribe(self) -> None: # noqa: D102
diff --git a/hololinked/client/proxy.py b/hololinked/client/proxy.py
index 98929a06..f020c192 100644
--- a/hololinked/client/proxy.py
+++ b/hololinked/client/proxy.py
@@ -1,10 +1,15 @@
"""Implementation of procedural/scripting client for Thing."""
-from typing import Any, Callable
+from collections.abc import Callable
+from typing import Any
import structlog
-from hololinked.client.abstractions import ConsumedThingAction, ConsumedThingEvent, ConsumedThingProperty
+from hololinked.client.abstractions import (
+ ConsumedThingAction,
+ ConsumedThingEvent,
+ ConsumedThingProperty,
+)
from hololinked.client.security import APIKeySecurity, BasicSecurity # noqa: F401
@@ -79,56 +84,57 @@ def __init__(self, id: str, **kwargs) -> None:
"""
self.id = id
self._allow_foreign_attributes = kwargs.get("allow_foreign_attributes", False)
- self._noblock_messages = dict() # type: dict[str, ConsumedThingAction | ConsumedThingProperty]
+ self._noblock_messages = {} # type: dict[str, ConsumedThingAction | ConsumedThingProperty]
self._schema_validator = kwargs.get("schema_validator", None)
self._security = kwargs.get("security", None) # type: BasicSecurity | APIKeySecurity | None
self.logger = kwargs.pop("logger", structlog.get_logger())
- self.td = kwargs.get("td", dict()) # type: dict[str, Any]
+ self.td = kwargs.get("td", {}) # type: dict[str, Any]
- def __getattribute__(self, __name: str) -> Any:
- obj = super().__getattribute__(__name)
+ def __getattribute__(self, name: str) -> Any: # noqa: D105
+ obj = super().__getattribute__(name)
if isinstance(obj, ConsumedThingProperty):
return obj.get()
return obj
- def __setattr__(self, __name: str, __value: Any) -> None:
+ def __setattr__(self, name: str, value: Any) -> None: # noqa: D105
if (
- __name in ObjectProxy._own_attrs
- or (__name not in self.__dict__ and isinstance(__value, ObjectProxy.__allowed_attribute_types__))
+ name in ObjectProxy._own_attrs
+ or (name not in self.__dict__ and isinstance(value, ObjectProxy.__allowed_attribute_types__))
or self._allow_foreign_attributes
):
# allowed attribute types are ConsumedThingProperty and ConsumedThingAction defined after this class
- return super(ObjectProxy, self).__setattr__(__name, __value)
- elif __name in self.__dict__:
- obj = self.__dict__[__name]
+ return super().__setattr__(name, value)
+ elif name in self.__dict__:
+ obj = self.__dict__[name]
if isinstance(obj, ConsumedThingProperty):
- obj.set(value=__value)
+ obj.set(value=value)
return
- raise AttributeError(f"Cannot set attribute {__name} again to ObjectProxy for {self.id}.")
+ raise AttributeError(f"Cannot set attribute {name} again to ObjectProxy for {self.id}.")
raise AttributeError(
- f"Cannot set foreign attribute {__name} to ObjectProxy for {self.id}. Given attribute not found in server object."
+ f"Cannot set foreign attribute {name} to ObjectProxy for {self.id}."
+ + "Given attribute not found in server object."
)
- def __repr__(self) -> str:
+ def __repr__(self) -> str: # noqa: D105
return f"ObjectProxy {self.id}"
- def __enter__(self):
+ def __enter__(self): # noqa: D105
return self
- def __exit__(self, exc_type, exc_value, traceback):
+ def __exit__(self, exc_type, exc_value, traceback): # noqa: D105
pass
- def __eq__(self, other) -> bool:
+ def __eq__(self, other) -> bool: # noqa: D105
if other is self:
return True
return isinstance(other, ObjectProxy) and other.id == self.id and other.TD == self.TD
- def __ne__(self, other) -> bool:
+ def __ne__(self, other) -> bool: # noqa: D105
if other and isinstance(other, ObjectProxy):
return other.id != self.id or other.TD != self.TD
return True
- def __hash__(self) -> int:
+ def __hash__(self) -> int: # noqa: D105
return hash(self.id)
# @abstractmethod
@@ -169,7 +175,7 @@ def invoke_action(self, name: str, *args, **kwargs) -> Any:
server raised exception are propagated
"""
action = getattr(self, name, None) # type: ConsumedThingAction
- if not isinstance(action, ConsumedThingAction):
+ if not action:
raise AttributeError(f"No action named {name} in Thing {self.td['id']}")
oneway = kwargs.pop("oneway", False)
noblock = kwargs.pop("noblock", False)
@@ -208,7 +214,7 @@ async def async_invoke_action(self, name: str, *args, **kwargs) -> Any:
server raised exception are propagated.
"""
action = getattr(self, name, None) # type: ConsumedThingAction
- if not isinstance(action, ConsumedThingAction):
+ if not action:
raise AttributeError(f"No remote action named {name}")
return await action.async_call(*args, **kwargs)
@@ -236,7 +242,7 @@ def read_property(self, name: str, noblock: bool = False) -> Any:
server raised exception are propagated.
"""
prop = self.__dict__.get(name, None) # type: ConsumedThingProperty
- if not isinstance(prop, ConsumedThingProperty):
+ if not prop:
raise AttributeError(f"No property named {name}")
if noblock:
return prop.noblock_get()
@@ -267,7 +273,7 @@ def write_property(self, name: str, value: Any, oneway: bool = False, noblock: b
server raised exception are propagated.
"""
prop = self.__dict__.get(name, None) # type: ConsumedThingProperty
- if not isinstance(prop, ConsumedThingProperty):
+ if not prop:
raise AttributeError(f"No property named {name}")
if oneway:
prop.oneway_set(value)
@@ -300,7 +306,7 @@ async def async_read_property(self, name: str) -> Any:
server raised exception are propagated.
"""
prop = self.__dict__.get(name, None) # type: ConsumedThingProperty
- if not isinstance(prop, ConsumedThingProperty):
+ if not prop:
raise AttributeError(f"No property named {name}")
return await prop.async_get()
@@ -325,7 +331,7 @@ async def async_write_property(self, name: str, value: Any) -> None:
server raised exception are propagated.
"""
prop = self.__dict__.get(name, None) # type: ConsumedThingProperty
- if not isinstance(prop, ConsumedThingProperty):
+ if not prop:
raise AttributeError(f"No property named {name}")
await prop.async_set(value)
@@ -491,7 +497,7 @@ def observe_property(
if no property with specified name found in the Thing Description or if the property is not observable.
"""
event = getattr(self, f"{name}_change_event", None) # type: ConsumedThingEvent
- if not isinstance(event, ConsumedThingEvent):
+ if not event:
raise AttributeError(f"No events for property {name} or property not found")
self.subscribe_event(
name=f"{name}_change_event",
@@ -516,7 +522,7 @@ def unobserve_property(self, name: str) -> None:
if no property with specified name found in the Thing Description or if the property is not observable.
"""
event = getattr(self, f"{name}_change_event", None) # type: ConsumedThingEvent
- if not isinstance(event, ConsumedThingEvent):
+ if not event:
raise AttributeError(f"No events for property {name} or property not found")
event.unsubscribe()
@@ -555,7 +561,7 @@ def subscribe_event(
if no event with specified name is found.
"""
event = getattr(self, name, None) # type: ConsumedThingEvent
- if not isinstance(event, ConsumedThingEvent):
+ if not event:
raise AttributeError(f"No event named {name}")
# TODO: fix the logic below to reuse connections when possible
# if not create_new_connection:
@@ -583,7 +589,7 @@ def unsubscribe_event(self, name: str) -> None:
if no event with specified name is found.
"""
event = getattr(self, name, None) # type: ConsumedThingEvent
- if not isinstance(event, ConsumedThingEvent):
+ if not event:
raise AttributeError(f"No event named {name}")
event.unsubscribe()
@@ -639,4 +645,4 @@ def TD(self) -> dict[str, Any]:
return self.td
-__all__ = [ObjectProxy.__name__]
+__all__ = ["ObjectProxy"]
diff --git a/hololinked/client/security.py b/hololinked/client/security.py
index 9bb6e881..9261c5e3 100644
--- a/hololinked/client/security.py
+++ b/hololinked/client/security.py
@@ -244,7 +244,7 @@ class OAuth2Security:
def __init__(
self,
oidc_settings: OAuthDirectAccessGrant,
- refresh_interval_fraction: int | float = 0.75,
+ refresh_interval_fraction: float = 0.75,
**kwargs,
) -> None:
"""
@@ -283,13 +283,13 @@ def http_header(self) -> str:
def login(self) -> None:
"""Login with username and password and obtain tokens."""
- body = dict(
- grant_type=self.oidc_settings.grant_type,
- client_id=self.oidc_settings.client_id,
- scope=self.oidc_settings.scope,
- username=self.oidc_settings.username,
- password=self.oidc_settings.password,
- )
+ body = {
+ "grant_type": self.oidc_settings.grant_type,
+ "client_id": self.oidc_settings.client_id,
+ "scope": self.oidc_settings.scope,
+ "username": self.oidc_settings.username,
+ "password": self.oidc_settings.password,
+ }
if self.oidc_settings.client_secret:
body["client_secret"] = self.oidc_settings.client_secret
response = self._sync_http_client.post(
@@ -315,11 +315,11 @@ def logout(self) -> None:
"""Logout and invalidate tokens."""
if not self.tokens or not self.oidc_settings.revocation_endpoint:
return
- body = dict(
- client_id=self.oidc_settings.client_id,
- token=self.tokens.refresh_token if self.tokens.refresh_token else self.tokens.access_token,
- token_type_hint="refresh_token" if self.tokens.refresh_token else "access_token",
- )
+ body = {
+ "client_id": self.oidc_settings.client_id,
+ "token": self.tokens.refresh_token if self.tokens.refresh_token else self.tokens.access_token,
+ "token_type_hint": "refresh_token" if self.tokens.refresh_token else "access_token",
+ }
if self.oidc_settings.client_secret:
body["client_secret"] = self.oidc_settings.client_secret
response = self._sync_http_client.post(
@@ -347,11 +347,11 @@ def refresh_tokens(self) -> None:
)
return
try:
- body = dict(
- grant_type="refresh_token",
- client_id=self.oidc_settings.client_id,
- refresh_token=self.tokens.refresh_token,
- )
+ body = {
+ "grant_type": "refresh_token",
+ "client_id": self.oidc_settings.client_id,
+ "refresh_token": self.tokens.refresh_token,
+ }
if self.oidc_settings.client_secret:
body["client_secret"] = self.oidc_settings.client_secret
response = self._sync_http_client.post(
diff --git a/hololinked/client/zmq/consumed_interactions.py b/hololinked/client/zmq/consumed_interactions.py
index 500b500e..7fb82266 100644
--- a/hololinked/client/zmq/consumed_interactions.py
+++ b/hololinked/client/zmq/consumed_interactions.py
@@ -6,7 +6,8 @@
import uuid
import warnings
-from typing import Any, Callable
+from collections.abc import Callable
+from typing import Any
import structlog
@@ -48,19 +49,19 @@ class ZMQConsumedAffordanceMixin:
# Dont add doc otherwise __doc__ in slots will conflict with class variable
__slots__ = [
- "resource",
- "logger",
- "schema_validator",
- "owner_inst",
+ "__doc__",
"__name__",
"__qualname__",
- "__doc__",
- "_sync_zmq_client",
"_async_zmq_client",
- "_invokation_timeout",
"_execution_timeout",
- "_thing_execution_context",
+ "_invokation_timeout",
"_last_zmq_response",
+ "_sync_zmq_client",
+ "_thing_execution_context",
+ "logger",
+ "owner_inst",
+ "resource",
+ "schema_validator",
] # __slots__ dont support multiple inheritance
def __init__(
@@ -90,7 +91,7 @@ def __init__(
self._async_zmq_client = async_client
self._invokation_timeout = kwargs.get("invokation_timeout", 5.0)
self._execution_timeout = kwargs.get("execution_timeout", 5.0)
- self._thing_execution_context = dict(fetch_execution_logs=False)
+ self._thing_execution_context = {"fetch_execution_logs": False}
self._last_zmq_response = None # type: ResponseMessage | None
from hololinked.client import ObjectProxy
@@ -135,7 +136,7 @@ def last_zmq_response(self) -> ResponseMessage | None:
"""Cache of last ZMQ message received."""
return self._last_zmq_response
- def read_reply(self, message_id: str, timeout: float | int | None = None) -> Any:
+ def read_reply(self, message_id: str, timeout: float | None = None) -> Any:
"""
Read the reply of the action call which was scheduled with `noblock`.
@@ -146,6 +147,11 @@ def read_reply(self, message_id: str, timeout: float | int | None = None) -> Any
timeout: float | int | None
timeout in seconds to wait for the reply, None means wait indefinitely
+ Returns
+ -------
+ Any
+ reply of the action call
+
Raises
------
RuntimeError
@@ -153,10 +159,6 @@ def read_reply(self, message_id: str, timeout: float | int | None = None) -> Any
ReplyNotArrivedError
if the reply did not arrive within the timeout
- Returns
- -------
- Any
- reply of the action call
"""
if self.owner_inst._noblock_messages.get(message_id) != self:
raise RuntimeError(f"Message ID {message_id} does not belong to this property.")
@@ -222,10 +224,10 @@ def __call__(self, *args, **kwargs) -> Any: # noqa: D102
objekt=self.resource.name,
operation=Operations.invokeaction,
payload=SerializableData(value=kwargs, content_type=form.contentType or "application/json"),
- server_execution_context=dict(
- invokation_timeout=self._invokation_timeout,
- execution_timeout=self._execution_timeout,
- ),
+ server_execution_context={
+ "invokation_timeout": self._invokation_timeout,
+ "execution_timeout": self._execution_timeout,
+ },
thing_execution_context=self._thing_execution_context,
)
self._last_zmq_response = response
@@ -243,10 +245,10 @@ async def async_call(self, *args, **kwargs) -> Any: # noqa: D102
content_type=self.resource.retrieve_form(Operations.invokeaction, Form()).contentType
or "application/json",
),
- server_execution_context=dict(
- invokation_timeout=self._invokation_timeout,
- execution_timeout=self._execution_timeout,
- ),
+ server_execution_context={
+ "invokation_timeout": self._invokation_timeout,
+ "execution_timeout": self._execution_timeout,
+ },
thing_execution_context=self._thing_execution_context,
)
self._last_zmq_response = response
@@ -264,11 +266,11 @@ def oneway(self, *args, **kwargs) -> None: # noqa: D102
content_type=self.resource.retrieve_form(Operations.invokeaction, Form()).contentType
or "application/json",
),
- server_execution_context=dict(
- invokation_timeout=self._invokation_timeout,
- execution_timeout=self._execution_timeout,
- oneway=True,
- ),
+ server_execution_context={
+ "invokation_timeout": self._invokation_timeout,
+ "execution_timeout": self._execution_timeout,
+ "oneway": True,
+ },
thing_execution_context=self._thing_execution_context,
)
@@ -284,10 +286,10 @@ def noblock(self, *args, **kwargs) -> str: # noqa: D102
content_type=self.resource.retrieve_form(Operations.invokeaction, Form()).contentType
or "application/json",
),
- server_execution_context=dict(
- invokation_timeout=self._invokation_timeout,
- execution_timeout=self._execution_timeout,
- ),
+ server_execution_context={
+ "invokation_timeout": self._invokation_timeout,
+ "execution_timeout": self._execution_timeout,
+ },
thing_execution_context=self._thing_execution_context,
).decode()
self.owner_inst._noblock_messages[msg_id] = self
@@ -350,10 +352,10 @@ def set(self, value: Any) -> None: # noqa: D102
content_type=self.resource.retrieve_form(Operations.writeproperty, Form()).contentType
or "application/json",
),
- server_execution_context=dict(
- invokation_timeout=self._invokation_timeout,
- execution_timeout=self._execution_timeout,
- ),
+ server_execution_context={
+ "invokation_timeout": self._invokation_timeout,
+ "execution_timeout": self._execution_timeout,
+ },
thing_execution_context=self._thing_execution_context,
)
self._last_zmq_response = response
@@ -364,10 +366,10 @@ def get(self) -> Any: # noqa: D102
thing_id=self.resource.thing_id,
objekt=self.resource.name,
operation=Operations.readproperty,
- server_execution_context=dict(
- invocation_timeout=self._invokation_timeout,
- execution_timeout=self._execution_timeout,
- ),
+ server_execution_context={
+ "invokation_timeout": self._invokation_timeout,
+ "execution_timeout": self._execution_timeout,
+ },
thing_execution_context=self._thing_execution_context,
)
self._last_zmq_response = response
@@ -383,10 +385,10 @@ async def async_set(self, value: Any) -> None: # noqa: D102
content_type=self.resource.retrieve_form(Operations.writeproperty, Form()).contentType
or "application/json",
),
- server_execution_context=dict(
- invokation_timeout=self._invokation_timeout,
- execution_timeout=self._execution_timeout,
- ),
+ server_execution_context={
+ "invokation_timeout": self._invokation_timeout,
+ "execution_timeout": self._execution_timeout,
+ },
thing_execution_context=self._thing_execution_context,
)
self._last_zmq_response = response
@@ -397,10 +399,10 @@ async def async_get(self) -> Any: # noqa: D102
thing_id=self.resource.thing_id,
objekt=self.resource.name,
operation=Operations.readproperty,
- server_execution_context=dict(
- invokation_timeout=self._invokation_timeout,
- execution_timeout=self._execution_timeout,
- ),
+ server_execution_context={
+ "invokation_timeout": self._invokation_timeout,
+ "execution_timeout": self._execution_timeout,
+ },
thing_execution_context=self._thing_execution_context,
)
self._last_zmq_response = response
@@ -416,11 +418,11 @@ def oneway_set(self, value: Any) -> None: # noqa: D102
content_type=self.resource.retrieve_form(Operations.writeproperty, Form()).contentType
or "application/json",
),
- server_execution_context=dict(
- invokation_timeout=self._invokation_timeout,
- execution_timeout=self._execution_timeout,
- oneway=True,
- ),
+ server_execution_context={
+ "invokation_timeout": self._invokation_timeout,
+ "execution_timeout": self._execution_timeout,
+ "oneway": True,
+ },
)
def noblock_get(self) -> str: # noqa: D102
@@ -428,10 +430,10 @@ def noblock_get(self) -> str: # noqa: D102
thing_id=self.resource.thing_id,
objekt=self.resource.name,
operation=Operations.readproperty,
- server_execution_context=dict(
- invokation_timeout=self._invokation_timeout,
- execution_timeout=self._execution_timeout,
- ),
+ server_execution_context={
+ "invokation_timeout": self._invokation_timeout,
+ "execution_timeout": self._execution_timeout,
+ },
thing_execution_context=self._thing_execution_context,
).decode()
self.owner_inst._noblock_messages[msg_id] = self
@@ -447,10 +449,10 @@ def noblock_set(self, value: Any) -> str: # noqa: D102
content_type=self.resource.retrieve_form(Operations.writeproperty, Form()).contentType
or "application/json",
),
- server_execution_context=dict(
- invokation_timeout=self._invokation_timeout,
- execution_timeout=self._execution_timeout,
- ),
+ server_execution_context={
+ "invokation_timeout": self._invokation_timeout,
+ "execution_timeout": self._execution_timeout,
+ },
thing_execution_context=self._thing_execution_context,
).decode()
self.owner_inst._noblock_messages[msg_id] = self
@@ -514,12 +516,12 @@ def listen( # noqa: D102
self.schedule_callbacks(callbacks, event_data, concurrent)
except BreakLoop:
break
- except Exception as ex:
+ except Exception as ex: # noqa: BLE001
# traceback.print_exc()
# TODO: some minor bug here within the zmq receive loop when the loop is interrupted
# uncomment the above line to see the traceback
warnings.warn(
- f"Uncaught exception from {self.resource.name} event - {str(ex)}\n{traceback.print_exc()}",
+ f"Uncaught exception from {self.resource.name} event - {ex!s}\n{traceback.print_exc()}",
category=RuntimeWarning,
)
diff --git a/pyproject.toml b/pyproject.toml
index 58026e4b..5fb85d56 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -125,6 +125,9 @@ extend-select = [
"D", # pydocstyle
"DOC"
]
+ignore = [
+ "G201", # logging-exc-info: do not require exc_info in logger.exception calls
+]
[tool.ruff.lint.per-file-ignores]
"hololinked/client/abstractions.py" = ["DOC502"]
From ebdd2744e1e1a6b924c9373b439701df0e3266f8 Mon Sep 17 00:00:00 2001
From: Vignesh Venkatasubramanian Vaidyanathan
<62492557+VigneshVSV@users.noreply.github.com>
Date: Sat, 11 Apr 2026 19:07:18 +0200
Subject: [PATCH 08/15] move protocol specific client imports inside their
specific factory functions
---
hololinked/client/factory.py | 48 +++++++++++++++++++-----------------
1 file changed, 26 insertions(+), 22 deletions(-)
diff --git a/hololinked/client/factory.py b/hololinked/client/factory.py
index 6aee9566..09ca6a06 100644
--- a/hololinked/client/factory.py
+++ b/hololinked/client/factory.py
@@ -13,13 +13,10 @@
from paho.mqtt.client import CallbackAPIVersion, MQTTMessage, MQTTProtocolVersion
from paho.mqtt.client import Client as PahoMQTTClient
-from hololinked.client.http.consumed_interactions import (
- HTTPAction,
- HTTPEvent,
- HTTPProperty,
-)
-from hololinked.client.mqtt.consumed_interactions import (
- MQTTConsumer, # only one type for now
+from hololinked.client.abstractions import (
+ ConsumedThingAction,
+ ConsumedThingEvent,
+ ConsumedThingProperty,
)
from hololinked.client.proxy import ObjectProxy
from hololinked.client.security import (
@@ -27,25 +24,15 @@
OAuth2Security,
OAuthDirectAccessGrant,
)
-from hololinked.client.zmq.consumed_interactions import (
- ReadMultipleProperties,
- WriteMultipleProperties,
- ZMQAction,
- ZMQEvent,
- ZMQProperty,
-)
-
-from ..constants import ZMQ_TRANSPORTS
-from ..core import Thing
-from ..core.zmq import AsyncZMQClient, SyncZMQClient
-from ..serializers import Serializers
-from ..td.interaction_affordance import (
+from hololinked.constants import ZMQ_TRANSPORTS
+from hololinked.core import Thing
+from hololinked.serializers import Serializers
+from hololinked.td.interaction_affordance import (
ActionAffordance,
EventAffordance,
PropertyAffordance,
)
-from ..utils import uuid_hex
-from .abstractions import ConsumedThingAction, ConsumedThingEvent, ConsumedThingProperty
+from hololinked.utils import uuid_hex
class ClientFactory:
@@ -148,6 +135,15 @@ def zmq(
ObjectProxy
An `ObjectProxy` instance representing the remote `Thing` with ZMQ protocol.
"""
+ from hololinked.client.zmq.consumed_interactions import (
+ ReadMultipleProperties,
+ WriteMultipleProperties,
+ ZMQAction,
+ ZMQEvent,
+ ZMQProperty,
+ )
+ from hololinked.core.zmq import AsyncZMQClient, SyncZMQClient
+
id = kwargs.get("id", f"{server_id}|{thing_id}|{access_point}|{uuid_hex()}")
# configs
@@ -318,6 +314,12 @@ def http(url: str, **kwargs) -> ObjectProxy:
ObjectProxy
An `ObjectProxy` instance representing the remote Thing with HTTP protocol.
"""
+ from hololinked.client.http.consumed_interactions import (
+ HTTPAction,
+ HTTPEvent,
+ HTTPProperty,
+ )
+
# config
skip_interaction_affordances = kwargs.get("skip_interaction_affordances", [])
invokation_timeout = kwargs.get("invokation_timeout", 5.0)
@@ -493,6 +495,8 @@ def mqtt(
TimeoutError
If the Thing Description (TD) could not be fetched within the timeout period.
"""
+ from hololinked.client.mqtt.consumed_interactions import MQTTConsumer
+
id = kwargs.get("id", f"mqtt-client|{hostname}:{port}|{uuid_hex()}")
logger = kwargs.get("logger", structlog.get_logger()).bind(
component="client",
From 72027725cedd5e619a837c06e30dc7ba5a1ed623 Mon Sep 17 00:00:00 2001
From: Vignesh Venkatasubramanian Vaidyanathan
<62492557+VigneshVSV@users.noreply.github.com>
Date: Sun, 19 Apr 2026 09:12:37 +0200
Subject: [PATCH 09/15] mypy td module (#173)
* move protocol specific client imports inside their specific factory functions
* update TD module
* optimize based on diff
---
hololinked/client/factory.py | 50 +++---
hololinked/td/base.py | 36 ++++-
hololinked/td/data_schema.py | 77 +++++----
hololinked/td/forms.py | 15 +-
hololinked/td/interaction_affordance.py | 90 +++++++----
hololinked/td/metadata.py | 10 +-
hololinked/td/pydantic_extensions.py | 197 ++++++++++++++++++++++--
hololinked/td/security_definitions.py | 24 +--
hololinked/td/tm.py | 33 +++-
hololinked/td/utils.py | 4 +-
pyproject.toml | 1 +
11 files changed, 410 insertions(+), 127 deletions(-)
diff --git a/hololinked/client/factory.py b/hololinked/client/factory.py
index 6aee9566..9529ca58 100644
--- a/hololinked/client/factory.py
+++ b/hololinked/client/factory.py
@@ -13,13 +13,10 @@
from paho.mqtt.client import CallbackAPIVersion, MQTTMessage, MQTTProtocolVersion
from paho.mqtt.client import Client as PahoMQTTClient
-from hololinked.client.http.consumed_interactions import (
- HTTPAction,
- HTTPEvent,
- HTTPProperty,
-)
-from hololinked.client.mqtt.consumed_interactions import (
- MQTTConsumer, # only one type for now
+from hololinked.client.abstractions import (
+ ConsumedThingAction,
+ ConsumedThingEvent,
+ ConsumedThingProperty,
)
from hololinked.client.proxy import ObjectProxy
from hololinked.client.security import (
@@ -27,25 +24,15 @@
OAuth2Security,
OAuthDirectAccessGrant,
)
-from hololinked.client.zmq.consumed_interactions import (
- ReadMultipleProperties,
- WriteMultipleProperties,
- ZMQAction,
- ZMQEvent,
- ZMQProperty,
-)
-
-from ..constants import ZMQ_TRANSPORTS
-from ..core import Thing
-from ..core.zmq import AsyncZMQClient, SyncZMQClient
-from ..serializers import Serializers
-from ..td.interaction_affordance import (
+from hololinked.constants import ZMQ_TRANSPORTS
+from hololinked.core import Thing
+from hololinked.serializers import Serializers
+from hololinked.td.interaction_affordance import (
ActionAffordance,
EventAffordance,
PropertyAffordance,
)
-from ..utils import uuid_hex
-from .abstractions import ConsumedThingAction, ConsumedThingEvent, ConsumedThingProperty
+from hololinked.utils import uuid_hex
class ClientFactory:
@@ -148,6 +135,15 @@ def zmq(
ObjectProxy
An `ObjectProxy` instance representing the remote `Thing` with ZMQ protocol.
"""
+ from hololinked.client.zmq.consumed_interactions import (
+ ReadMultipleProperties,
+ WriteMultipleProperties,
+ ZMQAction,
+ ZMQEvent,
+ ZMQProperty,
+ )
+ from hololinked.core.zmq import AsyncZMQClient, SyncZMQClient
+
id = kwargs.get("id", f"{server_id}|{thing_id}|{access_point}|{uuid_hex()}")
# configs
@@ -167,7 +163,7 @@ def zmq(
async_zmq_client = AsyncZMQClient(f"{id}|async", server_id=server_id, logger=logger, access_point=access_point)
# Fetch the TD
- Thing.get_thing_model # type: Action # noqa: B018
+ Thing.get_thing_model # noqa: B018 # type: Action
FetchTDAffordance = Thing.get_thing_model.to_affordance()
FetchTDAffordance.override_defaults(name="get_thing_description", thing_id=thing_id)
FetchTD = ZMQAction(
@@ -318,6 +314,12 @@ def http(url: str, **kwargs) -> ObjectProxy:
ObjectProxy
An `ObjectProxy` instance representing the remote Thing with HTTP protocol.
"""
+ from hololinked.client.http.consumed_interactions import (
+ HTTPAction,
+ HTTPEvent,
+ HTTPProperty,
+ )
+
# config
skip_interaction_affordances = kwargs.get("skip_interaction_affordances", [])
invokation_timeout = kwargs.get("invokation_timeout", 5.0)
@@ -493,6 +495,8 @@ def mqtt(
TimeoutError
If the Thing Description (TD) could not be fetched within the timeout period.
"""
+ from hololinked.client.mqtt.consumed_interactions import MQTTConsumer
+
id = kwargs.get("id", f"mqtt-client|{hostname}:{port}|{uuid_hex()}")
logger = kwargs.get("logger", structlog.get_logger()).bind(
component="client",
diff --git a/hololinked/td/base.py b/hololinked/td/base.py
index 580206e6..54fa4025 100644
--- a/hololinked/td/base.py
+++ b/hololinked/td/base.py
@@ -1,3 +1,5 @@
+"""Base Schema class for all WoT schema components."""
+
import inspect
from typing import Any, ClassVar
@@ -8,13 +10,21 @@
class Schema(BaseModel):
"""
Base pydantic model for all WoT schema components (as in, parts within the schema).
+
Call `model_dump` or `json` method to get the JSON representation of the schema.
"""
skip_keys: ClassVar = [] # override this to skip some dataclass attributes in the schema
def model_dump(self, **kwargs) -> dict[str, Any]:
- """Return the JSON representation of the schema"""
+ """
+ Return the JSON representation of the schema.
+
+ Returns
+ -------
+ dict[str, Any]
+ JSON representation
+ """
# we need to override this to work with our JSON serializer
kwargs["mode"] = "json"
kwargs["by_alias"] = True
@@ -30,13 +40,29 @@ def model_dump(self, **kwargs) -> dict[str, Any]:
]
return super().model_dump(**kwargs)
- def json(self) -> dict[str, Any]:
- """same as model_dump"""
+ def json(self) -> dict[str, Any]: # noqa
+ """
+ Same as model_dump.
+
+ Overrides pydantic's base class `json()` method.
+
+ Returns
+ -------
+ dict[str, Any]
+ JSON representation
+ """ # noqa: D401
return self.model_dump()
@classmethod
- def format_doc(cls, doc: str):
- """strip tabs, newlines, whitespaces etc. to format the docstring nicely"""
+ def format_doc(cls, doc: str) -> str:
+ """
+ Strip tabs, newlines, whitespaces etc. to format the docstring nicely.
+
+ Returns
+ -------
+ str
+ Formatted docstring
+ """
doc = inspect.cleandoc(doc)
# Remove everything after "Parameters\n-----" if present (when using numpydoc)
marker = "Parameters\n-----"
diff --git a/hololinked/td/data_schema.py b/hololinked/td/data_schema.py
index 4d7799b7..fcd087a9 100644
--- a/hololinked/td/data_schema.py
+++ b/hololinked/td/data_schema.py
@@ -1,3 +1,5 @@
+"""Implementations of Data Schema."""
+
from typing import Any, ClassVar, Optional
from pydantic import BaseModel, ConfigDict, Field, RootModel
@@ -29,8 +31,7 @@
class DataSchema(Schema):
"""
- Implements Data Schema, usually used to represent payloads of properties, actions and events in a
- WoT Thing Description.
+ Usually represents payloads of properties, actions and events in a WoT Thing Description.
- [Vocabulary Definitions](https://www.w3.org/TR/wot-thing-description11/#sec-data-schema-vocabulary-definition)
- [Supported Fields](https://www.w3.org/TR/wot-thing-description11/#data-schema-fields)
@@ -57,7 +58,7 @@ def __init__(self):
super().__init__()
def ds_build_fields_from_property(self, property: Property) -> None:
- """populates schema information from property descriptor object"""
+ """Populates schema information from property descriptor object."""
self.title = get_summary(property.doc)
if property.constant:
self.const = property.constant
@@ -87,8 +88,20 @@ def ds_build_fields_from_property(self, property: Property) -> None:
# you dont know what you are building, whether the data schema or something else when viewed from property affordance
def ds_build_from_property(self, property: Property) -> None:
"""
- generates the schema specific to the property type,
- calls `ds_build_fields_from_property()` after choosing the right type
+ Generates the schema specific to the property type.
+
+ Calls `ds_build_fields_from_property()` after choosing the right type.
+
+ Parameters
+ ----------
+ property: Property
+ property descriptor object to generate the schema from
+
+ Raises
+ ------
+ TypeError
+ if the property type is not supported for schema generation. Custom schema generators need to
+ be registered for custom defined properties.
"""
if self._custom_schema_generators.get(property, NotImplemented) is not NotImplemented:
data_schema = self._custom_schema_generators[property]()
@@ -135,21 +148,21 @@ def ds_build_from_property(self, property: Property) -> None:
setattr(self, field_name, field_value)
def _move_own_type_to_oneOf(self):
- """move a type to oneOf"""
+ """Move a type to oneOf, usually when allow None is True."""
pass
class BooleanSchema(DataSchema):
"""
- boolean schema - https://www.w3.org/TR/wot-thing-description11/#booleanschema
- used by Boolean descriptor
+ Boolean Schema - https://www.w3.org/TR/wot-thing-description11/#booleanschema.
+
+ Used by Boolean descriptor.
"""
def __init__(self):
super().__init__()
- def ds_build_fields_from_property(self, property) -> None:
- """generates the schema"""
+ def ds_build_fields_from_property(self, property) -> None: # noqa: D102
self.type = "boolean"
super().ds_build_fields_from_property(property)
@@ -164,8 +177,9 @@ def _move_own_type_to_oneOf(self):
class StringSchema(DataSchema):
"""
- string schema - https://www.w3.org/TR/wot-thing-description11/#stringschema
- used by String, Filename, Foldername, Path descriptors
+ String Schema - https://www.w3.org/TR/wot-thing-description11/#stringschema.
+
+ Used by String, Filename, Foldername, Path descriptors.
"""
pattern: Optional[str] = None
@@ -175,8 +189,7 @@ class StringSchema(DataSchema):
def __init__(self):
super().__init__()
- def ds_build_fields_from_property(self, property) -> None:
- """generates the schema"""
+ def ds_build_fields_from_property(self, property) -> None: # noqa: D102
self.type = "string"
if isinstance(property, String):
if property.regex is not None:
@@ -198,8 +211,9 @@ def _move_own_type_to_oneOf(self):
class NumberSchema(DataSchema):
"""
- number schema - https://www.w3.org/TR/wot-thing-description11/#numberschema
- used by Number and Integer descriptors
+ Number Schema - https://www.w3.org/TR/wot-thing-description11/#numberschema.
+
+ Used by Number and Integer descriptors.
"""
minimum: Optional[int | float] = None
@@ -211,8 +225,7 @@ class NumberSchema(DataSchema):
def __init__(self):
super().__init__()
- def ds_build_fields_from_property(self, property) -> None:
- """generates the schema"""
+ def ds_build_fields_from_property(self, property) -> None: # noqa: D102
if isinstance(property, Integer):
self.type = "integer"
elif isinstance(property, Number): # dont change order - one is subclass of other
@@ -248,8 +261,9 @@ def _move_own_type_to_oneOf(self):
class ArraySchema(DataSchema):
"""
- array schema - https://www.w3.org/TR/wot-thing-description11/#arrayschema
- Used by list, Tuple, TypedList and TupleSelector
+ Array Schema - https://www.w3.org/TR/wot-thing-description11/#arrayschema.
+
+ Used by List, Tuple, TypedList and TupleSelector.
"""
items: Optional[DataSchema | list[DataSchema] | JSON | JSONSerializable] = None
@@ -259,8 +273,7 @@ class ArraySchema(DataSchema):
def __init__(self):
super().__init__()
- def ds_build_fields_from_property(self, property) -> None:
- """generates the schema"""
+ def ds_build_fields_from_property(self, property) -> None: # noqa: D102
self.type = "array"
self.items = []
if isinstance(property, (List, Tuple, TypedList)) and property.item_type is not None:
@@ -304,8 +317,9 @@ def _move_own_type_to_oneOf(self):
class ObjectSchema(DataSchema):
"""
- object schema - https://www.w3.org/TR/wot-thing-description11/#objectschema
- Used by TypedDict where the key type must be a string
+ Object Schema - https://www.w3.org/TR/wot-thing-description11/#objectschema.
+
+ Used by TypedDict where the key type must be a string.
"""
properties: Optional[JSON] = None
@@ -314,8 +328,7 @@ class ObjectSchema(DataSchema):
def __init__(self):
super().__init__()
- def ds_build_fields_from_property(self, property) -> None:
- """generates the schema"""
+ def ds_build_fields_from_property(self, property) -> None: # noqa: D102
super().ds_build_fields_from_property(property)
properties = None
required = None
@@ -341,7 +354,8 @@ def ds_build_fields_from_property(self, property) -> None:
class SelectorSchema(DataSchema):
"""
- custom schema to deal with ClassSelector & Selector to fill oneOf field correctly
+ Custom schema to deal with ClassSelector & Selector to fill oneOf field correctly.
+
https://www.w3.org/TR/wot-thing-description11/#dataschema
"""
@@ -355,8 +369,7 @@ class SelectorSchema(DataSchema):
def __init__(self):
super().__init__()
- def ds_build_fields_from_property(self, property) -> None:
- """generates the schema"""
+ def ds_build_fields_from_property(self, property) -> None: # noqa: D102
self.oneOf = []
if isinstance(property, ClassSelector):
if not property.isinstance:
@@ -418,7 +431,8 @@ def _move_own_type_to_oneOf(self):
class EnumSchema(SelectorSchema):
"""
- custom schema to fill enum field correctly
+ Custom schema to fill enum field correctly.
+
https://www.w3.org/TR/wot-thing-description11/#dataschema
"""
@@ -427,8 +441,7 @@ class EnumSchema(SelectorSchema):
def __init__(self):
super().__init__()
- def ds_build_fields_from_property(self, property) -> None:
- """generates the schema"""
+ def ds_build_fields_from_property(self, property) -> None: # noqa: D102
if not isinstance(property, Selector):
raise TypeError(f"EnumSchema compatible property is only Selector, not {property.__class__}")
self.enum = list(property.objects)
diff --git a/hololinked/td/forms.py b/hololinked/td/forms.py
index b28ef0a8..cbc23f48 100644
--- a/hololinked/td/forms.py
+++ b/hololinked/td/forms.py
@@ -1,3 +1,5 @@
+"""Implementation of Forms."""
+
from typing import Any, Optional
from pydantic import Field
@@ -8,8 +10,9 @@
class ExpectedResponse(Schema):
"""
- Form property.
- schema - https://www.w3.org/TR/wot-thing-description11/#expectedresponse
+ Form field for the expected response of an interaction.
+
+ https://www.w3.org/TR/wot-thing-description11/#expectedresponse
"""
contentType: str
@@ -21,7 +24,8 @@ def __init__(self):
class AdditionalExpectedResponse(Schema):
"""
Form field for additional responses which are different from the usual response.
- schema - https://www.w3.org/TR/wot-thing-description11/#additionalexpectedresponse
+
+ https://www.w3.org/TR/wot-thing-description11/#additionalexpectedresponse
"""
success: bool = Field(default=False)
@@ -35,7 +39,8 @@ def __init__(self):
class Form(Schema):
"""
Form hypermedia.
- schema - https://www.w3.org/TR/wot-thing-description11/#form
+
+ https://www.w3.org/TR/wot-thing-description11/#form
"""
href: str = None
@@ -78,5 +83,5 @@ def from_TD(cls, form_json: dict[str, Any]) -> "Form":
setattr(form, field, form_json[field])
return form
- def __str__(self) -> str:
+ def __str__(self) -> str: # noqa: D105
return f"Form(href={self.href}, op={self.op}, htv_methodName={self.htv_methodName}, contentType={self.contentType})"
diff --git a/hololinked/td/interaction_affordance.py b/hololinked/td/interaction_affordance.py
index b5b12c04..a64e5083 100644
--- a/hololinked/td/interaction_affordance.py
+++ b/hololinked/td/interaction_affordance.py
@@ -1,3 +1,5 @@
+"""Implementation of Interaction Affordances."""
+
import copy
from enum import Enum
@@ -20,7 +22,7 @@
class InteractionAffordance(Schema):
"""
- Implements schema information common to all interaction affordances.
+ Implements schema fields common to all interaction affordances, property, action or event.
[Specification Definitions](https://www.w3.org/TR/wot-thing-description11/#interactionaffordance)
[UML Diagram](https://docs.hololinked.dev/UML/PDF/InteractionAffordance.pdf)
@@ -46,14 +48,15 @@ def __init__(self):
@property
def what(self) -> Enum:
- """Whether it is a property, action or event"""
+ """Whether it is a property, action or event."""
raise NotImplementedError("Unknown interaction affordance - implement in subclass of InteractionAffordance")
@property
def owner(self) -> Thing:
"""
Owning `Thing` instance or `Thing` class of the interaction affordance.
- Depends on how this object was created, whether using an instance or a class.
+
+ Depending on how this object was created, returns either an instance or a class.
"""
return self._owner
@@ -75,12 +78,11 @@ def owner(self, value):
@property
def objekt(self) -> Property | Action | Event:
- """Object instance of the interaction affordance - `Property`, `Action` or `Event`"""
+ """Object instance of the interaction affordance, instance of `Property`, `Action` or `Event`."""
return self._objekt
@objekt.setter
def objekt(self, value: Property | Action | Event) -> None:
- """Set the object instance of the interaction affordance - `Property`, `Action` or `Event`"""
if self._objekt is not None:
raise ValueError(
f"object is already set for this {self.what.name.lower()} affordance, "
@@ -102,26 +104,26 @@ def objekt(self, value: Property | Action | Event) -> None:
@property
def name(self) -> str:
- """Name of the interaction affordance used as key in the TD"""
+ """Name of the interaction affordance used as key in the TD."""
return self._name
@property
def thing_id(self) -> str:
- """ID of the `Thing` instance owning the interaction affordance, if available, otherwise None"""
+ """ID of the `Thing` instance owning the interaction affordance, if available, otherwise None."""
return self._thing_id
@property
def thing_cls(self) -> ThingMeta:
- """`Thing` class owning the interaction affordance"""
+ """`Thing` class owning the interaction affordance."""
return self._thing_cls
def build(self) -> None:
- """Populate the fields of the schema for the specific interaction affordance"""
+ """Populate the fields of the schema for the specific interaction affordance."""
raise NotImplementedError("build must be implemented in subclass of InteractionAffordance")
def retrieve_form(self, op: str, default: Any = None) -> Form:
"""
- Retrieve form for a certain operation, return default if not found
+ Retrieve form for a certain operation, return default if not found.
Parameters
----------
@@ -145,7 +147,7 @@ def retrieve_form(self, op: str, default: Any = None) -> Form:
def pop_form(self, op: str, default: Any = None) -> Form:
"""
- Retrieve and remove form for a certain operation, return default if not found
+ Retrieve and remove form for a certain operation, return default if not found.
Parameters
----------
@@ -174,11 +176,12 @@ def generate(
owner: Thing,
) -> "PropertyAffordance | ActionAffordance | EventAffordance":
"""
- Build the schema for the specific interaction affordance as an instance of this class.
+ Instantitate and build the schema for the specific interaction affordance.
+
Use the `json()` method to get the JSON representation of the schema.
- Note that this method is different from build() method as its supposed to be used as a classmethod
- to create an instance. Although, it internally calls build(), and some additional steps are included.
+ Note that this method is different from `build()` method as its supposed to be used as a classmethod
+ to create an instance. Although, it internally calls `build()`, and some additional steps are included.
Parameters
----------
@@ -190,6 +193,7 @@ def generate(
Returns
-------
"PropertyAffordance | ActionAffordance | EventAffordance"
+ Instance of this class with the schema fields populated.
"""
raise NotImplementedError("generate_schema must be implemented in subclass of InteractionAffordance")
@@ -198,6 +202,9 @@ def from_TD(cls, name: str, TD: JSON) -> "PropertyAffordance | ActionAffordance
"""
Populate the schema from the TD and return it as an instance of this class.
+ You need to supply both the TD and the name of the affordance, because the affordance definition in the TD
+ does not include its name and determine its type.
+
Parameters
----------
name: str
@@ -208,6 +215,12 @@ def from_TD(cls, name: str, TD: JSON) -> "PropertyAffordance | ActionAffordance
Returns
-------
"PropertyAffordance | ActionAffordance | EventAffordance"
+ Instance of this class with the schema fields populated from the TD.
+
+ Raises
+ ------
+ ValueError
+ If the affordance type cannot be determined from the TD.
"""
if cls == PropertyAffordance:
affordance_name = "properties"
@@ -235,7 +248,23 @@ def register_descriptor(
descriptor: Property | Action | Event,
schema_generator: "InteractionAffordance",
) -> None:
- """Register a custom schema generator for a descriptor"""
+ """
+ Register a custom schema generator for a descriptor.
+
+ Parameters
+ ----------
+ descriptor: Property | Action | Event
+ The descriptor class
+ schema_generator: `InteractionAffordance`
+ `InteractionAffordance` subclass that implements the custom schema generation logic for the descriptor.
+ Either override the `generate()` method or the `build()` method.
+
+ Raises
+ ------
+ TypeError
+ If the descriptor is not an instance of `Property`, `Action` or `Event`, or if the schema generator is not an
+ instance of `InteractionAffordance`.
+ """
if not isinstance(descriptor, (Property, Action, Event)):
raise TypeError(
"custom schema generator can also be registered for Property." + f" Given type {type(descriptor)}"
@@ -248,12 +277,13 @@ def register_descriptor(
InteractionAffordance._custom_schema_generators[descriptor] = schema_generator
def build_non_compliant_metadata(self) -> None:
- """If by chance, there is additional non standard metadata to be added, they can be added here"""
+ """If there is additional non standard metadata to be added, they can be added here."""
pass
def override_defaults(self, **kwargs):
"""
Override default values with provided keyword arguments, especially thing_id, owner name, object name etc.
+
Any logic to trigger side effects while setting those values should be handled here.
"""
for key, value in kwargs.items():
@@ -268,17 +298,17 @@ def override_defaults(self, **kwargs):
elif hasattr(self, key) or key in self.model_fields:
setattr(self, key, value)
- def __hash__(self):
+ def __hash__(self): # noqa: D105
return hash(
self.thing_id if self.thing_id else "" + self.thing_cls.__name__ if self.thing_cls else "" + self.name
)
- def __str__(self):
+ def __str__(self): # noqa: D105
if self.thing_cls:
return f"{self.__class__.__name__}({self.thing_cls.__name__}({self.thing_id}).{self.name})"
return f"{self.__class__.__name__}({self.name} of {self.thing_id})"
- def __eq__(self, value):
+ def __eq__(self, value): # noqa: D105
if not isinstance(value, self.__class__):
return False
if self.thing_id is None or value.thing_id is None:
@@ -293,7 +323,7 @@ def __eq__(self, value):
return False
return self.thing_id == value.thing_id and self.name == value.name
- def __deepcopy__(self, memo):
+ def __deepcopy__(self, memo): # noqa: D105
if self.__class__ == PropertyAffordance:
result = PropertyAffordance()
elif self.__class__ == ActionAffordance:
@@ -306,7 +336,7 @@ def __deepcopy__(self, memo):
setattr(result, k, copy.deepcopy(v, memo))
return result
- def __getstate__(self):
+ def __getstate__(self): # noqa: D105
state = self.__dict__.copy()
# Remove possibly unpicklable entries
if "_owner" in state:
@@ -333,17 +363,17 @@ def __init__(self):
super().__init__()
@property
- def what(self) -> Enum:
+ def what(self) -> Enum: # noqa: D102
return ResourceTypes.PROPERTY
- def build(self) -> None:
+ def build(self) -> None: # noqa: D102
property = self.objekt
self.ds_build_from_property(property)
if property.observable:
self.observable = property.observable
@classmethod
- def generate(cls, property, owner=None):
+ def generate(cls, property, owner=None): # noqa: D102
if not isinstance(property, Property):
raise TypeError(f"property must be instance of Property, given type {type(property)}")
affordance = PropertyAffordance()
@@ -373,10 +403,10 @@ def __init__(self):
super().__init__()
@property
- def what(self):
+ def what(self): # noqa: D102
return ResourceTypes.ACTION
- def build(self) -> None:
+ def build(self) -> None: # noqa: D102
action = self.objekt # type: Action
if action.obj.__doc__:
title = get_summary(action.obj.__doc__)
@@ -419,7 +449,7 @@ def build(self) -> None:
self.safe = action.execution_info.safe
@classmethod
- def generate(cls, action: Action, owner, **kwargs) -> "ActionAffordance":
+ def generate(cls, action: Action, owner, **kwargs) -> "ActionAffordance": # noqa: D102
if not isinstance(action, Action):
raise TypeError(f"action must be instance of Action, given type {type(action)}")
affordance = ActionAffordance()
@@ -446,10 +476,10 @@ def __init__(self):
super().__init__()
@property
- def what(self):
+ def what(self): # noqa: D102
return ResourceTypes.EVENT
- def build(self) -> None:
+ def build(self) -> None: # noqa: D102
event = self.objekt # type: Event
if event.__doc__:
title = get_summary(event.doc)
@@ -468,7 +498,7 @@ def build(self) -> None:
raise ValueError(f"unknown schema definition for event data, given type: {type(event.schema)}")
@classmethod
- def generate(cls, event: Event, owner, **kwargs) -> "EventAffordance":
+ def generate(cls, event: Event, owner, **kwargs) -> "EventAffordance": # noqa: D102
if not isinstance(event, Event):
raise TypeError(f"event must be instance of Event, given type {type(event)}")
affordance = EventAffordance()
diff --git a/hololinked/td/metadata.py b/hololinked/td/metadata.py
index 4870a755..cb2ee768 100644
--- a/hololinked/td/metadata.py
+++ b/hololinked/td/metadata.py
@@ -1,3 +1,5 @@
+"""Include a general metadata like links, version info, etc. here."""
+
from typing import Optional
from pydantic import Field
@@ -7,8 +9,9 @@
class Link(Schema):
"""
- Represents a link in the link section of the TD
- schema - https://www.w3.org/TR/wot-thing-description11/#link
+ Impelements the Link schema for linking to other resources.
+
+ https://www.w3.org/TR/wot-thing-description11/#link
"""
href: str
@@ -20,7 +23,8 @@ class Link(Schema):
class VersionInfo(Schema):
"""
Represents version info.
- schema - https://www.w3.org/TR/wot-thing-description11/#versioninfo
+
+ https://www.w3.org/TR/wot-thing-description11/#versioninfo
"""
instance: str
diff --git a/hololinked/td/pydantic_extensions.py b/hololinked/td/pydantic_extensions.py
index 51718022..a62a1d80 100644
--- a/hololinked/td/pydantic_extensions.py
+++ b/hololinked/td/pydantic_extensions.py
@@ -1,3 +1,32 @@
+"""
+pydantic specific utility functions for the TD module.
+
+This module is largely copied from LabThings fast API.
+Copyright belongs to LabThings, Richard Bowman and developers, licensed under MIT License.
+
+MIT License
+
+Copyright (c) 2024 Richard William Bowman
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+"""
+
from __future__ import annotations
from typing import Any, Dict, List, Mapping, Optional, Sequence, Union
@@ -21,22 +50,51 @@
def is_a_reference(d: JSONSchema) -> bool:
"""
- Return True if a JSONSchema dict is a reference
+ Return True if a JSONSchema dict is a reference.
JSON Schema references are one-element dictionaries with
a single key, `$ref`. `pydantic` sometimes breaks this
rule and so I don't check that it's a single key.
+
+ Parameters
+ ----------
+ d: JSONSchema
+ The JSONSchema dict to check
+
+ Returns
+ -------
+ bool
+ True if the dict is a reference, False otherwise
"""
return "$ref" in d
def look_up_reference(reference: str, d: JSONSchema) -> JSONSchema:
"""
- Look up a reference in a JSONSchema
+ Look up a reference in a JSONSchema.
This first asserts the reference is local (i.e. starts with #
so it's relative to the current file), then looks up
each path component in turn.
+
+ Parameters
+ ----------
+ reference: str
+ The reference to look up, e.g. "#/components/schemas/MySchema"
+ d: JSONSchema
+ The JSONSchema dict to look up the reference in
+
+ Returns
+ -------
+ JSONSchema
+ The JSONSchema dict that the reference points to
+
+ Raises
+ ------
+ NotImplementedError
+ If the reference is not local (i.e. does not start with #)
+ KeyError
+ If the reference cannot be found in the JSONSchema
"""
if not reference.startswith("#/"):
raise NotImplementedError(
@@ -52,12 +110,36 @@ def look_up_reference(reference: str, d: JSONSchema) -> JSONSchema:
def is_an_object(d: JSONSchema) -> bool:
- """Determine whether a JSON schema dict is an object"""
+ """
+ Determine whether a JSON schema dict is an object.
+
+ Parameters
+ ----------
+ d : JSONSchema
+ The JSONSchema dict to check.
+
+ Returns
+ -------
+ bool
+ True if the dict represents an object type, False otherwise.
+ """
return "type" in d and d["type"] == "object"
def convert_object(d: JSONSchema) -> JSONSchema:
- """Convert an object from JSONSchema to Thing Description"""
+ """
+ Convert an object from JSONSchema to Thing Description.
+
+ Parameters
+ ----------
+ d : JSONSchema
+ The JSONSchema dict representing an object.
+
+ Returns
+ -------
+ JSONSchema
+ The converted JSONSchema dict compatible with Thing Description.
+ """
out: JSONSchema = d.copy()
# AdditionalProperties is not supported by Thing Description, and it is ambiguous
# whether this implies it's false or absent. I will, for now, ignore it, so we
@@ -69,12 +151,22 @@ def convert_object(d: JSONSchema) -> JSONSchema:
def convert_anyof(d: JSONSchema) -> JSONSchema:
"""
- Convert the anyof key to oneof
+ Convert the anyof key to oneof.
JSONSchema makes a distinction between "anyof" and "oneof", where the former
means "any of these fields can be present" and the latter means "exactly one
of these fields must be present". Thing Description does not have this
distinction, so we convert anyof to oneof.
+
+ Parameters
+ ----------
+ d : JSONSchema
+ The JSONSchema dict to convert.
+
+ Returns
+ -------
+ JSONSchema
+ The converted JSONSchema dict with ``anyOf`` replaced by ``oneOf``.
"""
if "anyOf" not in d:
return d
@@ -86,7 +178,7 @@ def convert_anyof(d: JSONSchema) -> JSONSchema:
def convert_prefixitems(d: JSONSchema) -> JSONSchema:
"""
- Convert the prefixitems key to items
+ Convert the prefixitems key to items.
JSONSchema 2019 (as used by thing description) used
`items` with a list of values in the same way that JSONSchema
@@ -98,6 +190,21 @@ def convert_prefixitems(d: JSONSchema) -> JSONSchema:
additional items, and we raise a ValueError if that happens.
This behaviour may be relaxed in the future.
+
+ Parameters
+ ----------
+ d : JSONSchema
+ The JSONSchema dict to convert.
+
+ Returns
+ -------
+ JSONSchema
+ The converted JSONSchema dict with ``prefixItems`` replaced by ``items``.
+
+ Raises
+ ------
+ ValueError
+ If the ``items`` key already exists in the schema, as it would be overwritten.
"""
if "prefixItems" not in d:
return d
@@ -110,7 +217,19 @@ def convert_prefixitems(d: JSONSchema) -> JSONSchema:
def convert_additionalproperties(d: JSONSchema) -> JSONSchema:
- """Move additionalProperties into properties, or remove it"""
+ """
+ Move additionalProperties into properties, or remove it.
+
+ Parameters
+ ----------
+ d : JSONSchema
+ The JSONSchema dict to convert.
+
+ Returns
+ -------
+ JSONSchema
+ The converted JSONSchema dict with ``additionalProperties`` moved or removed.
+ """
if "additionalProperties" not in d:
return d
out: JSONSchema = d.copy()
@@ -121,7 +240,21 @@ def convert_additionalproperties(d: JSONSchema) -> JSONSchema:
def check_recursion(depth: int, limit: int):
- """Check the recursion count is less than the limit"""
+ """
+ Check the recursion count is less than the limit.
+
+ Parameters
+ ----------
+ depth : int
+ The current recursion depth.
+ limit : int
+ The maximum allowed recursion depth.
+
+ Raises
+ ------
+ ValueError
+ If the recursion depth exceeds the limit.
+ """
if depth > limit:
raise ValueError(f"Recursion depth of {limit} exceeded - perhaps there is a circular reference?")
@@ -133,7 +266,7 @@ def jsonschema_to_dataschema(
recursion_limit: int = 99,
) -> JSONSchema:
"""
- Remove references and change field formats
+ Remove references and change field formats.
JSONSchema allows schemas to be replaced with `{"$ref": "#/path/to/schema"}`.
Thing Description does not allow this. `dereference_jsonschema_dict` takes a
@@ -146,6 +279,22 @@ def jsonschema_to_dataschema(
`DataSchema` objects. This function does not yet do that conversion.
This generates a copy of the document, to avoid messing up `pydantic`'s cache.
+
+ Parameters
+ ----------
+ d : JSONSchema
+ The JSONSchema dict to convert.
+ root_schema : JSONSchema, optional
+ The root JSONSchema document used to resolve ``$ref`` references. Defaults to ``d``.
+ recursion_depth : int, optional
+ The current recursion depth, used to detect circular references. Defaults to 0.
+ recursion_limit : int, optional
+ The maximum allowed recursion depth. Defaults to 99.
+
+ Returns
+ -------
+ JSONSchema
+ The converted JSONSchema dict compatible with Thing Description.
"""
root_schema = root_schema or d
check_recursion(recursion_depth, recursion_limit)
@@ -186,7 +335,7 @@ def jsonschema_to_dataschema(
def type_to_dataschema(t: Union[type, BaseModel], **kwargs) -> dict:
"""
- Convert a Python type to a Thing Description DataSchema
+ Convert a Python type to a Thing Description DataSchema.
This makes use of pydantic's `schema_of` function to create a
json schema, then applies some fixes to make a DataSchema
@@ -197,6 +346,19 @@ def type_to_dataschema(t: Union[type, BaseModel], **kwargs) -> dict:
and will override the fields generated from the type that
is passed in. Typically you'll want to use this for the
`title` field.
+
+ Parameters
+ ----------
+ t : type or BaseModel
+ The Python type or pydantic model to convert.
+ **kwargs : Any
+ Additional fields to merge into the resulting DataSchema, overriding any
+ auto-generated values.
+
+ Returns
+ -------
+ dict
+ The Thing Description DataSchema representation of the given type.
"""
if isinstance(t, BaseModel):
json_schema = t.model_json_schema(schema_generator=GenerateJsonSchemaWithoutDefaultTitles)
@@ -220,10 +382,23 @@ def type_to_dataschema(t: Union[type, BaseModel], **kwargs) -> dict:
class GenerateJsonSchemaWithoutDefaultTitles(GenerateJsonSchema):
- """Drops autogenerated titles from JSON Schema"""
+ """Drops autogenerated titles from JSON Schema."""
# https://stackoverflow.com/questions/78679812/pydantic-v2-to-json-schema-translation-how-to-suppress-autogeneration-of-title
def field_title_should_be_set(self, schema: CoreSchemaOrField) -> bool:
+ """Return False for core schemas to suppress autogenerated field titles.
+
+ Parameters
+ ----------
+ schema : CoreSchemaOrField
+ The pydantic core schema or field schema being evaluated.
+
+ Returns
+ -------
+ bool
+ False if the schema is a core schema and the parent would set a title,
+ otherwise the parent class result.
+ """
return_value = super().field_title_should_be_set(schema)
if return_value and is_core_schema(schema):
return False
diff --git a/hololinked/td/security_definitions.py b/hololinked/td/security_definitions.py
index 68ab1018..3411ef3c 100644
--- a/hololinked/td/security_definitions.py
+++ b/hololinked/td/security_definitions.py
@@ -1,3 +1,5 @@
+"""Implements security scheme definitions for the TD."""
+
from typing import Optional
from pydantic import Field
@@ -7,8 +9,9 @@
class SecurityScheme(Schema):
"""
- Represents a security scheme.
- schema - https://www.w3.org/TR/wot-thing-description11/#sec-security-vocabulary-definition
+ Subclass from here to implement Security Scheme metadata.
+
+ https://www.w3.org/TR/wot-thing-description11/#sec-security-vocabulary-definition
"""
scheme: str = None
@@ -20,47 +23,48 @@ def __init__(self):
super().__init__()
def build(self):
+ """Populate the security scheme metadata."""
raise NotImplementedError("Please implement specific security scheme builders")
class NoSecurityScheme(SecurityScheme):
- """No Security Scheme"""
+ """No Security Scheme."""
- def build(self):
+ def build(self): # noqa: D102
self.scheme = "nosec"
self.description = "currently no security scheme supported"
class BasicSecurityScheme(SecurityScheme):
- """Basic Security Scheme, username and password"""
+ """Basic Security Scheme - username and password."""
in_: str = Field(default="header", alias="in")
- def build(self):
+ def build(self): # noqa: D102
self.scheme = "basic"
self.description = "HTTP Basic Authentication"
self.in_ = "header"
class APIKeySecurityScheme(SecurityScheme):
- """API Key Security Scheme"""
+ """API Key Security Scheme."""
in_: str = Field(default="header", alias="in")
- def build(self):
+ def build(self): # noqa: D102
self.scheme = "apikey"
self.description = "API Key Authentication"
self.in_ = "header"
class OIDCSecurityScheme(SecurityScheme):
- """OIDC Security Scheme"""
+ """OIDC Security Scheme."""
scheme: str = "oauth2"
token: str = ""
scopes: list[str] = Field(default_factory=list)
- def build(self, token_url: str, scopes: list[str] | None = ["openid"]):
+ def build(self, token_url: str, scopes: list[str] | None = ["openid"]): # noqa: D102
self.description = "OpenID Connect Authentication"
self.token = token_url
if scopes is not None:
diff --git a/hololinked/td/tm.py b/hololinked/td/tm.py
index 42a5de7b..61ff148c 100644
--- a/hololinked/td/tm.py
+++ b/hololinked/td/tm.py
@@ -1,3 +1,9 @@
+"""
+Implemetation of Thing Model.
+
+Thing Descriptions are always generated by the specific protocols.
+"""
+
from typing import Any, Optional
from pydantic import ConfigDict, Field
@@ -16,10 +22,10 @@
class ThingModel(Schema):
"""
- Thing Model as per W3C WoT Thing Description v1.1
+ Thing Model as per W3C WoT Thing Description v1.1.
- [Specification](https://www.w3.org/TR/wot-thing-description11/)
- [UML Diagram](https://docs.hololinked.dev/UML/PDF/ThingModel.pdf)
+ - [Specification](https://www.w3.org/TR/wot-thing-description11/)
+ - [UML Diagram](https://docs.hololinked.dev/UML/PDF/ThingModel.pdf)
"""
context: list[str | dict[str, str]] = Field(["https://www.w3.org/2022/wot/td/v1.1"], alias="@context")
@@ -52,7 +58,13 @@ def __init__(
self.skip_names = skip_names or []
def generate(self) -> "ThingModel":
- """populate the thing model"""
+ """
+ Populate the thing model.
+
+ Returns
+ -------
+ ThingModel
+ """
self.id = self.instance.id
self.title = self.instance.__class__.__name__
self.context = ["https://www.w3.org/2022/wot/td/v1.1"]
@@ -66,7 +78,7 @@ def generate(self) -> "ThingModel":
return self
def produce(self) -> Thing:
- """produce a Thing instance from the Thing Model, not implemented yet"""
+ """Produce a Thing instance from the Thing Model, not implemented yet."""
raise NotImplementedError("This will be implemented in a future release for an API first approach")
# not the best code and logic, but works for now
@@ -87,7 +99,7 @@ def produce(self) -> Thing:
"""list of event names to skip when generating the TD"""
def add_interaction_affordances(self) -> None:
- """add interaction affordances to thing model"""
+ """Add interaction affordances to thing model."""
for affordance, items, affordance_cls, skip_list in [
["properties", self.instance.properties.remote_objects.items(), PropertyAffordance, self.skip_properties],
["actions", self.instance.actions.descriptors.items(), ActionAffordance, self.skip_actions],
@@ -114,7 +126,14 @@ def add_interaction_affordances(self) -> None:
self.instance.logger.error(f"Error while generating schema for {name} - {ex}")
def model_dump(self, **kwargs) -> dict[str, Any]:
- """Return the JSON representation of the schema"""
+ """
+ Return the JSON representation of the schema.
+
+ Returns
+ -------
+ dict[str, Any]
+ JSON representation
+ """
def dump_value(value):
nonlocal kwargs
diff --git a/hololinked/td/utils.py b/hololinked/td/utils.py
index 6d464ab0..e2e3cf70 100644
--- a/hololinked/td/utils.py
+++ b/hololinked/td/utils.py
@@ -1,9 +1,11 @@
+"""utility functions for the TD module."""
+
from typing import Optional
def get_summary(docs: str) -> Optional[str]:
"""
- Return the first line of the dosctring of an object
+ Return the first line of the docstring of an object.
Parameters
----------
diff --git a/pyproject.toml b/pyproject.toml
index 5fb85d56..fc5515f2 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -127,6 +127,7 @@ extend-select = [
]
ignore = [
"G201", # logging-exc-info: do not require exc_info in logger.exception calls
+ "D401", # imperative mode for summary line
]
[tool.ruff.lint.per-file-ignores]
From e0d97b3ca3a7f0655726b6f1060a06a4f44894d8 Mon Sep 17 00:00:00 2001
From: Vignesh Venkatasubramanian Vaidyanathan
<62492557+VigneshVSV@users.noreply.github.com>
Date: Sun, 26 Apr 2026 10:46:08 +0200
Subject: [PATCH 10/15] Mypy db (#175)
* do db folder
* Mypy serializers (#174)
* fix serializer doc
* run ruff and ty
* complete schema validators as well
* Update based on diff
* optimize based on DB diff
---
hololinked/constants.py | 1 +
hololinked/schema_validators/__init__.py | 10 +
hololinked/schema_validators/json_schema.py | 112 +++++---
hololinked/schema_validators/validators.py | 120 +++++++--
hololinked/serializers/__init__.py | 2 +
hololinked/serializers/payloads.py | 38 ++-
hololinked/serializers/serializers.py | 177 ++++++++++---
hololinked/storage/__init__.py | 16 +-
hololinked/storage/config.py | 21 +-
hololinked/storage/database.py | 279 +++++++++++++++-----
hololinked/storage/json_storage.py | 28 +-
hololinked/storage/models.py | 28 +-
hololinked/storage/utils.py | 4 +-
hololinked/utils.py | 25 +-
pyproject.toml | 3 +-
15 files changed, 661 insertions(+), 203 deletions(-)
diff --git a/hololinked/constants.py b/hololinked/constants.py
index 09b1d75a..6507c7d2 100644
--- a/hololinked/constants.py
+++ b/hololinked/constants.py
@@ -8,6 +8,7 @@
# types
JSONSerializable = typing.Union[str, int, float, bool, None, typing.Dict[str, typing.Any], typing.List]
JSON = typing.Dict[str, JSONSerializable]
+JSONSchema = typing.Dict[str, str | typing.Dict[str, typing.Any] | typing.List[typing.Dict[str, typing.Any]]]
byte_types = (bytes, bytearray, memoryview)
diff --git a/hololinked/schema_validators/__init__.py b/hololinked/schema_validators/__init__.py
index 307db4b4..eac2874c 100644
--- a/hololinked/schema_validators/__init__.py
+++ b/hololinked/schema_validators/__init__.py
@@ -1,2 +1,12 @@
+"""
+Validators for validating data against schemas.
+
+All of properties, actions and events validate can their payload or types against the schema.
+For properties and actions, validation is carried out right before carrying out the operation.
+For events, such validation is missing on the client. For properties and actions, said validation is also missing
+on the client. This is an architectural error that needs to be fixed. The current architecture of the package leads
+to duplication of code if this is implemented as is. Therefore it has been left out by choice.
+"""
+
from .validators import BaseSchemaValidator, JSONSchemaValidator, PydanticSchemaValidator # noqa
from .json_schema import JSONSchema # noqa
diff --git a/hololinked/schema_validators/json_schema.py b/hololinked/schema_validators/json_schema.py
index 2a4d3709..58585133 100644
--- a/hololinked/schema_validators/json_schema.py
+++ b/hololinked/schema_validators/json_schema.py
@@ -1,3 +1,5 @@
+"""JSON Schema type management."""
+
from typing import Any
from ..constants import JSON
@@ -5,8 +7,19 @@
class JSONSchema:
"""
- Type management object for converting python types to JSON schema types,
- generally used for WoT Thing Descriptions.
+ JSON Schema type management.
+
+ Handles converting highly specific python types to JSON schema types.
+ One needs to explicitly register such python types with the `register_type_replacement` method to be able to
+ insert JSON schema in JSON documents (like the Thing Description).
+
+ ```python
+ JSONSchema.register_type_replacement(Image, 'string', schema=dict(contentEncoding='base64'))
+ JSONSchema.register_type_replacement(MyCustomObject, 'object', schema=MyCustomObject.schema())
+ ```
+
+ Validation of JSON schema, say for properties or action payloads, is carried out by the `JSONSchemaValidator`
+ class which is separate.
"""
_allowed_types = ("string", "number", "integer", "boolean", "object", "array", None)
@@ -31,15 +44,16 @@ class JSONSchema:
},
"required": ["message", "type", "traceback"],
},
- }
+ } # type: dict[type, str | dict]
_schemas = {}
@classmethod
def is_allowed_type(cls, typ: Any) -> bool:
"""
- Check if a certain base type has a JSON schema base type
- For example,
+ Check if a certain base type has a JSON schema base type.
+
+ For example:
```python
JSONSchema.is_allowed_type(int) # returns True
@@ -49,26 +63,6 @@ def is_allowed_type(cls, typ: Any) -> bool:
JSONSchema.is_allowed_type(MyCustomClass) # returns True
```
- Parameters
- ----------
- typ: Any
- the python type to check
- """
- if typ in JSONSchema._replacements.keys():
- return True
- return False
-
- @classmethod
- def has_additional_schema_definitions(cls, typ: Any) -> bool:
- """
- Check, if in additional to the JSON schema base type, additional schema definitions exists.
- Utility function to decide where to insert additional schema definitions in a JSON document.
-
- ```python
- JSONSchema.register_type_replacement(Image, 'string', schema=dict(contentEncoding='base64'))
- JSONSchema.has_additional_schema_definitions(Image) # returns True
- ```
-
Parameters
----------
typ: Any
@@ -77,16 +71,16 @@ def has_additional_schema_definitions(cls, typ: Any) -> bool:
Returns
-------
bool
- True, if additional schema definitions exist for the type
+ True or False
"""
- if typ in JSONSchema._schemas.keys():
+ if typ in JSONSchema._replacements.keys():
return True
return False
@classmethod
def get_base_type(cls, typ: Any) -> str:
"""
- Get the JSON schema base type for a certain python type
+ Get the JSON schema base type for a certain python type.
```python
JSONSchema.register_type_replacement(MyCustomObject, 'object', schema=MyCustomObject.schema())
@@ -97,13 +91,28 @@ def get_base_type(cls, typ: Any) -> str:
----------
typ: Any
the python type to get the JSON schema base type
+
+ Returns
+ -------
+ str
+ the JSON schema base type
+
+ Raises
+ ------
+ TypeError
+ If the type is not natively supported in JSON schema or is not registered for conversion.
"""
if not JSONSchema.is_allowed_type(typ):
raise TypeError(
f"Object for wot-td has invalid type for JSON conversion. Given type - {type(typ)}. "
+ "Use JSONSchema.register_replacements on hololinked.schema_validators.JSONSchema object to recognise the type."
)
- return JSONSchema._replacements[typ]
+ typ = JSONSchema._replacements[typ]
+ if isinstance(typ, str):
+ return typ
+ if isinstance(typ, dict) and "type" in typ:
+ return typ["type"] # type: ignore[invalid-return-type]
+ return "object"
@classmethod
def register_type_replacement(self, type: Any, json_schema_base_type: str, schema: JSON | None = None) -> None:
@@ -125,6 +134,11 @@ def register_type_replacement(self, type: Any, json_schema_base_type: str, schem
('string', 'number', 'integer', 'boolean', 'object', 'array', 'null').
schema: Optional[JSON]
An optional JSON schema to use for the type.
+
+ Raises
+ ------
+ TypeError
+ If the provided JSON schema base type is not one of the allowed types.
"""
if json_schema_base_type in JSONSchema._allowed_types:
JSONSchema._replacements[type] = json_schema_base_type
@@ -136,9 +150,47 @@ def register_type_replacement(self, type: Any, json_schema_base_type: str, schem
+ f"'number', 'integer', 'boolean', 'null'. Given value {json_schema_base_type}"
)
+ @classmethod
+ def has_additional_schema_definitions(cls, typ: Any) -> bool:
+ """
+ Check, if in additional to the JSON schema base type, additional schema definitions exists.
+
+ Utility function to decide where to insert additional schema definitions in a JSON document.
+
+ ```python
+ JSONSchema.register_type_replacement(Image, 'string', schema=dict(contentEncoding='base64'))
+ JSONSchema.has_additional_schema_definitions(Image) # returns True
+ ```
+
+ Parameters
+ ----------
+ typ: Any
+ the python type to check
+
+ Returns
+ -------
+ bool
+ True, if additional schema definitions exist for the type
+ """
+ if typ in JSONSchema._schemas.keys():
+ return True
+ return False
+
@classmethod
def get_additional_schema_definitions(cls, typ: Any):
- """retrieve additional schema definitions for a certain python type"""
+ """
+ Retrieve additional schema definitions for a certain python type.
+
+ Returns
+ -------
+ JSON
+ the additional schema definitions for the type
+
+ Raises
+ ------
+ ValueError
+ If no additional schema definitions exist for the type.
+ """
if not JSONSchema.has_additional_schema_definitions(typ):
raise ValueError(f"Schema for {typ} not provided. register one with JSONSchema.register_type_replacement()")
return JSONSchema._schemas[typ]
diff --git a/hololinked/schema_validators/validators.py b/hololinked/schema_validators/validators.py
index b8b45aca..55219ecb 100644
--- a/hololinked/schema_validators/validators.py
+++ b/hololinked/schema_validators/validators.py
@@ -1,57 +1,89 @@
+"""Concrete implementations of schema validators."""
+
import jsonschema
from pydantic import BaseModel
-from ..constants import JSON
+from ..constants import JSONSchema
from ..utils import json_schema_merge_args_to_kwargs, pydantic_validate_args_kwargs
-class BaseSchemaValidator: # type definition
+# type definition
+class BaseSchemaValidator:
"""
Base class for all schema validators.
+
Serves as a type definition.
"""
- def __init__(self, schema: JSON | BaseModel) -> None:
+ def __init__(self, schema) -> None:
self.schema = schema
def validate(self, data) -> None:
- """validate the data against the schema"""
+ """Validate the data against the schema."""
raise NotImplementedError("validate method must be implemented by subclass")
def validate_method_call(self, args, kwargs) -> None:
- """validate the method call against the schema"""
+ """Validate the method call against the schema."""
raise NotImplementedError("validate_method_call method must be implemented by subclass")
+ def json(self) -> JSONSchema:
+ """Allows JSON serialization of the validator instance itself."""
+ raise NotImplementedError("json method must be implemented by subclass")
+
+ def __get_state__(self):
+ return self.json()
+
+ def __set_state__(self, schema):
+ raise NotImplementedError("__set_state__ method must be implemented by subclass")
+
class JSONSchemaValidator(BaseSchemaValidator):
"""
- JSON schema validator according to standard python JSON schema.
- Somewhat slow, consider `FastJSONSchemaValidator` (`pip install fastjsonschema`) or
- pydantic annotation based validation if possible.
+ JSON schema validator extending the standard python JSON schema package.
+
+ ```python
+ power_supply_output_schema = {
+ "type": "object",
+ "properties": {
+ "current": {"type": "number", "minimum": 0},
+ "power": {"type": "number", "minimum": 0, "maximum": 100},
+ },
+ }
+ validator = JSONSchemaValidator(power_supply_output_schema)
+ validator.validate({"current": 50, "power": 75}) # valid
+ validator.validate({"current": 65, "power": 110}) # raises
+ ```
+
+ This class is largely used internally and there is no need to explicitly instantiate it.
+
+ Consider `FastJSONSchemaValidator` (`pip install fastjsonschema`) or
+ pydantic annotation based validation for performance if necessary.
"""
- def __init__(self, schema) -> None:
+ def __init__(self, schema: JSONSchema) -> None:
"""
+ Initialize the validator.
+
Parameters
----------
- schema: JSON
+ schema: JSONSchema
The JSON schema to validate against
"""
jsonschema.Draft7Validator.check_schema(schema)
super().__init__(schema)
self.validator = jsonschema.Draft7Validator(schema)
- def validate(self, data) -> None:
+ def validate(self, data) -> None: # noqa: D102
self.validator.validate(data)
- def validate_method_call(self, args, kwargs) -> None:
+ def validate_method_call(self, args, kwargs) -> None: # noqa: D102
if len(args) > 0:
kwargs = json_schema_merge_args_to_kwargs(self.schema, args, kwargs)
+ # TODO fix type definition
self.validate(kwargs)
- def json(self) -> JSON:
- """allows JSON (de-)serializable of the instance itself"""
+ def json(self) -> JSONSchema: # noqa: D102
return self.schema
def __get_state__(self):
@@ -62,10 +94,27 @@ def __set_state__(self, schema):
class PydanticSchemaValidator(BaseSchemaValidator):
- """Schema validator according to pydantic models"""
+ """
+ Pydantic model validator.
+
+ ```python
+ class PowerSupplyOutput(BaseModel):
+ current: float = Field(..., ge=0)
+ power: float = Field(..., ge=0, le=100)
+
+ validator = PydanticSchemaValidator(PowerSupplyOutput)
+ validator.validate({"current": 50, "power": 75}) # valid
+ validator.validate({"current": 65, "power": 110}) # raises
+ ```
+
+ The user is encouraged to use pydantic models as much as possible. This class is largely used internally and
+ there is no need to explicitly instantiate it.
+ """
def __init__(self, schema: BaseModel) -> None:
"""
+ Initialize the validator.
+
Parameters
----------
schema: BaseModel
@@ -74,20 +123,19 @@ def __init__(self, schema: BaseModel) -> None:
super().__init__(schema)
self.validator = schema.model_validate
- def validate(self, data) -> None:
+ def validate(self, data) -> None: # noqa: D102
self.validator(data)
- def validate_method_call(self, args, kwargs) -> None:
+ def validate_method_call(self, args, kwargs) -> None: # noqa: D102
pydantic_validate_args_kwargs(self.schema, args, kwargs)
- def json(self) -> JSON:
- """allows JSON (de-)serializable of the instance itself"""
+ def json(self) -> JSONSchema: # noqa: D102
return self.schema.model_dump_json()
def __get_state__(self):
return self.json()
- def __set_state__(self, schema: JSON):
+ def __set_state__(self, schema: JSONSchema):
return PydanticSchemaValidator(BaseModel(**schema))
@@ -95,33 +143,49 @@ def __set_state__(self, schema: JSON):
import fastjsonschema
class FastJSONSchemaValidator(BaseSchemaValidator):
- """JSON schema validator according to fast JSON schema"""
+ """
+ JSON schema validator according to fast JSON schema.
+
+ `pip install fastjsonschema` to use.
+
+ ```python
+ power_supply_output_schema = {
+ "type": "object",
+ "properties": {
+ "current": {"type": "number", "minimum": 0},
+ "power": {"type": "number", "minimum": 0, "maximum": 100},
+ },
+ }
+ validator = JSONSchemaValidator(power_supply_output_schema)
+ validator.validate({"current": 50, "power": 75}) # valid
+ validator.validate({"current": 65, "power": 110}) # raises
+ ```
+ """
# Useful for performance with dictionary based schema specification
# which msgspec has no built in support. Normally, for speed,
# one should try to use msgspec's struct concept.
- def __init__(self, schema: JSON) -> None:
+ def __init__(self, schema: JSONSchema) -> None:
super().__init__(schema)
self.validator = fastjsonschema.compile(schema)
- def validate(self, data) -> None:
- """validates and raises exception when failed directly to the caller"""
+ def validate(self, data) -> None: # noqa: D102
self.validator(data)
- def validate_method_call(self, args, kwargs) -> None:
+ def validate_method_call(self, args, kwargs) -> None: # noqa: D102
if len(args) > 0:
kwargs = json_schema_merge_args_to_kwargs(self.schema, args, kwargs)
+ # TODO fix type definition
self.validate(kwargs)
- def json(self) -> JSON:
- """allows JSON (de-)serializable of the instance itself"""
+ def json(self) -> JSONSchema: # noqa: D102
return self.schema
def __get_state__(self):
return self.schema
- def __set_state__(self, schema):
+ def __set_state__(self, schema: JSONSchema):
return FastJSONSchemaValidator(schema)
except ImportError:
diff --git a/hololinked/serializers/__init__.py b/hololinked/serializers/__init__.py
index 7b1aefe9..3c77f352 100644
--- a/hololinked/serializers/__init__.py
+++ b/hololinked/serializers/__init__.py
@@ -1,3 +1,5 @@
+"""Concrete implementations of serializers."""
+
from .serializers import ( # noqa: F401
BaseSerializer,
JSONSerializer,
diff --git a/hololinked/serializers/payloads.py b/hololinked/serializers/payloads.py
index c46b3e59..315254b4 100644
--- a/hololinked/serializers/payloads.py
+++ b/hololinked/serializers/payloads.py
@@ -1,3 +1,5 @@
+"""Generic reusable payload dataclasses for serialization and deserialization of data from/to different formats."""
+
from dataclasses import dataclass
from typing import Any
@@ -9,6 +11,7 @@
class SerializableData:
"""
A container for data that can be serialized.
+
Either provide a serializer or a content type to pick a suitable already supported serializer.
"""
@@ -17,8 +20,20 @@ class SerializableData:
content_type: str = "application/json"
_serialized: bytes | None = None
- def serialize(self):
- """serialize the value"""
+ def serialize(self) -> bytes:
+ """
+ Serialize the value.
+
+ Returns
+ -------
+ bytes
+ serialized value
+
+ Raises
+ ------
+ ValueError
+ If no suitable serializer is found for the content type.
+ """
if self._serialized is not None:
return self._serialized
if isinstance(self.value, byte_types):
@@ -30,8 +45,20 @@ def serialize(self):
return serializer.dumps(self.value)
raise ValueError(f"content type {self.content_type} not supported for serialization")
- def deserialize(self):
- """deserialize the value"""
+ def deserialize(self) -> Any:
+ """
+ Deserialize the value.
+
+ Returns
+ -------
+ Any
+ deserialized value
+
+ Raises
+ ------
+ ValueError
+ If no suitable serializer is found for the content type.
+ """
if not isinstance(self.value, byte_types):
return self.value
if self.serializer is not None:
@@ -42,7 +69,7 @@ def deserialize(self):
raise ValueError(f"content type {self.content_type} not supported for deserialization")
def require_serialized(self) -> None:
- """ensure the value is serialized"""
+ """Ensure the value is serialized. Can raise `ValueError` if no suitable serializer is found."""
self._serialized = self.serialize()
@@ -50,6 +77,7 @@ def require_serialized(self) -> None:
class PreserializedData:
"""
A container for data that is already serialized.
+
The content type is only a metadata here. The value is expected to be bytes.
"""
diff --git a/hololinked/serializers/serializers.py b/hololinked/serializers/serializers.py
index ee77f921..764f57dd 100644
--- a/hololinked/serializers/serializers.py
+++ b/hololinked/serializers/serializers.py
@@ -1,4 +1,6 @@
"""
+Concrete implementations of serializers.
+
adopted from pyro - https://github.com/irmen/Pyro5 - see following license
MIT License
@@ -64,11 +66,12 @@
class BaseSerializer(object):
"""
- Base class for (de)serializer implementations. All serializers must inherit this class
- and overload dumps() and loads() to be usable. Any serializer
- that returns bytes when serialized and a python object on deserialization will be accepted.
- Serialization and deserialization errors will be passed as invalid message type
- from server side and a exception will be raised on the client.
+ Base class for (de)serializer implementations.
+
+ All serializers must inherit this class and overload dumps() and loads() to be usable. Any serializer
+ that returns bytes when serialized and a python object on deserialization will be accepted. Serialization and
+ deserialization errors will be passed as invalid message type from server side and a exception will be raised on
+ the client.
"""
def __init__(self) -> None:
@@ -76,15 +79,26 @@ def __init__(self) -> None:
self.type = None
def loads(self, data) -> Any:
- """deserialize data"""
+ """Deserialize data."""
raise NotImplementedError("implement loads()/deserialization in subclass")
def dumps(self, data) -> bytes:
- """serialize data"""
+ """Serialize data."""
raise NotImplementedError("implement dumps()/serialization in subclass")
def convert_to_bytes(self, data) -> bytes:
- """convert data to bytes if it is bytearray or memoryview"""
+ """
+ Convert data to bytes if it is bytearray or memoryview.
+
+ Returns
+ -------
+ bytes
+
+ Raises
+ ------
+ TypeError
+ if data is not bytes, bytearray or memoryview
+ """
if isinstance(data, bytes):
return data
if isinstance(data, bytearray):
@@ -97,7 +111,7 @@ def convert_to_bytes(self, data) -> bytes:
@property
def content_type(self) -> str:
- """content type of the serializer"""
+ """Content type of the serializer."""
raise NotImplementedError("serializer must implement a content type")
@@ -105,7 +119,7 @@ def content_type(self) -> str:
class JSONSerializer(BaseSerializer):
- """(de)serializer that wraps the msgspec JSON serialization protocol, default serializer for hololinked"""
+ """Serializer that wraps the msgspec JSON serialization protocol, default serializer for this package."""
_type_replacements = {}
@@ -120,8 +134,28 @@ def dumps(self, data) -> bytes:
return msgspecjson.encode(data, enc_hook=self.default)
@classmethod
- def default(cls, obj) -> JSONSerializable:
- """method called if no serialization option was found"""
+ def default(cls, obj: Any) -> JSONSerializable:
+ """
+ Method called if object is not serializable by default JSON encoder.
+
+ To override, one can subclass and implement default method and call `super().default()` at the very end.
+ Or one can directly register a type with `register_type_replacement()`.
+
+ Parameters
+ ----------
+ obj: Any
+ the object to be serialized
+
+ Returns
+ -------
+ JSONSerializable
+ a JSON serializable representation of the object, not bytes.
+
+ Raises
+ ------
+ TypeError
+ if the object cannot be serialized to JSON
+ """
if hasattr(obj, "model_dump"):
return obj.model_dump()
if hasattr(obj, "json"):
@@ -159,7 +193,22 @@ def default(cls, obj) -> JSONSerializable:
@classmethod
def register_type_replacement(cls, object_type, replacement_function) -> None:
- """register custom serialization function for a particular type"""
+ """
+ Register custom serialization function for a particular type.
+
+ Parameters
+ ----------
+ object_type: type
+ the type for which the replacement function is registered
+ replacement_function: Function
+ the function that takes an object of the given type and returns a JSON serializable representation of
+ the object. `bytes` are not expected, only the JSON serializable representation.
+
+ Raises
+ ------
+ ValueError
+ if the object_type is not a type or is the type 'type' itself
+ """
if object_type is type or not inspect.isclass(object_type):
raise ValueError("refusing to register replacement for a non-type or the type 'type' itself")
cls._type_replacements[object_type] = replacement_function
@@ -170,7 +219,7 @@ def content_type(self) -> str:
class PythonBuiltinJSONSerializer(JSONSerializer):
- """(de)serializer that wraps the python builtin JSON serialization protocol"""
+ """Serializer that wraps the python builtin JSON serializer."""
def __init__(self) -> None:
super().__init__()
@@ -185,12 +234,19 @@ def dumps(self, data) -> bytes:
@classmethod
def dump(cls, data: dict[str, Any], file_desc) -> None:
- """write JSON to file"""
+ """Write JSON to file."""
pythonjson.dump(data, file_desc, ensure_ascii=False, allow_nan=True, default=cls.default)
@classmethod
- def load(cls, file_desc) -> JSONSerializable:
- """load JSON from file"""
+ def load(cls, file_desc) -> Any:
+ """
+ Load JSON from file.
+
+ Returns
+ -------
+ Any
+ the deserialized JSON object
+ """
return pythonjson.load(file_desc)
@@ -220,8 +276,9 @@ def content_type(self) -> str:
class MsgpackSerializer(BaseSerializer):
"""
- (de)serializer that wraps the msgspec MessagePack serialization protocol, recommended serializer for
- highspeed applications.
+ Serializer that wraps the msgspec MessagePack serialization protocol.
+
+ Recommended serializer for highspeed applications.
"""
def __init__(self) -> None:
@@ -230,11 +287,11 @@ def __init__(self) -> None:
codes = dict(NDARRAY_EXT=1)
- def dumps(self, value) -> bytes:
- return msgpack.encode(value, enc_hook=self.default_encode)
+ def dumps(self, data) -> bytes:
+ return msgpack.encode(data, enc_hook=self.default_encode)
- def loads(self, value) -> Any:
- return msgpack.decode(self.convert_to_bytes(value), ext_hook=self.ext_decode)
+ def loads(self, data) -> Any:
+ return msgpack.decode(self.convert_to_bytes(data), ext_hook=self.ext_decode)
@classmethod
def default_encode(cls, obj) -> Any:
@@ -259,7 +316,7 @@ def content_type(self) -> str:
class TextSerializer(BaseSerializer):
- """Converts string or string compatible types to bytes and vice versa"""
+ """Converts string or string compatible types to bytes and vice versa."""
def __init__(self) -> None:
super().__init__()
@@ -280,7 +337,7 @@ def content_type(self) -> str:
import serpent
class SerpentSerializer(BaseSerializer):
- """(de)serializer that wraps the serpent serialization protocol."""
+ """Serializer that wraps the serpent serialization protocol."""
def __init__(self) -> None:
super().__init__()
@@ -294,7 +351,22 @@ def loads(self, data) -> Any:
@classmethod
def register_type_replacement(cls, object_type, replacement_function) -> None:
- """register custom serialization function for a particular type"""
+ """
+ Register custom serialization function for a particular type.
+
+ Parameters
+ ----------
+ object_type: type
+ the type for which the replacement function is registered
+ replacement_function: Function
+ the function that takes an object of the given type and returns a JSON serializable representation of
+ the object, not bytes.
+
+ Raises
+ ------
+ ValueError
+ if the object_type is not a type or is the type 'type' itself
+ """
def custom_serializer(obj, serpent_serializer, outputstream, indentlevel):
replaced = replacement_function(obj)
@@ -309,14 +381,14 @@ def custom_serializer(obj, serpent_serializer, outputstream, indentlevel):
# __all__.append(SerpentSerializer.__name__)
except ImportError:
- SerpentSerializer = None
+ pass
class Serializers(metaclass=MappableSingleton):
"""
A singleton class that holds all serializers and provides a registry for content types.
- All members are class attributes and settings are applied process-wide (python process).
+ All members are class attributes and settings are applied process-wide (python process).
Registration of serializer is not mandatory for any property, action or event.
The default serializer is `JSONSerializer`, which will be provided to any unregistered object.
"""
@@ -418,7 +490,9 @@ class Serializers(metaclass=MappableSingleton):
@classmethod
def register(cls, serializer: BaseSerializer, name: str | None = None, override: bool = False) -> None:
"""
- Register a new serializer. It is recommended to implement a content type property/attribute for the serializer
+ Register a new serializer to be generally available for the running application.
+
+ It is recommended to implement a content type property/attribute for the serializer
to facilitate automatic deserialization on client side, otherwise deserialization is not gauranteed.
Moreover, the said serializer must be defined on both client and server side if running in a distributed
environment.
@@ -446,12 +520,12 @@ def register(cls, serializer: BaseSerializer, name: str | None = None, override:
cls.content_types[serializer.content_type] = serializer
except NotImplementedError:
warnings.warn("serializer does not implement a content type", category=UserWarning)
- cls[name or serializer.__name__] = serializer
+ cls[name or serializer.__class__.__name__] = serializer
@classmethod
- def for_object(cls, thing_id: str, thing_cls: str, objekt: str) -> BaseSerializer | None:
+ def for_object(cls, thing_id: str, thing_cls: str, objekt: str) -> BaseSerializer:
"""
- Retrieve a serializer for a given property, action or event
+ Retrieve a serializer for a given property, action or event.
Parameters
----------
@@ -483,7 +557,7 @@ def for_object(cls, thing_id: str, thing_cls: str, objekt: str) -> BaseSerialize
@classmethod
def get_content_type_for_object(self, thing_id: str, thing_cls: str, objekt: str) -> str:
"""
- Retrieve a content type for a given property, action or event
+ Retrieve a content type for a given property, action or event.
Parameters
----------
@@ -500,7 +574,6 @@ def get_content_type_for_object(self, thing_id: str, thing_cls: str, objekt: str
the content type for the property, action or event. If no content type is found, the default content type is
returned.
"""
-
if len(self.object_serializer_map) == 0 and len(self.object_content_type_map) == 0:
return self.default_content_type
for thing in [thing_id, thing_cls]: # first thing id, then thing cls
@@ -512,8 +585,9 @@ def get_content_type_for_object(self, thing_id: str, thing_cls: str, objekt: str
@classmethod
def register_for_object(cls, objekt: Any, serializer: BaseSerializer) -> None:
"""
- Register (an existing) serializer for a property, action or event. Other option is to register a content type,
- the effects are similar.
+ Register (an existing) serializer for a property, action or event.
+
+ Other option is to register a content type, the effects are similar.
Parameters
----------
@@ -521,6 +595,11 @@ def register_for_object(cls, objekt: Any, serializer: BaseSerializer) -> None:
the property, action or event
serializer: BaseSerializer
the serializer to be used
+
+ Raises
+ ------
+ ValueError
+ if the object is not a Property, Action or Event, or Thing class
"""
if not isinstance(serializer, BaseSerializer):
raise ValueError("serializer must be an instance of BaseSerializer, given : {}".format(type(serializer)))
@@ -546,6 +625,7 @@ def register_for_object(cls, objekt: Any, serializer: BaseSerializer) -> None:
def register_content_type_for_object(cls, objekt: Any, content_type: str) -> None:
"""
Register content type for a property, action, event, or a `Thing` class to use a specific serializer.
+
If no serializer is found, content type could still be used as metadata.
Parameters
@@ -587,8 +667,9 @@ def register_content_type_for_object_per_thing_instance(
content_type: str,
) -> None:
"""
- Register a content type for a property, action or event to use a specific serializer. Other option is
- to register a serializer directly, the effects are similar. If no serializer is found,
+ Register a content type for a property, action or event to use a specific serializer.
+
+ Other option is to register a serializer directly, the effects are similar. If no serializer is found,
content type could still be used as metadata.
Parameters
@@ -599,6 +680,11 @@ def register_content_type_for_object_per_thing_instance(
the name of the property, action or event
content_type: str
the content type to be used
+
+ Raises
+ ------
+ ValueError
+ if the object is not a Property, Action or Event
"""
from ..core import Action, Event, Property
@@ -629,6 +715,7 @@ def register_content_type_for_thing_instance(cls, thing_id: str, content_type: s
def register_for_object_per_thing_instance(cls, thing_id: str, objekt: str, serializer: BaseSerializer) -> None:
"""
Register a serializer for a property, action or event for a specific Thing instance.
+
If no serializer is found, content type could still be used as metadata.
Parameters
@@ -662,7 +749,7 @@ def register_for_thing_instance(cls, thing_id: str, serializer: BaseSerializer)
@classmethod
def reset(cls) -> None:
- """Reset the serializer registry"""
+ """Reset the serializer registry."""
cls.object_content_type_map.clear()
cls.object_serializer_map.clear()
cls.protocol_serializer_map.clear()
@@ -670,7 +757,17 @@ def reset(cls) -> None:
@allowed_content_types.getter
def get_allowed_content_types(cls) -> list[str]:
- """Get a list of all allowed content types for serialization"""
+ """
+ Get a list of all allowed content types for serialization.
+
+ Set `global_config.ALLOW_PICKLE` to `True` to allow pickle content type,
+ which is not allowed by default for security reasons.
+
+ Returns
+ -------
+ list[str]
+ a list of allowed content types
+ """
_allowed_content_types = list(cls.content_types.keys())
_allowed_content_types.remove(cls.pickle.content_type)
if global_config.ALLOW_PICKLE:
diff --git a/hololinked/storage/__init__.py b/hololinked/storage/__init__.py
index 31b172e2..0583aca0 100644
--- a/hololinked/storage/__init__.py
+++ b/hololinked/storage/__init__.py
@@ -1,3 +1,11 @@
+"""
+Storage backends for `Thing` instances.
+
+When properties are written, their values can be dispatched to store them in different storage backends.
+Whenever the `Thing` instance is reinitialized, these stored values can be reloaded.
+This helps to backup running configuration and survive those values in case of power-cycles or crashes.
+"""
+
import os
from ..config import global_config
@@ -6,9 +14,9 @@
from .utils import get_sanitized_filename_from_thing_instance
-def prepare_object_storage(instance, **kwargs):
+def prepare_object_storage(instance, **kwargs) -> None:
"""
- Prepare the storage backend for a `Thing` instance
+ Prepare the storage backend for a `Thing` instance.
Parameters
----------
@@ -22,10 +30,6 @@ def prepare_object_storage(instance, **kwargs):
- `use_mongo_db`: `bool`, whether to use MongoDB storage (default: False)
- `db_config_file`: `str`, path to database configuration file (default: from `global_config.DB_CONFIG_FILE`)
- `json_filename`: `str`, filename for JSON file storage (default: derived from thing instance)
-
- Returns
- -------
- None
"""
use_json_file = kwargs.get(
"use_json_file",
diff --git a/hololinked/storage/config.py b/hololinked/storage/config.py
index 3578c96e..2dbfaec4 100644
--- a/hololinked/storage/config.py
+++ b/hololinked/storage/config.py
@@ -1,3 +1,5 @@
+"""Configuration validators for database storage."""
+
from typing import Literal
from pydantic import BaseModel, ConfigDict, Field, model_validator
@@ -5,7 +7,7 @@
class SQLDBConfig(BaseModel):
- """Configuration validator for SQL databases for PostgreSQL and MySQL"""
+ """Configuration validator for SQL databases for PostgreSQL and MySQL."""
provider: Literal["postgresql", "mysql"] = "postgresql"
"""Database provider, postgresql or mysql"""
@@ -21,13 +23,14 @@ class SQLDBConfig(BaseModel):
"""user password, default empty"""
dialect: Literal["asyncpg", "psycopg", "asyncmy", "mysqldb", ""] = ""
"""dialect to use, default psycopg for postgresql"""
- uri: SecretStr = ""
+ uri: SecretStr = Field(default=SecretStr(""), repr=False)
"""Full database URI, overrides other settings, default empty"""
model_config = ConfigDict(extra="forbid")
@property
def URL(self) -> str:
+ """Fully qualified database URL."""
if self.uri:
return self.uri.get_secret_value()
if self.provider == "postgresql":
@@ -42,23 +45,24 @@ def _at_least_one(self):
class SQLiteConfig(BaseModel):
- """Configuration validator for SQLite database"""
+ """Configuration validator for SQLite database."""
provider: Literal["sqlite"] = "sqlite"
"""Database provider, only sqlite is supported"""
- dialect: SecretStr = "pysqlite"
+ dialect: SecretStr = Field(default=SecretStr("pysqlite"), repr=False)
"""dialect to use, aiosqlite for async, pysqlite for sync"""
file: str = ""
"""SQLite database file, default is empty string, which leads to an DB with name of thing ID"""
in_memory: bool = False
"""Use in-memory SQLite database, default False as it is not persistent"""
- uri: SecretStr = ""
+ uri: SecretStr = Field(default=SecretStr(""), repr=False)
"""Full database URI, overrides other settings, default empty"""
model_config = ConfigDict(extra="forbid")
@property
def URL(self) -> str:
+ """Fully qualified database URL."""
if self.uri:
return self.uri.get_secret_value()
if self.in_memory:
@@ -69,7 +73,7 @@ def URL(self) -> str:
class MongoDBConfig(BaseModel):
- """Configuration validator for MongoDB database"""
+ """Configuration validator for MongoDB database."""
provider: Literal["mongo"] = "mongo"
"""Database provider, only mongo is supported"""
@@ -81,17 +85,18 @@ class MongoDBConfig(BaseModel):
"""database name, default hololinked"""
user: StrictStr = ""
"""user name, default empty, recommended not to use admin user"""
- password: SecretStr = ""
+ password: SecretStr = Field(default=SecretStr(""), repr=False)
"""user password, default empty"""
authSource: StrictStr = ""
"""authentication source database, default empty"""
- uri: SecretStr = ""
+ uri: SecretStr = Field(default=SecretStr(""), repr=False)
"""Full database URI, overrides other settings, default empty"""
model_config = ConfigDict(extra="forbid")
@property
def URL(self) -> str:
+ """Fully qualified database URL."""
if self.uri:
return self.uri.get_secret_value()
if self.user and self.password:
diff --git a/hololinked/storage/database.py b/hololinked/storage/database.py
index b7672b67..6b07df61 100644
--- a/hololinked/storage/database.py
+++ b/hololinked/storage/database.py
@@ -1,10 +1,12 @@
+"""Implementation of database engines."""
+
import base64
import os
import threading
from datetime import datetime
from sqlite3 import DatabaseError
-from typing import Any
+from typing import Any, Sequence
from pymongo import MongoClient
from pymongo import errors as mongo_errors
@@ -25,6 +27,7 @@
class BaseDB:
"""
Database base class irrespective of sync or async implementation.
+
Implements configuration file reader.
"""
@@ -43,25 +46,47 @@ def __init__(self, instance: Parameterized, config_file: str | None = None) -> N
@classmethod
def load_conf(
cls,
- config_file: str,
+ config_file: str | None,
default_file_path: str = "",
) -> SQLDBConfig | SQLiteConfig | MongoDBConfig:
"""
- load configuration file using JSON serializer
+ Load configuration file using JSON serializer.
+
+ Parameters
+ ----------
+ config_file: str
+ path to configuration file, expected to be in JSON format with fields according to the config classes.
+ default_file_path: str
+ fallback file path if config_file is not provided, only used for SQLiteConfig, default is empty string
+ which leads to an DB with name of thing ID
+
+ Returns
+ -------
+ SQLDBConfig | SQLiteConfig | MongoDBConfig
+ configuration object according to the provider specified in the config file
+
+ Raises
+ ------
+ NotImplementedError
+ if the provider specified in the config file is not supported
+ ValueError
+ if the config file is not in JSON format
"""
if not config_file:
return SQLiteConfig(file=default_file_path)
- elif config_file.endswith(".json"):
- file = open(config_file, "r")
- conf = JSONSerializer.load(file)
- if conf.get("provider", None) in ["postgresql", "mysql"]:
- return SQLDBConfig.model_validate(conf, strict=True, from_attributes=True)
- elif conf.get("provider", None) == "sqlite":
- return SQLiteConfig.model_validate(conf, strict=True, from_attributes=True)
- elif conf.get("provider", None) == "mongo":
- return MongoDBConfig.model_validate(conf, strict=True, from_attributes=True)
- raise NotImplementedError("only postgresql, mysql, sqlite and mongo are supported")
- raise ValueError("config files of extension {} expected, given file name {}".format(["json"], config_file))
+ if not config_file.endswith(".json"):
+ raise ValueError("config files of extension {} expected, given file name {}".format(["json"], config_file))
+ file = open(config_file, "r")
+ conf = JSONSerializer.load(file)
+ if not isinstance(conf, dict):
+ raise ValueError("config file expected to contain a JSON object/dictionary, given {}".format(type(conf)))
+ if conf.get("provider", None) in ["postgresql", "mysql"]:
+ return SQLDBConfig.model_validate(conf, strict=True, from_attributes=True)
+ elif conf.get("provider", None) == "sqlite":
+ return SQLiteConfig.model_validate(conf, strict=True, from_attributes=True)
+ elif conf.get("provider", None) == "mongo":
+ return MongoDBConfig.model_validate(conf, strict=True, from_attributes=True)
+ raise NotImplementedError("only postgresql, mysql, sqlite and mongo are supported")
@property
def in_batch_call_context(self):
@@ -105,6 +130,7 @@ def __init__(
class BaseSyncDB(BaseDB):
"""
Base class for a synchronous (blocking) database engine, implements sqlalchemy engine & session creation.
+
Default DB engine for `Thing` & called immediately after properties are set/written.
Parameters
@@ -124,24 +150,25 @@ def __init__(self, instance: Parameterized, config_file: str | None = None) -> N
class ThingDB(BaseSyncDB):
- """
- Synchronous database engine composed within `Thing`.
- Carries out database operations like storing object information, properties etc.
- """
-
def fetch_own_info(self): # -> ThingInformation:
"""
- fetch `Thing` instance's own information (some useful metadata which helps the `Thing` run).
+ Fetch `Thing` instance's own information (some useful metadata which helps the `Thing` run).
+
+ Highly unused and irrelevant currently.
Returns
-------
`ThingInformation`
+
+ Raises
+ ------
+ DatabaseError
"""
if not inspect_database(self.engine).has_table("things"):
return
with self.sync_session() as session:
stmt = select(ThingInformation).filter_by(
- thing_id=self.thing_instance.id,
+ thing_id=self.thing_instance.id, # type: ignore[unresolved-attribute]
thing_class=self.thing_instance.__class__.__name__,
)
data = session.execute(stmt)
@@ -150,7 +177,7 @@ def fetch_own_info(self): # -> ThingInformation:
return None
elif len(data) == 1:
return data[0]
- else:
+ else: # TODO raise different exception?
raise DatabaseError(
"Multiple things with same instance name found, either cleanup database/detach/make new"
)
@@ -170,11 +197,16 @@ def get_property(self, property: str | Property, deserialized: bool = True) -> A
-------
Any
property value
+
+ Raises
+ ------
+ DatabaseError
+ if the property is not found in database or multiple properties with same name are found
"""
with self.sync_session() as session:
name = property if isinstance(property, str) else property.name
stmt = select(SerializedProperty).filter_by(
- thing_id=self.thing_instance.id,
+ thing_id=self.thing_instance.id, # type: ignore[unresolved-attribute]
thing_class=self.thing_instance.__class__.__name__,
name=name,
)
@@ -187,7 +219,9 @@ def get_property(self, property: str | Property, deserialized: bool = True) -> A
if not deserialized:
return prop[0]
serializer = Serializers.content_types.get(prop[0].content_type, None) or Serializers.for_object(
- self.thing_instance.id, self.thing_instance.__class__.__name__, name
+ self.thing_instance.id, # type: ignore[unresolved-attribute]
+ self.thing_instance.__class__.__name__,
+ name,
)
return serializer.loads(prop[0].serialized_value)
@@ -201,14 +235,19 @@ def set_property(self, property: str | Property, value: Any) -> None:
string name or descriptor object
value: Any
value of the property
+
+ Raises
+ ------
+ DatabaseError
+ if multiple properties with same name are found
"""
+ name = property if isinstance(property, str) else property.name
if self.in_batch_call_context:
- self._batch_call_context[threading.get_ident()][property.name] = value
+ self._batch_call_context[threading.get_ident()][name] = value
return
with self.sync_session() as session:
- name = property if isinstance(property, str) else property.name
stmt = select(SerializedProperty).filter_by(
- thing_id=self.thing_instance.id,
+ thing_id=self.thing_instance.id, # type: ignore[unresolved-attribute]
thing_class=self.thing_instance.__class__.__name__,
name=name,
)
@@ -219,21 +258,25 @@ def set_property(self, property: str | Property, value: Any) -> None:
if len(prop) == 1:
prop = prop[0]
serializer = Serializers.content_types.get(prop.content_type, None) or Serializers.for_object(
- self.thing_instance.id, self.thing_instance.__class__.__name__, name
+ self.thing_instance.id, # type: ignore[unresolved-attribute]
+ self.thing_instance.__class__.__name__,
+ name,
)
prop.serialized_value = serializer.dumps(value)
prop.updated_at = datetime.now().isoformat()
prop.content_type = serializer.content_type
else:
serializer = Serializers.for_object(
- self.thing_instance.id, self.thing_instance.__class__.__name__, name
+ self.thing_instance.id, # type: ignore[unresolved-attribute]
+ self.thing_instance.__class__.__name__,
+ name,
)
now = datetime.now().isoformat()
prop = SerializedProperty(
- id=None,
+ id=None, # type: ignore[invalid-argument-type]
name=name,
serialized_value=serializer.dumps(value),
- thing_id=self.thing_instance.id,
+ thing_id=self.thing_instance.id, # type: ignore[unresolved-attribute]
thing_class=self.thing_instance.__class__.__name__,
created_at=now,
updated_at=now,
@@ -264,7 +307,10 @@ def get_properties(self, properties: dict[str | Property, Any], deserialized: bo
names.append(obj if isinstance(obj, str) else obj.name)
stmt = (
select(SerializedProperty)
- .filter_by(thing_id=self.thing_instance.id, thing_class=self.thing_instance.__class__.__name__)
+ .filter_by(
+ thing_id=self.thing_instance.id, # type: ignore[unresolved-attribute]
+ thing_class=self.thing_instance.__class__.__name__,
+ )
.filter(SerializedProperty.name.in_(names))
)
data = session.execute(stmt)
@@ -272,7 +318,7 @@ def get_properties(self, properties: dict[str | Property, Any], deserialized: bo
props = dict()
for prop in unserialized_props:
serializer = Serializers.content_types.get(prop.content_type, None) or Serializers.for_object(
- self.thing_instance.id,
+ self.thing_instance.id, # type: ignore[unresolved-attribute]
self.thing_instance.__class__.__name__,
prop.name,
)
@@ -283,12 +329,17 @@ def get_properties(self, properties: dict[str | Property, Any], deserialized: bo
def set_properties(self, properties: dict[str | Property, Any]) -> None:
"""
- Change the values of already existing properties at once
+ Change the values of already existing properties at once.
Parameters
----------
properties: Dict[str | Property, Any]
string names or the descriptor of the property and any value as dictionary pairs
+
+ Raises
+ ------
+ DatabaseError
+ if multiple properties with same name are found
"""
if self.in_batch_call_context:
for obj, value in properties.items():
@@ -301,7 +352,10 @@ def set_properties(self, properties: dict[str | Property, Any]) -> None:
names.append(obj if isinstance(obj, str) else obj.name)
stmt = (
select(SerializedProperty)
- .filter_by(thing_id=self.thing_instance.id, thing_class=self.thing_instance.__class__.__name__)
+ .filter_by(
+ thing_id=self.thing_instance.id, # type: ignore[unresolved-attribute]
+ thing_class=self.thing_instance.__class__.__name__,
+ )
.filter(SerializedProperty.name.in_(names))
)
data = session.execute(stmt)
@@ -314,21 +368,25 @@ def set_properties(self, properties: dict[str | Property, Any]) -> None:
if len(db_prop) == 1:
db_prop = db_prop[0] # type: SerializedProperty
serializer = Serializers.content_types.get(db_prop.content_type, None) or Serializers.for_object(
- self.thing_instance.id, self.thing_instance.__class__.__name__, name
+ self.thing_instance.id, # type: ignore[unresolved-attribute]
+ self.thing_instance.__class__.__name__,
+ name,
)
db_prop.serialized_value = serializer.dumps(value)
db_prop.updated_at = datetime.now().isoformat()
db_prop.content_type = serializer.content_type
else:
serializer = Serializers.for_object(
- self.thing_instance.id, self.thing_instance.__class__.__name__, name
+ self.thing_instance.id, # type: ignore[unresolved-attribute]
+ self.thing_instance.__class__.__name__,
+ name,
)
now = datetime.now().isoformat()
prop = SerializedProperty(
- id=None,
+ id=None, # type: ignore[invalid-argument-type]
name=name,
serialized_value=serializer.dumps(value),
- thing_id=self.thing_instance.id,
+ thing_id=self.thing_instance.id, # type: ignore[unresolved-attribute]
thing_class=self.thing_instance.__class__.__name__,
created_at=now,
updated_at=now,
@@ -337,7 +395,7 @@ def set_properties(self, properties: dict[str | Property, Any]) -> None:
session.add(prop)
session.commit()
- def get_all_properties(self, deserialized: bool = True) -> dict[str, Any]:
+ def get_all_properties(self, deserialized: bool = True) -> dict[str, Any] | Sequence[SerializedProperty]:
"""
Get all properties of the `Thing` instance.
@@ -353,7 +411,8 @@ def get_all_properties(self, deserialized: bool = True) -> dict[str, Any]:
"""
with self.sync_session() as session:
stmt = select(SerializedProperty).filter_by(
- thing_id=self.thing_instance.id, thing_class=self.thing_instance.__class__.__name__
+ thing_id=self.thing_instance.id, # type: ignore[unresolved-attribute]
+ thing_class=self.thing_instance.__class__.__name__,
)
data = session.execute(stmt)
existing_props = data.scalars().all() # type: list[SerializedProperty]
@@ -362,7 +421,9 @@ def get_all_properties(self, deserialized: bool = True) -> dict[str, Any]:
props = dict()
for prop in existing_props:
serializer = Serializers.content_types.get(prop.content_type, None) or Serializers.for_object(
- self.thing_instance.id, self.thing_instance.__class__.__name__, prop.name
+ self.thing_instance.id, # type: ignore[unresolved-attribute]
+ self.thing_instance.__class__.__name__,
+ prop.name,
)
props[prop.name] = serializer.loads(prop.serialized_value)
return props
@@ -393,14 +454,16 @@ def create_missing_properties(
for prop in properties.values():
if prop.name not in existing_props:
serializer = Serializers.for_object(
- self.thing_instance.id, self.thing_instance.__class__.__name__, prop.name
+ self.thing_instance.id, # type: ignore[unresolved-attribute]
+ self.thing_instance.__class__.__name__,
+ prop.name,
)
now = datetime.now().isoformat()
prop = SerializedProperty(
- id=None,
+ id=None, # type: ignore[invalid-argument-type]
name=prop.name,
serialized_value=serializer.dumps(getattr(self.thing_instance, prop.name)),
- thing_id=self.thing_instance.id,
+ thing_id=self.thing_instance.id, # type: ignore[unresolved-attribute]
thing_class=self.thing_instance.__class__.__name__,
created_at=now,
updated_at=now,
@@ -412,9 +475,10 @@ def create_missing_properties(
if get_missing_property_names:
return missing_props
- def create_db_init_properties(self, thing_id: str = None, thing_class: str = None, **properties: Any) -> None:
+ def create_db_init_properties(self, thing_id: str, thing_class: str, **properties: Any) -> None:
"""
Create properties that are supposed to be initialized from database for a thing instance.
+
Invoke this method once before running the thing instance to store its initial value in database.
Parameters
@@ -431,7 +495,7 @@ def create_db_init_properties(self, thing_id: str = None, thing_class: str = Non
serializer = Serializers.for_object(thing_id, thing_class, name)
now = datetime.now().isoformat()
prop = SerializedProperty(
- id=None,
+ id=None, # type: ignore[invalid-argument-type]
name=name,
serialized_value=serializer.dumps(value),
thing_id=thing_id,
@@ -446,18 +510,19 @@ def create_db_init_properties(self, thing_id: str = None, thing_class: str = Non
class batch_db_commit:
"""
- Context manager to write multiple properties to database at once. Useful for sequential sets/writes of multiple properties
- which has db_commit or db_persist set to True, but only write their values to database at once.
+ Write multiple properties to a database at once.
+
+ Useful for optimizing sequential sets/writes of multiple properties which are stored onto a database.
"""
def __init__(self, db_engine: ThingDB) -> None:
self.db_engine = db_engine
def __enter__(self) -> None:
- self.db_engine._context[threading.get_ident()] = dict()
+ self.db_engine._context[threading.get_ident()] = dict() # type: ignore[unresolved-attribute]
def __exit__(self, exc_type, exc_value, exc_tb) -> None:
- data = self.db_engine._context.pop(threading.get_ident(), dict()) # dict[str, Any]
+ data = self.db_engine._context.pop(threading.get_ident(), dict()) # type: ignore[unresolved-attribute]
if exc_type is None:
self.db_engine.set_properties(data)
return
@@ -465,7 +530,7 @@ def __exit__(self, exc_type, exc_value, exc_tb) -> None:
try:
self.db_engine.set_property(name, value)
except Exception as ex:
- self.db_engine.thing_instance.logger.error(
+ self.db_engine.thing_instance.logger.error( # type: ignore[unresolved-attribute]
f"failed to set property {name} to value {value} during batch commit due to exception {ex}"
)
@@ -486,10 +551,11 @@ class MongoThingDB:
def __init__(self, instance: Parameterized, config_file: str | None = None) -> None:
"""
Initialize MongoThingDB for a Thing instance.
+
Connects to MongoDB and sets up collections.
"""
self.thing_instance = instance
- self.id = instance.id
+ self.id = instance.id # type: ignore[unresolved-attribute]
self.config = self.load_conf(config_file)
self.client = MongoClient(self.config.get("mongo_uri", "mongodb://localhost:27017"))
self.db = self.client[self.config.get("database", "hololinked")]
@@ -500,26 +566,64 @@ def __init__(self, instance: Parameterized, config_file: str | None = None) -> N
def load_conf(cls, config_file: str | None) -> dict[str, Any]:
"""
Load configuration from JSON file if provided.
+
+ Parameters
+ ----------
+ config_file: str | None
+ Path to the JSON configuration file. If None, default configuration is used.
+
+ Returns
+ -------
+ dict[str, Any]
+ Configuration dictionary with MongoDB connection parameters.
+
+ Raises
+ ------
+ ValueError
+ If the config file is not in JSON format.
"""
if not config_file:
return {}
elif config_file.endswith(".json"):
with open(config_file, "r") as file:
- return JSONSerializer.load(file)
+ return JSONSerializer.load(file) # type: ignore[invalid-return-type]
else:
raise ValueError(f"config files of extension - ['json'] expected, given file name {config_file}")
def fetch_own_info(self):
"""
- Fetch Thing instance metadata from the 'things' collection.
+ Fetch `Thing` instance metadata from the 'things' collection.
+
+ Largely unused now.
+
+ Returns
+ -------
+ dict[str, Any] | None
+ Metadata document for the Thing instance, or None if not found.
"""
doc = self.things.find_one({"id": self.id})
return doc
def get_property(self, property: str | Property, deserialized: bool = True) -> Any:
"""
- Get a property value from MongoDB for this Thing.
- If deserialized=True, returns the Python value.
+ Fetch a single property.
+
+ Parameters
+ ----------
+ property: str | Property
+ string name or descriptor object
+ deserialized: bool, default True
+ deserialize the property if True
+
+ Returns
+ -------
+ Any
+ property value
+
+ Raises
+ ------
+ PyMongoError
+ if the property is not found in database
"""
name = property if isinstance(property, str) else property.name
doc = self.properties.find_one({"id": self.id, "name": name})
@@ -532,8 +636,16 @@ def get_property(self, property: str | Property, deserialized: bool = True) -> A
def set_property(self, property: str | Property, value: Any) -> None:
"""
- Set a property value in MongoDB for this Thing.
+ Set a property value.
+
Value is serialized before storage.
+
+ Parameters
+ ----------
+ property: str | Property
+ string name or descriptor object
+ value: Any
+ value of the property
"""
name = property if isinstance(property, str) else property.name
serializer = Serializers.for_object(self.id, self.thing_instance.__class__.__name__, name)
@@ -544,8 +656,21 @@ def set_property(self, property: str | Property, value: Any) -> None:
def get_properties(self, properties: dict[str | Property, Any], deserialized: bool = True) -> dict[str, Any]:
"""
- Get multiple property values from MongoDB for this Thing.
+ Get multiple property values.
+
Returns a dict of property names to values.
+
+ Parameters
+ ----------
+ properties: List[str | Property]
+ string names or the descriptor of the properties as a list
+ deserialized: bool, default True
+ deserialize the properties if True
+
+ Returns
+ -------
+ dict[str, Any]
+ property names and values as items
"""
names = [obj if isinstance(obj, str) else obj.name for obj in properties.keys()]
cursor = self.properties.find({"id": self.id, "name": {"$in": names}})
@@ -561,7 +686,12 @@ def get_properties(self, properties: dict[str | Property, Any], deserialized: bo
def set_properties(self, properties: dict[str | Property, Any]) -> None:
"""
- Set multiple property values in MongoDB for this Thing.
+ Set multiple property values.
+
+ Parameters
+ ----------
+ properties: dict[str | Property, Any]
+ dictionary of property names or descriptors to values
"""
for obj, value in properties.items():
name = obj if isinstance(obj, str) else obj.name
@@ -572,6 +702,20 @@ def set_properties(self, properties: dict[str | Property, Any]) -> None:
)
def get_all_properties(self, deserialized: bool = True) -> dict[str, Any]:
+ """
+ Get all property values.
+
+ Returns a dict of property names to values.
+
+ Parameters
+ ----------
+ deserialized: bool, default True
+ deserialize the properties if True
+
+ Returns
+ -------
+ dict[str, Any]
+ """
cursor = self.properties.find({"id": self.id})
result = {}
for doc in cursor:
@@ -588,6 +732,21 @@ def create_missing_properties(
properties: dict[str, Property],
get_missing_property_names: bool = False,
) -> Any:
+ """
+ Create missing properties in the database.
+
+ Parameters
+ ----------
+ properties: dict[str, Property]
+ dictionary of property names or descriptors to values
+ get_missing_property_names: bool, default False
+ whether to return the list of missing property names
+
+ Returns
+ -------
+ list[str] | None
+ list of missing property names if get_missing_property_names is True, else None
+ """
missing_props = []
existing_props = self.get_all_properties()
for name, new_prop in properties.items():
diff --git a/hololinked/storage/json_storage.py b/hololinked/storage/json_storage.py
index 60cace8c..e5447e1d 100644
--- a/hololinked/storage/json_storage.py
+++ b/hololinked/storage/json_storage.py
@@ -1,3 +1,5 @@
+"""JSON-based file storage engine."""
+
import os
import threading
@@ -10,8 +12,9 @@
class ThingJSONStorage:
"""
- JSON-based storage engine composed within `Thing`. Carries out property operations such as storing and
- retrieving values from a plain JSON file.
+ JSON-based storage engine composed within `Thing`.
+
+ Carries out property operations such as storing and retrieving values from a plain JSON file.
Parameters
----------
@@ -27,7 +30,7 @@ class ThingJSONStorage:
def __init__(self, filename: str, instance: Parameterized, serializer: Any = None):
self.filename = filename
self.thing_instance = instance
- self.id = instance.id
+ self.id = instance.id # type: ignore[unresolved-attribute]
self._serializer = serializer or JSONSerializer()
self._lock = threading.RLock()
self._data = self._load()
@@ -48,12 +51,12 @@ def _load(self) -> dict[str, Any]:
raw_bytes = f.read()
if not raw_bytes:
return {}
- return self._serializer.loads(raw_bytes)
+ return self._serializer.loads(raw_bytes) # type: ignore[invalid-return-type]
except Exception:
return {}
def _save(self):
- """Encode and write data to the JSON file"""
+ """Encode and write data to the JSON file."""
raw_bytes = self._serializer.dumps(self._data)
with open(self.filename, "wb") as f:
f.write(raw_bytes)
@@ -71,6 +74,11 @@ def get_property(self, property: str | Property) -> Any:
-------
value: Any
property value
+
+ Raises
+ ------
+ KeyError
+ If the property is not found in storage.
"""
name = property if isinstance(property, str) else property.name
if name not in self._data:
@@ -128,7 +136,13 @@ def set_properties(self, properties: dict[str | Property, Any]) -> None:
self._save()
def get_all_properties(self) -> dict[str, Any]:
- """Read all properties of the `Thing` instance"""
+ """
+ Read all properties of the `Thing` instance.
+
+ Returns
+ -------
+ dict[str, Any]
+ """
with self._lock:
return dict(self._data)
@@ -138,7 +152,7 @@ def create_missing_properties(
get_missing_property_names: bool = False,
) -> list[str] | None:
"""
- Create any and all missing properties of `Thing` instance
+ Create any and all missing properties of `Thing` instance.
Parameters
----------
diff --git a/hololinked/storage/models.py b/hololinked/storage/models.py
index 766c0f7c..7a8615aa 100644
--- a/hololinked/storage/models.py
+++ b/hololinked/storage/models.py
@@ -1,3 +1,5 @@
+"""Models for ORM based storage."""
+
from dataclasses import asdict, dataclass
from typing import Any
@@ -13,15 +15,18 @@
class ThingTableBase(DeclarativeBase):
- """SQLAlchemy base table for all Thing related tables"""
+ """SQLAlchemy base table for all `Thing` related tables."""
pass
class SerializedProperty(MappedAsDataclass, ThingTableBase):
"""
- Property value is serialized before storing in database, therefore providing unified version for
- SQLite and other relational tables
+ Represents a serialized property of a `Thing` instance stored in the database.
+
+ Property Serialized is done, providing unified version for SQLite and other relational tables.
+ Anyway while sending in the wire, the value is serialized to bytes, so when preserialized, the bytes
+ are stored directly. A performance problem is not expected.
"""
__tablename__ = "properties"
@@ -37,7 +42,11 @@ class SerializedProperty(MappedAsDataclass, ThingTableBase):
class ThingInformation(MappedAsDataclass, ThingTableBase):
- """Stores information about the Thing instance itself, useful metadata which may be later populated in a GUI"""
+ """
+ Stores information about the Thing instance itself.
+
+ Useful metadata which may be later populated in a GUI or client applications need to go here.
+ """
__tablename__ = "things"
@@ -48,13 +57,20 @@ class ThingInformation(MappedAsDataclass, ThingTableBase):
init_kwargs: Mapped[JSONSerializable] = mapped_column(JSON)
server_id: Mapped[str] = mapped_column(String)
- def json(self):
+ def json(self) -> dict[str, Any]:
+ """
+ JSON-serializable dictionary representation of the Thing information.
+
+ Returns
+ -------
+ dict[str, Any]
+ """
return asdict(self)
@dataclass
class DeserializedProperty: # not part of database
- """Property with deserialized value after fetching from database"""
+ """Property with deserialized value after fetching from database."""
thing_id: str
name: str
diff --git a/hololinked/storage/utils.py b/hololinked/storage/utils.py
index 7af70278..3823f492 100644
--- a/hololinked/storage/utils.py
+++ b/hololinked/storage/utils.py
@@ -1,6 +1,8 @@
+"""Utility functions for storage engines."""
+
from ..utils import get_sanitized_filename_from_random_string
def get_sanitized_filename_from_thing_instance(instance, extension: str = "db") -> str:
- """Sanitize database filename from `Thing` instance"""
+ """Sanitize database filename from `Thing` instance.""" # noqa
return get_sanitized_filename_from_random_string(f"{instance.__class__.__name__}.{instance.id}", extension)
diff --git a/hololinked/utils.py b/hololinked/utils.py
index 557c9df1..8fc4838d 100644
--- a/hololinked/utils.py
+++ b/hololinked/utils.py
@@ -54,12 +54,12 @@ def get_IP_from_interface(interface_name: str = "Ethernet", adapter_name: str |
def uuid_hex() -> str:
- """generate a random UUID hex string of 8 characters"""
+ """Generate a random UUID hex string of 8 characters"""
return uuid4().hex[:8]
def format_exception_as_json(exc: Exception) -> dict[str, Any]:
- """return exception as a JSON serializable dictionary"""
+ """Return exception as a JSON serializable dictionary"""
return dict(
message=str(exc),
type=repr(exc).split("(", 1)[0],
@@ -82,7 +82,7 @@ def pep8_to_dashed_name(word: str) -> str:
def get_current_async_loop() -> asyncio.AbstractEventLoop:
- """get or automatically create an asnyc loop for the current thread"""
+ """Get or automatically create an asnyc loop for the current thread"""
try:
loop = asyncio.get_event_loop()
except RuntimeError:
@@ -92,7 +92,7 @@ def get_current_async_loop() -> asyncio.AbstractEventLoop:
def run_coro_sync(coro: Coroutine) -> Any:
- """try to run coroutine synchronously, raises runtime error if event loop is already running"""
+ """Try to run coroutine synchronously, raises runtime error if event loop is already running"""
eventloop = get_current_async_loop()
if eventloop.is_running():
raise RuntimeError(
@@ -105,7 +105,7 @@ def run_coro_sync(coro: Coroutine) -> Any:
def run_callable_somehow(method: Callable | Coroutine) -> Any:
"""
- run method if synchronous, or when async, either schedule a coroutine
+ Run method if synchronous, or when async, either schedule a coroutine
or run it until its complete
"""
if inspect.isawaitable(method):
@@ -158,7 +158,7 @@ def print_pending_tasks_in_current_loop():
def set_global_event_loop_policy(use_uvloop: bool = False) -> None:
- """set global event loop policy, optionally using uvloop if available and on linux/macos"""
+ """Set global event loop policy, optionally using uvloop if available and on linux/macos"""
if sys.platform.lower().startswith("win"):
asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
@@ -201,7 +201,7 @@ def get_signature(callable: Callable) -> tuple[list[str], list[type]]:
def getattr_without_descriptor_read(instance: Any, key: str) -> Any:
"""
- supply to inspect._get_members (not inspect.get_members) to avoid calling
+ Supply to inspect._get_members (not inspect.get_members) to avoid calling
__get__ on descriptors, especially whey they are hardware attributes that would
invoke a hardware read.
"""
@@ -307,7 +307,7 @@ def get_sanitized_filename_from_random_string(a_string: str, extension: str) ->
def generate_main_script_log_filename(self, app_name: str | None = None) -> str | None:
- """returns the main script filename if available"""
+ """Returns the main script filename if available"""
import __main__
if not app_name:
@@ -490,7 +490,6 @@ def pydantic_validate_args_kwargs(
ValidationError
If the arguments are invalid
"""
-
field_names = list(model.model_fields.keys())
data = {}
@@ -530,7 +529,11 @@ def pydantic_validate_args_kwargs(
model.model_validate(data)
-def json_schema_merge_args_to_kwargs(schema: dict, args: tuple = tuple(), kwargs: dict = dict()) -> dict[str, Any]:
+def json_schema_merge_args_to_kwargs(
+ schema,
+ args: tuple = tuple(),
+ kwargs: dict = dict(),
+) -> dict[str, Any]:
"""
Merge positional arguments into keyword arguments according to the schema.
@@ -597,7 +600,7 @@ def wrapper(*args, **kwargs):
def get_all_sub_things_recusively(thing) -> list:
- """get all sub things recursively from a thing"""
+ """Get all sub things recursively from a thing"""
sub_things = [thing]
for sub_thing in thing.sub_things.values():
sub_things.extend(get_all_sub_things_recusively(sub_thing))
diff --git a/pyproject.toml b/pyproject.toml
index fc5515f2..0197ad53 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -127,7 +127,8 @@ extend-select = [
]
ignore = [
"G201", # logging-exc-info: do not require exc_info in logger.exception calls
- "D401", # imperative mode for summary line
+ "D401", # imperative mode for summary line
+ "D105", # magic methods dont need a docstring
]
[tool.ruff.lint.per-file-ignores]
From 9297adc34656da16e4be77c3ed85a42530091c77 Mon Sep 17 00:00:00 2001
From: Vignesh Venkatasubramanian Vaidyanathan
<62492557+VigneshVSV@users.noreply.github.com>
Date: Thu, 14 May 2026 08:44:32 +0200
Subject: [PATCH 11/15] ty (#176)
* ty all server files except http
* fix docstrings for http
* http & mqtt folders server
* exclude rest of the folders
---
hololinked/__init__.py | 2 +
hololinked/server/__init__.py | 2 +
hololinked/server/http/__init__.py | 2 +
hololinked/server/http/config.py | 5 +-
hololinked/server/http/controllers.py | 227 ++++++++++++++++++--------
hololinked/server/http/server.py | 202 ++++++++++++++++++-----
hololinked/server/http/services.py | 99 +++++++----
hololinked/server/mqtt/__init__.py | 2 +
hololinked/server/mqtt/config.py | 3 +
hololinked/server/mqtt/controllers.py | 22 ++-
hololinked/server/mqtt/server.py | 39 +++--
hololinked/server/mqtt/services.py | 11 +-
hololinked/server/repository.py | 6 +-
hololinked/server/security.py | 190 ++++++++++++++++++---
hololinked/server/server.py | 61 +++++--
hololinked/server/zmq/__init__.py | 2 +
hololinked/server/zmq/server.py | 38 ++++-
hololinked/storage/database.py | 2 +-
pyproject.toml | 19 ++-
19 files changed, 714 insertions(+), 220 deletions(-)
diff --git a/hololinked/__init__.py b/hololinked/__init__.py
index 1b8b98e9..97185be4 100644
--- a/hololinked/__init__.py
+++ b/hololinked/__init__.py
@@ -1,3 +1,5 @@
+"""beginner friendly data acquisition and IoT in python."""
+
__version__ = "0.3.12"
from .config import global_config # noqa
diff --git a/hololinked/server/__init__.py b/hololinked/server/__init__.py
index 335d6c01..2081a0f6 100644
--- a/hololinked/server/__init__.py
+++ b/hololinked/server/__init__.py
@@ -1,3 +1,5 @@
+"""Concrete implementations of specific protocol servers."""
+
from .server import BaseProtocolServer, run, stop # noqa: F401, isort: skip
from .http import HTTPServer # noqa: F401
from .mqtt import MQTTPublisher # noqa: F401
diff --git a/hololinked/server/http/__init__.py b/hololinked/server/http/__init__.py
index 952d7517..fd645969 100644
--- a/hololinked/server/http/__init__.py
+++ b/hololinked/server/http/__init__.py
@@ -1,3 +1,5 @@
+"""HTTP server protocol implementation."""
+
from .controllers import ( # noqa: F401
ActionHandler,
BaseHandler,
diff --git a/hololinked/server/http/config.py b/hololinked/server/http/config.py
index 1d7a12fd..06a15784 100644
--- a/hololinked/server/http/config.py
+++ b/hololinked/server/http/config.py
@@ -1,3 +1,5 @@
+"""Configuration and dependency injection for HTTP server and handlers."""
+
from typing import Any
from pydantic import BaseModel, Field
@@ -21,6 +23,7 @@
class RuntimeConfig(BaseModel):
"""
Runtime configuration for HTTP server and handlers.
+
Pass the attributes of this class as a dictionary to the `config` argument of `HTTPServer`.
"""
@@ -67,7 +70,7 @@ class RuntimeConfig(BaseModel):
class HandlerMetadata(BaseModel):
- """Specific metadata when a request handler has been initialized, in other words, handler specific metadata"""
+ """Specific metadata when a request handler has been initialized."""
http_methods: tuple[str, ...] = tuple()
"""HTTP methods supported by the handler"""
diff --git a/hololinked/server/http/controllers.py b/hololinked/server/http/controllers.py
index 698708ae..6142aebb 100644
--- a/hololinked/server/http/controllers.py
+++ b/hololinked/server/http/controllers.py
@@ -1,3 +1,5 @@
+"""HTTP request handlers."""
+
from typing import Any, Optional
import msgspec
@@ -36,12 +38,22 @@
class LocalExecutionContext(msgspec.Struct):
+ """dataclass holding context on the current operation being performed."""
+
noblock: Optional[bool] = None
+ """
+ The request will be scheduled but the response will not be provided,
+ the client is expected to fetch the response later using the message ID
+ """
messageID: Optional[str] = None
+ """
+ A message ID especially to correlate no-block requests with their responses,
+ can be provided in the query parameters or the headers
+ """
class BaseHandler(RequestHandler):
- """Base request handler for running operations on the `Thing`"""
+ """Base request handler for running operations on the `Thing`."""
# Would be a Controller in layered architecture.
@@ -53,12 +65,16 @@ def initialize(
metadata: Any = None,
) -> None:
"""
+ Initializes the handler with the resource, config, logger and metadata.
+
Parameters
----------
resource: InteractionAffordance | PropertyAffordance | ActionAffordance | EventAffordance
dataclass representation of `Thing`'s exposed object that can quickly convert to a ZMQ Request object
- owner_inst: HTTPServer
- owning `hololinked.server.HTTPServer` instance
+ config: Any
+ The runtime configuration for the `HTTPServer`
+ logger: structlog.stdlib.BoundLogger
+ The logger to use for logging messages
metadata: HandlerMetadata | None,
additional metadata about the resource, like allowed HTTP methods
"""
@@ -82,9 +98,15 @@ def initialize(
async def has_access_control(self) -> bool:
"""
- Checks if a client is an allowed client and enforces security schemes.
- Custom web request handlers can use this property to check if a client has access control on the server or `Thing`
- and let this property automatically generate a 401/403.
+ Enforces security schemes.
+
+ Custom web request handlers can use this property to check if a client has access control on the server or
+ `Thing` and automatically generate a 401/403.
+
+ Returns
+ -------
+ bool
+ True if the client has access control, False otherwise
"""
if not self.allowed_clients and not self.security_schemes:
return True
@@ -116,7 +138,14 @@ async def has_access_control(self) -> bool:
return False # keep False always at the end
async def is_authenticated(self) -> bool:
- """enforces authentication using the defined security schemes, freshly computed everytime"""
+ """
+ Enforces authentication using the defined security schemes, freshly computed everytime.
+
+ Returns
+ -------
+ bool
+ True if the client is authenticated, False otherwise
+ """
authenticated = False
# 1. Basic Authentication
authorization_header = self.request.headers.get("Authorization", None) # type: str
@@ -180,8 +209,14 @@ async def is_authenticated(self) -> bool:
async def is_authorized(self) -> bool:
"""
- enforces authorization using the defined security schemes, freshly computed everytime.
- Do not call this method without doing authentication first and having a userinfo property.
+ Enforces authorization (roles and permissions) using the defined security schemes, freshly computed everytime.
+
+ Do not call this method without doing authentication first. Mainly useful only for OIDC/OAuth2 currently.
+
+ Returns
+ -------
+ bool
+ True if the client is authorized, False otherwise.
"""
if not self.userinfo:
return True
@@ -192,7 +227,8 @@ async def is_authorized(self) -> bool:
def set_access_control_allow_headers(self) -> None:
"""
- For credential login, access control allow headers cannot be a wildcard '*'.
+ For credential login, sets access control allow headers, which cannot be a wildcard '*'.
+
Some requests require exact list of allowed headers for the client to access the response.
"""
headers = ", ".join(self.request.headers.keys())
@@ -202,7 +238,9 @@ def set_access_control_allow_headers(self) -> None:
def set_custom_default_headers(self) -> None:
"""
- sets general default headers, override in child classes to add more headers.
+ Sets general default headers for any request.
+
+ Override in child classes to add more headers.
```yaml
Content-Type: application/json
@@ -225,18 +263,19 @@ def get_execution_parameters(
]:
"""
Aggregates all arguments to a standard dataclasses from the query parameters.
+
Retrieves execution context (like oneway calls, fetching executing
logs), timeouts, etc. Non recognized arguments are passed as additional payload to the `Thing`.
An example would be the following URL:
```
- http://localhost:8080/property/temperature?oneway=true&invokationTimeout=5&some_arg=42
+ http://localhost:8080/properties/temperature?oneway=true&invokationTimeout=5&some_arg=42
```
- server execution context would have `oneway` set to true & `invokationTimeout` set to 5 seconds,
- local execution context would be empty as no such arguments were passed,
- and additional payload would have `{"some_arg": 42}` as its value.
+ - Server execution context would have `oneway` set to true & `invokationTimeout` set to 5 seconds
+ - Local execution context would be empty as no such arguments were passed
+ - Additional payload would have `{"some_arg": 42}` as its value.
Returns
-------
@@ -289,7 +328,14 @@ def get_execution_parameters(
@property
def message_id(self) -> str:
- """retrieves the message id from the request headers"""
+ """
+ Retrieves the message id from the request headers or query parameters.
+
+ Raises
+ ------
+ AttributeError
+ If the message ID is not found in the headers or query parameters.
+ """
try:
return self._message_id
except AttributeError:
@@ -298,11 +344,27 @@ def message_id(self) -> str:
_, _, local_execution_context, _ = self.get_execution_parameters()
# TODO avoid calling get_execution_parameters twice in the same request
message_id = local_execution_context.messageID
+ if not message_id:
+ raise AttributeError("message ID not found in headers or query parameters")
self._message_id = message_id
return message_id
def get_request_payload(self) -> tuple[SerializableData, PreserializedData]:
- """retrieves the payload from the request body, does not necessarily deserialize it"""
+ """
+ Retrieves the payload from the request body, does not necessarily deserialize it.
+
+ Returns
+ -------
+ tuple[SerializableData, PreserializedData]
+ A tuple of SerializableData and PreserializedData, where the first one has the deserialized value
+ (if possible) and the second one has the raw value.
+ If deserialization is not possible and ALLOW_UNKNOWN_SERIALIZATION is False, raises an error.
+
+ Raises
+ ------
+ ValueError
+ If the content type is not supported and ALLOW_UNKNOWN_SERIALIZATION is False.
+ """
payload = SerializableData(value=None)
preserialized_payload = PreserializedData(value=b"")
if self.request.body:
@@ -311,7 +373,10 @@ def get_request_payload(self) -> tuple[SerializableData, PreserializedData]:
payload.content_type = self.request.headers.get("Content-Type", "application/json")
elif global_config.ALLOW_UNKNOWN_SERIALIZATION:
preserialized_payload.value = self.request.body
- preserialized_payload.content_type = self.request.headers.get("Content-Type", None)
+ preserialized_payload.content_type = self.request.headers.get(
+ "Content-Type",
+ "application/octet-stream",
+ )
else:
raise ValueError("Content-Type not supported")
# NOTE that was assume that the content type is JSON even if unspecified in the header.
@@ -319,26 +384,27 @@ def get_request_payload(self) -> tuple[SerializableData, PreserializedData]:
return payload, preserialized_payload
async def get(self) -> None:
- """runs property or action if accessible by 'GET' method. Default for property reads"""
+ """Runs property or action if accessible by 'GET' method. Default for property reads."""
raise NotImplementedError("implement GET request method in child handler class")
async def post(self) -> None:
- """runs property or action if accessible by 'POST' method. Default for action execution"""
+ """Runs property or action if accessible by 'POST' method. Default for action execution."""
raise NotImplementedError("implement POST request method in child handler class")
async def put(self) -> None:
- """runs property or action if accessible by 'PUT' method. Default for property writes"""
+ """Runs property or action if accessible by 'PUT' method. Default for property writes."""
raise NotImplementedError("implement PUT request method in child handler class")
async def delete(self) -> None:
"""
- runs property or action if accessible by 'DELETE' method. Default for property deletes
- (not a valid operation as per web of things semantics).
+ Runs property or action if accessible by 'DELETE' method.
+
+ Default for property deletes (not a valid operation as per web of things semantics).
"""
raise NotImplementedError("implement DELETE request method in child handler class")
- def is_method_allowed(self, method: str) -> bool:
- """checks if the method is allowed for the property"""
+ async def is_method_allowed(self, method: str) -> bool:
+ """Checks if the method is allowed for the property."""
raise NotImplementedError("implement is_method_allowed in child handler class")
@@ -354,11 +420,16 @@ class RPCHandler(BaseHandler):
async def is_method_allowed(self, method: str) -> bool:
"""
- Checks if the method is allowed for the property:
+ Checks if the method is allowed for the property.
- Access control (authentication & authorization)
- if the HTTP method is allowed for the resource.
- if its GET method with message id for no-block response.
+
+ Returns
+ -------
+ bool
+ True if the method is allowed, False otherwise.
"""
if not await self.has_access_control():
return False
@@ -371,7 +442,9 @@ async def is_method_allowed(self, method: str) -> bool:
async def options(self) -> None:
"""
- Options for the resource. Main functionality is to inform the client is a specific HTTP method is supported by
+ Options for the resource (HTTP OPTIONS method).
+
+ Main functionality is to inform the client if a specific HTTP method is supported by
the property or the action (Access-Control-Allow-Methods).
"""
if await self.has_access_control():
@@ -383,7 +456,7 @@ async def options(self) -> None:
async def handle_through_thing(self, operation: str) -> None:
"""
- handles the `Thing` operations and writes the reply to the HTTP client.
+ Handles the `Thing` operations and writes the reply to the HTTP client.
Parameters
----------
@@ -453,7 +526,7 @@ async def handle_through_thing(self, operation: str) -> None:
self.write(response_payload.value)
async def handle_no_block_response(self) -> None:
- """handles the no-block response for the noblock calls"""
+ """Handles the response for a previously made noblock call."""
try:
self.logger.info("waiting for no-block response", message_id=self.message_id)
response_message = await self.thing.recv_response(
@@ -486,9 +559,9 @@ async def handle_no_block_response(self) -> None:
class PropertyHandler(RPCHandler):
- """handles property requests"""
+ """handles property requests."""
- async def get(self) -> None:
+ async def get(self) -> None: # noqa: D102
if await self.is_method_allowed("GET"):
self.set_custom_default_headers()
if self.message_id is not None:
@@ -497,19 +570,19 @@ async def get(self) -> None:
await self.handle_through_thing(Operations.readproperty)
self.finish()
- async def post(self) -> None:
+ async def post(self) -> None: # noqa: D102
if await self.is_method_allowed("POST"):
self.set_custom_default_headers()
await self.handle_through_thing(Operations.writeproperty)
self.finish()
- async def put(self) -> None:
+ async def put(self) -> None: # noqa: D102
if await self.is_method_allowed("PUT"):
self.set_custom_default_headers()
await self.handle_through_thing(Operations.writeproperty)
self.finish()
- async def delete(self) -> None:
+ async def delete(self) -> None: # noqa: D102
if await self.is_method_allowed("DELETE"):
self.set_custom_default_headers()
await self.handle_through_thing(Operations.deleteproperty)
@@ -517,9 +590,9 @@ async def delete(self) -> None:
class ActionHandler(RPCHandler):
- """handles action requests"""
+ """handles action requests."""
- async def get(self) -> None:
+ async def get(self) -> None: # noqa: D102
if await self.is_method_allowed("GET"):
self.set_custom_default_headers()
if self.message_id is not None:
@@ -528,19 +601,19 @@ async def get(self) -> None:
await self.handle_through_thing(Operations.invokeaction)
self.finish()
- async def post(self) -> None:
+ async def post(self) -> None: # noqa: D102
if await self.is_method_allowed("POST"):
self.set_custom_default_headers()
await self.handle_through_thing(Operations.invokeaction)
self.finish()
- async def put(self) -> None:
+ async def put(self) -> None: # noqa: D102
if await self.is_method_allowed("PUT"):
self.set_custom_default_headers()
await self.handle_through_thing(Operations.invokeaction)
self.finish()
- async def delete(self) -> None:
+ async def delete(self) -> None: # noqa: D102
if await self.is_method_allowed("DELETE"):
self.set_custom_default_headers()
await self.handle_through_thing(Operations.invokeaction)
@@ -548,9 +621,9 @@ async def delete(self) -> None:
class RWMultiplePropertiesHandler(ActionHandler):
- """handles read-write of multiple properties via an action"""
+ """handles read-write of multiple properties via an action."""
- def initialize(
+ def initialize( # type: ignore # noqa
self,
resource: ActionAffordance,
config: Any,
@@ -562,7 +635,7 @@ def initialize(
self.write_properties_resource = kwargs.get("write_properties_resource", None)
return super().initialize(resource, config, logger, metadata)
- async def get(self) -> None:
+ async def get(self) -> None: # noqa: D102
if await self.is_method_allowed("GET"):
self.set_custom_default_headers()
self.resource = self.read_properties_resource
@@ -572,19 +645,19 @@ async def get(self) -> None:
await self.handle_through_thing(Operations.invokeaction)
self.finish()
- async def post(self) -> None:
+ async def post(self) -> None: # noqa: D102
if await self.is_method_allowed("POST"):
self.set_status(405, "method not allowed, PUT instead")
self.finish()
- async def put(self) -> None:
+ async def put(self) -> None: # noqa: D102
if await self.is_method_allowed("PUT"):
self.set_custom_default_headers()
self.resource = self.write_properties_resource
await self.handle_through_thing(Operations.invokeaction)
self.finish()
- async def patch(self) -> None:
+ async def patch(self) -> None: # noqa: D102
if await self.is_method_allowed("PATCH"):
self.set_custom_default_headers()
self.resource = self.write_properties_resource
@@ -593,9 +666,9 @@ async def patch(self) -> None:
class EventHandler(BaseHandler):
- """handles events emitted by `Thing` and tunnels them as HTTP SSE"""
+ """handles events emitted by `Thing` and tunnels them as HTTP SSE."""
- def initialize(
+ def initialize( # noqa
self,
resource: InteractionAffordance | EventAffordance,
config: Any,
@@ -607,7 +680,9 @@ def initialize(
def set_custom_default_headers(self) -> None:
"""
- sets default headers for event handling. The general headers are listed as follows:
+ Sets default headers for event handling.
+
+ The general headers are listed as follows:
```yaml
Content-Type: text/event-stream
@@ -623,14 +698,14 @@ def set_custom_default_headers(self) -> None:
super().set_custom_default_headers()
async def get(self):
- """events are support only with GET method"""
+ """Events are support only with GET method."""
if await self.has_access_control():
self.set_custom_default_headers()
await self.handle_datastream()
self.finish()
async def options(self):
- """options for the resource"""
+ """Options for the resource."""
if await self.has_access_control():
self.set_status(204)
self.set_custom_default_headers()
@@ -638,12 +713,24 @@ async def options(self):
self.set_header("Access-Control-Allow-Methods", "GET")
self.finish()
- def receive_blocking_event(self, event_consumer: EventConsumer):
- """deprecated, but can make a blocking call in an async loop"""
- return event_consumer.receive(timeout=10000, deserialize=False)
+ def receive_blocking_event(self, event_consumer: EventConsumer) -> Any:
+ """
+ Deprecated, but can make a blocking call in an async loop.
+
+ Parameters
+ ----------
+ event_consumer: EventConsumer
+ The event consumer to receive events from.
+
+ Returns
+ -------
+ Any
+ The received event.
+ """
+ return event_consumer.receive(timeout=10000)
async def handle_datastream(self) -> None:
- """called by GET method and handles the event publishing"""
+ """Called by GET method and handles the event publishing."""
try:
event_consumer = self.thing.subscribe_event(self.resource)
self.set_status(200)
@@ -671,9 +758,9 @@ async def handle_datastream(self) -> None:
class JPEGImageEventHandler(EventHandler):
- """handles events with images with JPEG image data header"""
+ """Handles events with images with JPEG image data header."""
- def initialize(
+ def initialize( # noqa
self,
resource: InteractionAffordance | EventAffordance,
config: Any,
@@ -685,9 +772,9 @@ def initialize(
class PNGImageEventHandler(EventHandler):
- """handles events with images with PNG image data header"""
+ """Handles events with images with PNG image data header."""
- def initialize(
+ def initialize( # noqa
self,
resource: InteractionAffordance | EventAffordance,
config: Any,
@@ -699,9 +786,9 @@ def initialize(
class StopHandler(BaseHandler):
- """Stops the tornado HTTP server"""
+ """Stops the tornado HTTP server."""
- def initialize(
+ def initialize( # type:ignore # noqa
self,
config: Any,
logger: structlog.stdlib.BoundLogger,
@@ -716,7 +803,7 @@ def initialize(
self.security_schemes = self.config.security_schemes
self.server = owner_inst # type: HTTPServer
- async def post(self):
+ async def post(self): # noqa: D102
if not await self.has_access_control():
return
try:
@@ -736,9 +823,9 @@ async def post(self):
class LivenessProbeHandler(BaseHandler):
- """Liveness probe handler"""
+ """Liveness probe handler."""
- def initialize(
+ def initialize( # type: ignore # noqa
self,
config: Any,
logger: structlog.stdlib.BoundLogger,
@@ -751,16 +838,16 @@ def initialize(
self.logger = logger.bind(path=self.request.path)
self.server = owner_inst # type: HTTPServer
- async def get(self):
+ async def get(self): # noqa: D102
self.set_status(200, "ok")
self.set_custom_default_headers()
self.finish()
class ReadinessProbeHandler(BaseHandler):
- """Readiness probe handler"""
+ """Readiness probe handler."""
- def initialize(
+ def initialize( # type: ignore # noqa
self,
config: Any,
logger: structlog.stdlib.BoundLogger,
@@ -773,7 +860,7 @@ def initialize(
self.logger = logger.bind(path=self.request.path)
self.server = owner_inst # type: HTTPServer
- async def get(self):
+ async def get(self): # noqa: D102
self.set_custom_default_headers()
try:
if len(self.server._disconnected_things) > 0:
@@ -794,9 +881,9 @@ async def get(self):
class ThingDescriptionHandler(BaseHandler):
- """Thing Description handler"""
+ """Thing Description handler."""
- def initialize(
+ def initialize( # type: ignore # noqa
self,
resource: InteractionAffordance | PropertyAffordance,
config: Any,
@@ -817,7 +904,7 @@ def initialize(
server=owner_inst,
)
- async def get(self):
+ async def get(self): # noqa: D102
if not await self.has_access_control():
self.finish()
return
diff --git a/hololinked/server/http/server.py b/hololinked/server/http/server.py
index 15fd31f0..4406d7e3 100644
--- a/hololinked/server/http/server.py
+++ b/hololinked/server/http/server.py
@@ -1,3 +1,5 @@
+"""HTTP server implementation to expose `Thing` over HTTP protocol."""
+
import socket
import ssl
import warnings
@@ -47,8 +49,10 @@
class HTTPServer(BaseProtocolServer):
"""
- HTTP(s) server to expose `Thing` over HTTP protocol. Supports HTTP 1.1.
- Use `add_thing`, or `add_property` or `add_action` or `add_event` methods to add things to the server.
+ HTTP(s) server to expose `Thing` over HTTP protocol.
+
+ Supports only HTTP 1.1. Use `add_thing`, or `add_property` or `add_action` or `add_event` methods to
+ add things to the server.
"""
address = IPAddress(default="0.0.0.0", doc="IP address") # type: str
@@ -85,6 +89,8 @@ def __init__(
**kwargs,
) -> None:
"""
+ Initialize the HTTP server.
+
Parameters
----------
port: int, default 8080
@@ -113,7 +119,7 @@ def __init__(
or RuntimeConfig attributes can be passed as keyword arguments.
"""
- default_config = dict(
+ default_config: dict[str, Any] = dict(
cors=global_config.ALLOW_CORS,
property_handler=kwargs.get("property_handler", PropertyHandler),
action_handler=kwargs.get("action_handler", ActionHandler),
@@ -129,14 +135,14 @@ def __init__(
security_schemes=security_schemes,
)
default_config.update(config or dict())
- config = RuntimeConfig(**default_config)
+ updated_config = RuntimeConfig(**default_config)
# need to be extended when more options are added
super().__init__(
port=port,
address=address,
logger=logger,
ssl_context=ssl_context,
- config=config,
+ config=updated_config,
)
self._IP = f"{self.address}:{self.port}" # TODO, remove this variable later?
@@ -144,7 +150,6 @@ def __init__(
if self.logger is None:
self.logger = structlog.get_logger().bind(component="http-server", host=f"{self.address}:{self.port}")
- self.tornado_instance = None
self.app = Application(
handlers=[
(
@@ -165,6 +170,8 @@ def __init__(
]
)
self.router = ApplicationRouter(self.app, self)
+ self.tornado_event_loop = ioloop.IOLoop.current()
+ self.tornado_instance = TornadoHTTP1Server(self.app, ssl_options=self.ssl_context) # type: TornadoHTTP1Server
self.zmq_client_pool = MessageMappedZMQClientPool(
id=self.id,
@@ -175,8 +182,15 @@ def __init__(
)
self.add_things(*(things or []))
- def setup(self) -> None:
- """check if all the requirements are met before starting the server, auto invoked by listen()"""
+ def setup(self) -> None: # type: ignore
+ """
+ Check if all the requirements are met before starting the server, auto invoked by listen().
+
+ Raises
+ ------
+ ValueError
+ TODO
+ """
# Add only those code here that needs to be redone always before restarting the server.
# One time creation attributes/activities must be in init
@@ -187,7 +201,7 @@ def setup(self) -> None:
# 2. sets async loop for a non-possessing thread as well
event_loop = get_current_async_loop()
# 3. schedule the ZMQ client pool polling
- event_loop.create_task(self.zmq_client_pool.poll_responses())
+ event_loop.create_task(self.zmq_client_pool.poll_responses()) # type: ignore TODO remove
# self.zmq_client_pool.handshake(), NOTE - handshake better done upfront as we already poll_responses here
# which will prevent handshake function to succeed (although handshake will be done)
# 4. Expose via broker
@@ -204,8 +218,6 @@ def setup(self) -> None:
# 5. finally also get a reference of the same event loop from tornado
self.tornado_event_loop = ioloop.IOLoop.current()
- self.tornado_instance = TornadoHTTP1Server(self.app, ssl_options=self.ssl_context) # type: TornadoHTTP1Server
-
async def start(self) -> None:
self.setup()
self.tornado_instance.listen(port=self.port, address=self.address)
@@ -214,6 +226,7 @@ async def start(self) -> None:
def stop(self, attempt_async_stop: bool = True) -> None:
"""
Stop the HTTP server - unreliable, use `async_stop()` if possible.
+
A stop handler at the path `/stop` with POST method is already implemented that invokes this
method for the clients.
@@ -225,7 +238,7 @@ def stop(self, attempt_async_stop: bool = True) -> None:
if attempt_async_stop:
run_callable_somehow(self.async_stop())
return
- self.zmq_client_pool.stop_polling()
+ self.zmq_client_pool.stop_polling() # type: ignore # TODO remove
if not self.tornado_instance:
return
self.tornado_instance.stop()
@@ -233,10 +246,12 @@ def stop(self, attempt_async_stop: bool = True) -> None:
async def async_stop(self) -> None:
"""
- Stop the HTTP server. A stop handler at the path `/stop` with POST method is already implemented
- that invokes this method for the clients.
+ Stop the HTTP server.
+
+ A stop handler at the path `/stop` with POST method is already implemented that invokes this method for the
+ clients.
"""
- self.zmq_client_pool.stop_polling()
+ self.zmq_client_pool.stop_polling() # type: ignore # TODO remove
if not self.tornado_instance:
return
try:
@@ -248,12 +263,12 @@ async def async_stop(self) -> None:
+ f"from hololinked.server and do not reuse the port - {ex}"
)
- def add_property(
+ def add_property( # type: ignore
self,
URL_path: str,
property: Property | PropertyAffordance,
- http_methods: str | tuple[str, str, str] = ("GET", "PUT"),
- handler: BaseHandler | PropertyHandler = PropertyHandler,
+ http_methods: str | tuple[str, str] | tuple[str, str, str] = ("GET", "PUT"),
+ handler: type[BaseHandler | PropertyHandler] = PropertyHandler,
**kwargs,
) -> None:
"""
@@ -272,6 +287,15 @@ def add_property(
custom handler for the property, otherwise the default handler will be used
kwargs: dict
additional keyword arguments to be passed to the handler's __init__
+
+ Raises
+ ------
+ TypeError
+ if the property is not of type `Property` or `PropertyAffordance`, or the handler is not subclass of
+ `BaseHandler`
+ ValueError
+ if the http methods are not valid (read should be GET, write should be POST or PUT, delete should be
+ DELETE)
"""
if not isinstance(property, (Property, PropertyAffordance)):
raise TypeError(f"property should be of type Property, given type {type(property)}")
@@ -299,16 +323,16 @@ def add_property(
kwargs["metadata"] = HandlerMetadata(http_methods=http_methods)
self.router.add_rule(affordance=property, URL_path=URL_path, handler=handler, kwargs=kwargs)
- def add_action(
+ def add_action( # type: ignore
self,
URL_path: str,
action: Action | ActionAffordance,
http_method: str | None = "POST",
- handler: BaseHandler | ActionHandler = ActionHandler,
+ handler: type[BaseHandler | ActionHandler] = ActionHandler,
**kwargs,
) -> None:
"""
- Add an action to be accessible by HTTP
+ Add an action to be accessible by HTTP.
Parameters
----------
@@ -322,6 +346,12 @@ def add_action(
custom handler for the action
kwargs: dict
additional keyword arguments to be passed to the handler's __init__
+
+ Raises
+ ------
+ TypeError
+ if the action is not of type `Action` or `ActionAffordance`, or the handler is not subclass of
+ `BaseHandler`
"""
if not isinstance(action, (Action, ActionAffordance)):
raise TypeError(f"Given action should be of type Action or ActionAffordance, given type {type(action)}")
@@ -336,15 +366,15 @@ def add_action(
kwargs["metadata"] = HandlerMetadata(http_methods=http_methods)
self.router.add_rule(affordance=action, URL_path=URL_path, handler=handler, kwargs=kwargs)
- def add_event(
+ def add_event( # type: ignore
self,
URL_path: str,
event: Event | EventAffordance | PropertyAffordance,
- handler: BaseHandler | EventHandler = EventHandler,
+ handler: type[BaseHandler | EventHandler] = EventHandler,
**kwargs,
) -> None:
"""
- Add an event to be accessible by HTTP server; only GET method is supported for events.
+ Add an event to be accessible by HTTP server, only GET method is supported for events.
Parameters
----------
@@ -356,6 +386,12 @@ def add_event(
custom handler for the event
kwargs: dict
additional keyword arguments to be passed to the handler's __init__
+
+ Raises
+ ------
+ TypeError
+ if the event is not of type `Event` or `EventAffordance`, or the handler is not subclass of
+ `BaseHandler`
"""
if not isinstance(event, (Event, EventAffordance)) and (
not isinstance(event, PropertyAffordance) or not event.observable
@@ -389,9 +425,9 @@ def __str__(self):
class ApplicationRouter:
"""
- Covering implementation (as in - a layer on top) of the application router to
- add rules to the tornado application. Not a real router, which is taken care of
- by the tornado application automatically.
+ Covering implementation (as in - a layer on top) of the application router to add rules to the tornado application.
+
+ Not a real router, which is taken care of by the tornado application automatically.
"""
def __init__(self, app: Application, server: HTTPServer) -> None:
@@ -410,7 +446,9 @@ def add_rule(
kwargs: dict[str, Any],
) -> None:
"""
- Add rule to the application router. Note that this method will replace existing rules and can duplicate
+ Add rule to the application router.
+
+ Note that this method will replace existing rules and can duplicate
endpoints for an affordance without checks (i.e. you could technically add two different endpoints for the
same affordance).
@@ -424,6 +462,11 @@ def add_rule(
handler class to be used for the affordance
kwargs: dict[str, Any]
additional keyword arguments to be passed to the handler's __init__
+
+ Raises
+ ------
+ RuntimeError
+ if the affordance does not have thing id or thing class associated with it, which is required to add a rule
"""
for rule in self.app.wildcard_router.rules:
if rule.matcher == URL_path:
@@ -493,10 +536,11 @@ def add_interaction_affordances(
properties: Iterable[PropertyAffordance],
actions: Iterable[ActionAffordance],
events: Iterable[EventAffordance],
- thing_id: str = None,
+ thing_id: str | None = None,
) -> None:
"""
Can add multiple properties, actions and events at once to the application router.
+
Calls `add_rule` method internally for each affordance.
Parameters
@@ -521,7 +565,7 @@ def add_interaction_affordances(
self.server.add_property(
URL_path=path,
property=property,
- http_methods=("GET",) if property.readOnly else ("GET", "PUT"),
+ http_methods="GET" if property.readOnly else ("GET", "PUT"),
# if prop.fdel is None else ('GET', 'PUT', 'DELETE')
handler=self.server.config.property_handler,
)
@@ -549,11 +593,11 @@ def add_interaction_affordances(
self.server.add_event(URL_path=path, event=event, handler=self.server.config.event_handler)
# thing model handler
- get_thing_model_action = next((action for action in actions if action.name == "get_thing_model"), None)
+ get_thing_model_action = next((action for action in actions if action.name == "get_thing_model"))
self.server.add_action(
URL_path=f"/{thing_id}/resources/wot-tm" if thing_id else "/resources/wot-tm",
action=get_thing_model_action,
- http_method=("GET",),
+ http_method="GET",
)
# thing description handler
@@ -562,7 +606,7 @@ def add_interaction_affordances(
self.server.add_action(
URL_path=f"/{thing_id}/resources/wot-td" if thing_id else "/resources/wot-td",
action=get_thing_description_action,
- http_method=("GET",),
+ http_method="GET",
handler=self.server.config.thing_description_handler,
owner_inst=self.server,
)
@@ -575,7 +619,7 @@ def add_interaction_affordances(
self.server.add_action(
URL_path=f"/{thing_id}/properties" if thing_id else "/properties",
action=read_properties,
- http_method=("GET", "PUT", "PATCH"),
+ http_method=("GET", "PUT", "PATCH"), # type: ignore[invalid-argument-type]
handler=self.server.config.RW_multiple_properties_handler,
read_properties_resource=read_properties,
write_properties_resource=write_properties,
@@ -584,12 +628,16 @@ def add_interaction_affordances(
# can add an entire thing instance at once
def add_thing(self, thing: Thing) -> None:
"""
- internal method to add a thing instance to be served by the HTTP server. Iterates through the
- interaction affordances and adds a route for each property, action and event.
+ Internal method to add a thing instance to be served by the HTTP server.
+
+ Iterates through the interaction affordances and adds a route for each property, action and event.
+
+ Parameters
+ ----------
+ thing: Thing
+ thing instance to be added
"""
# Prepare affordance lists with error handling (single loop)
- if not isinstance(thing, Thing):
- raise TypeError(f"thing should be of type Thing, unknown type given - {type(thing)}")
TM = thing.get_thing_model(ignore_errors=True).json()
properties, actions, events = [], [], []
for prop in TM.get("properties", dict()).keys():
@@ -619,6 +667,7 @@ def _resolve_rules(
) -> None:
"""
Process the pending rules and add them to the application router.
+
Rules become pending only when a property, action or event has a thing class associated
but no thing instance.
"""
@@ -641,8 +690,13 @@ def __contains__(
) -> bool:
"""
Check if the item is in the application router.
+
Not exact for torando's rules when a string is provided for the URL path,
as you need to provide the Matcher object
+
+ Returns
+ -------
+ bool
"""
if isinstance(item, str):
for rule in self.app.wildcard_router.rules:
@@ -664,7 +718,7 @@ def __contains__(
return True
return False
- def get_href_for_affordance(self, affordance, authority: str = None, use_localhost: bool = False) -> str:
+ def get_href_for_affordance(self, affordance, authority: str | None = None, use_localhost: bool = False) -> str:
"""
Get the full URL path for the affordance in the application router.
@@ -681,6 +735,11 @@ def get_href_for_affordance(self, affordance, authority: str = None, use_localho
-------
str
full URL path for the affordance
+
+ Raises
+ ------
+ ValueError
+ if the affordance is not found in the application router
"""
if affordance not in self:
raise ValueError(f"affordance {affordance} not found in the application router")
@@ -688,9 +747,27 @@ def get_href_for_affordance(self, affordance, authority: str = None, use_localho
if rule.target_kwargs.get("resource", None) == affordance:
path = str(rule.matcher.regex.pattern).rstrip("$")
return f"{self.get_basepath(authority, use_localhost)}{path}"
+ raise ValueError(f"affordance {affordance} not found in the application router rules")
def get_injected_dependencies(self, affordance) -> dict[str, Any]:
- """Get the target kwargs for the affordance in the application router"""
+ """
+ Get the target kwargs for the affordance in the application router.
+
+ Parameters
+ ----------
+ affordance: PropertyAffordance | ActionAffordance | EventAffordance
+ the interaction affordance for which the target kwargs are to be retrieved
+
+ Returns
+ -------
+ dict[str, Any]
+ target kwargs for the affordance
+
+ Raises
+ ------
+ ValueError
+ if the affordance is not found in the application router
+ """
if affordance not in self:
raise ValueError(f"affordance {affordance} not found in the application router")
for rule in self.app.wildcard_router.rules:
@@ -701,7 +778,7 @@ def get_injected_dependencies(self, affordance) -> dict[str, Any]:
return rule[2]
raise ValueError(f"affordance {affordance} not found in the application router rules")
- def get_basepath(self, authority: str = None, use_localhost: bool = False) -> str:
+ def get_basepath(self, authority: str | None = None, use_localhost: bool = False) -> str:
"""
Get the basepath of the server.
@@ -711,6 +788,11 @@ def get_basepath(self, authority: str = None, use_localhost: bool = False) -> st
authority (protocol + host + port) to be used in the basepath. If None, the machine's hostname is used.
use_localhost: bool, default `False`
if `True`, localhost is used in the basepath instead of the server's hostname.
+
+ Returns
+ -------
+ str
+ basepath of the server
"""
if authority:
return authority
@@ -728,13 +810,44 @@ def get_basepath(self, authority: str = None, use_localhost: bool = False) -> st
basepath = property(fget=get_basepath, doc="basepath of the server")
def adapt_route(self, interaction_affordance_name: str) -> str:
- """adapt the URL path to default conventions"""
+ """
+ Adapt the URL path to default conventions.
+
+ Parameters
+ ----------
+ interaction_affordance_name: str
+ name of the interaction affordance to be adapted to URL path
+
+ Returns
+ -------
+ str
+ adapted URL path for the interaction affordance
+ """
if interaction_affordance_name == "get_thing_model":
return "/resources/wot-tm"
return f"/{pep8_to_dashed_name(interaction_affordance_name)}"
def adapt_http_methods(self, http_methods: Any):
- """comply the supplied HTTP method to the router to a tuple and check if the method is supported"""
+ """
+ Comply the supplied HTTP method to the router to a tuple and check if the method is supported.
+
+ Parameters
+ ----------
+ http_methods: str | tuple
+ HTTP method(s) to be adapted
+
+ Returns
+ -------
+ tuple
+ adapted HTTP methods
+
+ Raises
+ ------
+ TypeError
+ if the http_methods is not a string or a tuple of strings
+ ValueError
+ if any of the http_methods is not supported (i.e. not in HTTP_METHODS enum)
+ """
if isinstance(http_methods, str):
http_methods = (http_methods,)
if not isinstance(http_methods, tuple):
@@ -747,10 +860,11 @@ def adapt_http_methods(self, http_methods: Any):
def print_rules(self) -> None:
"""
Print the rules in the application router.
+
prettytable is used if available, otherwise a simple print is done.
"""
try:
- from prettytable import PrettyTable
+ from prettytable import PrettyTable # type: ignore
table = PrettyTable()
table.field_names = ["URL Path", "Handler", "Resource Name"]
diff --git a/hololinked/server/http/services.py b/hololinked/server/http/services.py
index 6b172d66..8450d84f 100644
--- a/hololinked/server/http/services.py
+++ b/hololinked/server/http/services.py
@@ -1,10 +1,12 @@
+"""Services assisting the implementation of the HTTP protocol."""
+
import copy
-from typing import Any
+from typing import Any, cast
import structlog
-from ...constants import JSONSerializable, Operations
+from ...constants import Operations
from ...core.zmq.message import ERROR, INVALID_MESSAGE, TIMEOUT
from ...serializers import Serializers
from ...serializers.payloads import SerializableData
@@ -28,7 +30,7 @@
class ThingDescriptionService:
- """Service layer to generate HTTP TD"""
+ """Service layer to generate HTTP TD."""
def __init__(
self,
@@ -51,10 +53,10 @@ async def generate(
ignore_errors: bool = False,
skip_names: list[str] = [],
use_localhost: bool = False,
- authority: str = None,
- ) -> dict[str, JSONSerializable]:
+ authority: str | None = None,
+ ) -> dict[str, Any]:
"""
- generate the HTTP Thing Description
+ Generate the HTTP Thing Description.
Parameters
----------
@@ -66,6 +68,11 @@ async def generate(
if `True`, localhost is used in the TD URLs instead of the server's hostname.
authority: str, optional
custom authority (protocol + host + port) to be used in the TD URLs. If None, the machine's hostname is used.
+
+ Returns
+ -------
+ dict[str, Any]
+ The generated Thing Description as a dictionary
"""
ZMQ_TD = await self.get_ZMQ_TD(ignore_errors=ignore_errors, skip_names=skip_names)
TD = copy.deepcopy(ZMQ_TD)
@@ -81,20 +88,20 @@ async def generate(
def add_properties(
self,
- TD: dict[str, JSONSerializable],
- ZMQ_TD: dict[str, JSONSerializable],
- authority: str,
+ TD: dict[str, Any],
+ ZMQ_TD: dict[str, Any],
+ authority: str | None,
ignore_errors: bool,
use_localhost: bool,
) -> None:
"""
- add properties to the TD with forms
+ Add properties to the TD with forms.
Parameters
----------
- TD: dict[str, JSONSerializable]
+ TD: dict[str, Any]
The Thing Description to which properties are to be added
- ZMQ_TD: dict[str, JSONSerializable]
+ ZMQ_TD: dict[str, Any]
The ZMQ Thing Description from which properties are to be read
authority: str
authority (protocol + host + port) to be used in the TD URLs
@@ -106,7 +113,7 @@ def add_properties(
from .config import HandlerMetadata
for name in ZMQ_TD.get("properties", []):
- affordance = PropertyAffordance.from_TD(name, ZMQ_TD)
+ affordance = cast(PropertyAffordance, PropertyAffordance.from_TD(name, ZMQ_TD))
TD["properties"][name]["forms"] = []
try:
href = self.server.router.get_href_for_affordance(
@@ -152,20 +159,20 @@ def add_properties(
def add_actions(
self,
- TD: dict[str, JSONSerializable],
- ZMQ_TD: dict[str, JSONSerializable],
- authority: str,
+ TD: dict[str, Any],
+ ZMQ_TD: dict[str, Any],
+ authority: str | None,
ignore_errors: bool,
use_localhost: bool,
) -> None:
"""
- add actions to the TD with forms
+ Add actions to the TD with forms.
Parameters
----------
- TD: dict[str, JSONSerializable]
+ TD: dict[str, Any]
The Thing Description to which actions are to be added
- ZMQ_TD: dict[str, JSONSerializable]
+ ZMQ_TD: dict[str, Any]
The ZMQ Thing Description from which actions are to be read
authority: str
authority (protocol + host + port) to be used in the TD URLs
@@ -207,20 +214,20 @@ def add_actions(
def add_events(
self,
- TD: dict[str, JSONSerializable],
- ZMQ_TD: dict[str, JSONSerializable],
- authority: str,
+ TD: dict[str, Any],
+ ZMQ_TD: dict[str, Any],
+ authority: str | None,
ignore_errors: bool,
use_localhost: bool,
) -> None:
"""
- add events to the TD with forms
+ Add events to the TD with forms.
Parameters
----------
- TD: dict[str, JSONSerializable]
+ TD: dict[str, Any]
The Thing Description to which events are to be added
- ZMQ_TD: dict[str, JSONSerializable]
+ ZMQ_TD: dict[str, Any]
The ZMQ Thing Description from which events are to be read
authority: str
authority (protocol + host + port) to be used in the TD URLs
@@ -242,7 +249,7 @@ def add_events(
)
http_methods = (
self.server.router.get_injected_dependencies(affordance)
- .get("metadata", HandlerMetadata(http_methods=["GET"]))
+ .get("metadata", HandlerMetadata(http_methods=("GET",)))
.http_methods
) # type: tuple[str]
except ValueError as ex:
@@ -263,12 +270,11 @@ def add_events(
def add_top_level_forms(
self,
- TD: dict[str, JSONSerializable],
- authority: str,
+ TD: dict[str, Any],
+ authority: str | None,
use_localhost: bool,
) -> None:
- """adds top level forms for reading and writing multiple properties"""
-
+ """Adds top level forms for reading and writing multiple properties."""
properties_end_point = f"{self.server.router.get_basepath(authority, use_localhost)}/{TD['id']}/properties"
if TD.get("forms", None) is None:
@@ -302,8 +308,8 @@ def add_top_level_forms(
writemultipleproperties.contentType = "application/json"
TD["forms"].append(writemultipleproperties.json())
- def add_security_definitions(self, TD: dict[str, JSONSerializable]) -> None:
- """adds security definitions to the TD"""
+ def add_security_definitions(self, TD: dict[str, Any]) -> None:
+ """Adds security definitions to the TD."""
from ...td.security_definitions import (
APIKeySecurityScheme,
BasicSecurityScheme,
@@ -337,12 +343,33 @@ def add_security_definitions(self, TD: dict[str, JSONSerializable]) -> None:
TD["securityDefinitions"][scheme.name] = oidc_sec.json()
TD["security"].append(scheme.name)
- def add_links(self, TD: dict[str, JSONSerializable]) -> None:
- """adds custom links to the TD, override this in subclass"""
+ def add_links(self, TD: dict[str, Any]) -> None:
+ """Adds custom links to the TD, override this in subclass."""
pass
- async def get_ZMQ_TD(self, ignore_errors: bool = False, skip_names: list[str] = []) -> dict[str, JSONSerializable]:
- """fetch the TM or ZMQ in process queue TD"""
+ async def get_ZMQ_TD(self, ignore_errors: bool = False, skip_names: list[str] = []) -> dict[str, Any]:
+ """
+ Fetch the TM or ZMQ in process queue TD.
+
+ Parameters
+ ----------
+ ignore_errors: bool, default `False`
+ if `True`, errors while generating metadata for an affordances is ignored
+ skip_names: list[str], default `[]`
+ list of affordance names to skip while generating the TD
+
+ Returns
+ -------
+ dict[str, Any]
+ The generated TD as a dictionary
+
+ Raises
+ ------
+ RuntimeError
+ if there is an error while fetching the TD from the thing
+ ValueError
+ if the payload received from the thing is invalid
+ """
response_message = await self.thing.execute(
objekt=self.resource.name,
operation=Operations.invokeaction,
diff --git a/hololinked/server/mqtt/__init__.py b/hololinked/server/mqtt/__init__.py
index 77417417..b47f50a3 100644
--- a/hololinked/server/mqtt/__init__.py
+++ b/hololinked/server/mqtt/__init__.py
@@ -1,2 +1,4 @@
+"""MQTT Publisher."""
+
from .controllers import ThingDescriptionPublisher, TopicPublisher # noqa: F401
from .server import MQTTPublisher # noqa: F401
diff --git a/hololinked/server/mqtt/config.py b/hololinked/server/mqtt/config.py
index f7ec9843..465443f5 100644
--- a/hololinked/server/mqtt/config.py
+++ b/hololinked/server/mqtt/config.py
@@ -1,3 +1,5 @@
+"""Configruration and dependency injection for MQTT publishers."""
+
from typing import Annotated, Any
from pydantic import BaseModel, Field
@@ -9,6 +11,7 @@
class RuntimeConfig(BaseModel):
"""
Runtime configuration for MQTT publishers, initialized in `MQTTPublisher` object.
+
Pass the attributes of this class as a dictionary to the `config` argument of `MQTTPublisher`.
"""
diff --git a/hololinked/server/mqtt/controllers.py b/hololinked/server/mqtt/controllers.py
index 32d8fc36..7f156187 100644
--- a/hololinked/server/mqtt/controllers.py
+++ b/hololinked/server/mqtt/controllers.py
@@ -1,3 +1,5 @@
+"""MQTT Topic Publishers."""
+
from typing import Any
import aiomqtt
@@ -11,7 +13,9 @@
class TopicPublisher:
"""
- Publishes an event to an MQTT topic. Supply a different class in `MQTTPublisher` to use a different one.
+ Publishes an event to an MQTT topic.
+
+ Supply a different class in `MQTTPublisher` to use a different one.
This object would be a controller in layered architecture.
"""
@@ -23,6 +27,8 @@ def __init__(
logger: structlog.stdlib.BoundLogger,
) -> None:
"""
+ Initializes the `TopicPublisher`.
+
Parameters
----------
client: aiomqtt.Client
@@ -46,11 +52,11 @@ def __init__(
self._stop_publishing = False
def stop(self):
- """stop publishing, the client is not closed automatically"""
+ """Stop publishing, the client is not closed automatically."""
self._stop_publishing = True
async def publish(self):
- """Publishes events to the MQTT broker in an infinite loop"""
+ """Publishes events to the MQTT broker in an infinite loop."""
consumer = self.thing.subscribe_event(self.resource)
self.logger.info(f"Starting to publish events for {self.resource.name} to MQTT broker on topic {self.topic}")
while not self._stop_publishing:
@@ -73,7 +79,9 @@ async def publish(self):
class ThingDescriptionPublisher:
"""
- Publishes Thing Description to an MQTT Topic. Supply a different class in `MQTTPublisher` to use a different one.
+ Publishes Thing Description to an MQTT Topic.
+
+ Supply a different class in `MQTTPublisher` to use a different one.
This object would be a controller in layered architecture.
"""
@@ -85,6 +93,8 @@ def __init__(
ZMQ_TD: dict[str, Any],
) -> None:
"""
+ Initializes the `ThingDescriptionPublisher`.
+
Parameters
----------
client: aiomqtt.Client
@@ -110,8 +120,8 @@ def __init__(
ssl=self.client._client._ssl_context is not None,
)
- async def publish(self, ZMQ_TD: dict[str, Any]) -> dict[str, Any]:
- """Publishes Thing Description to the MQTT broker, one-time at startup, with qos=2 and retain=True"""
+ async def publish(self, ZMQ_TD: dict[str, Any]) -> None:
+ """Publishes Thing Description to the MQTT broker, one-time at startup, with qos=2 and retain=True."""
TD = await self.thing_description.generate(ZMQ_TD)
await self.client.publish(
diff --git a/hololinked/server/mqtt/server.py b/hololinked/server/mqtt/server.py
index c4209420..0bd40c10 100644
--- a/hololinked/server/mqtt/server.py
+++ b/hololinked/server/mqtt/server.py
@@ -1,6 +1,8 @@
+"""MQTT Publisher."""
+
import ssl
-from typing import Optional, Type # noqa: F401
+from typing import Any, Optional, Type, cast # noqa: F401
import aiomqtt
import structlog
@@ -17,7 +19,9 @@
class MQTTPublisher(BaseProtocolServer):
"""
- MQTT Publisher. All events and observable properties defined on the Thing will be published to MQTT topics
+ MQTT Event Publisher.
+
+ All events and observable properties defined on the Thing will be published to MQTT topics
with topic name "{thing id}/{event name}".
For setting up an MQTT broker if one does not exist,
@@ -45,6 +49,8 @@ def __init__(
**kwargs,
):
"""
+ Initializes the `MQTTPublisher`.
+
Parameters
----------
hostname: str
@@ -65,7 +71,7 @@ def __init__(
kwargs: dict
Additional keyword arguments
"""
- default_config = dict(
+ default_config: dict[str, Any] = dict(
topic_publisher=kwargs.get("topic_publisher", TopicPublisher),
thing_description_publisher=kwargs.get("thing_description_publisher", ThingDescriptionPublisher),
thing_description_service=kwargs.get("thing_description_service", ThingDescriptionService),
@@ -73,9 +79,9 @@ def __init__(
qos=qos,
)
default_config.update(config or dict())
- config = RuntimeConfig(**default_config)
+ updated_config = RuntimeConfig(**default_config)
- super().__init__(config=config)
+ super().__init__(config=updated_config)
endpoint = f"{hostname}{f':{port}' if port else ''}"
@@ -92,6 +98,7 @@ def __init__(
async def start(self):
"""
Sets up the MQTT client and starts publishing events from the `Thing`s.
+
All events are dispatched to their own async tasks. This method returns and
creates side-effects only & does not block. Use the `run()` method instead for a blocking call.
"""
@@ -112,7 +119,19 @@ async def start(self):
await self.setup()
async def start_publishers(self, thing: CoreThing) -> None:
- """Start the publishers for a given `Thing`"""
+ """
+ Start the publishers for a given `Thing`.
+
+ Parameters
+ ----------
+ thing: CoreThing
+ The `Thing` for which to start the publishers
+
+ Raises
+ ------
+ ValueError
+ If the `Thing` is not associated with any RPC server
+ """
eventloop = get_current_async_loop()
if not thing.rpc_server:
raise ValueError(f"Thing {thing.id} is not associated with any RPC server")
@@ -132,7 +151,7 @@ async def start_publishers(self, thing: CoreThing) -> None:
eventloop.create_task(topic_publisher.publish())
self.logger.info(f"MQTT will publish events for {event_name} of thing {thing.id}")
for prop_name in TD.get("properties", {}).keys():
- property_affordance = PropertyAffordance.from_TD(prop_name, TD)
+ property_affordance = cast(PropertyAffordance, PropertyAffordance.from_TD(prop_name, TD))
if not property_affordance.observable:
continue
topic_publisher = self.config.topic_publisher(
@@ -155,18 +174,18 @@ async def start_publishers(self, thing: CoreThing) -> None:
eventloop.create_task(td_publisher.publish(TD))
async def setup(self) -> None:
- """Setup MQTT publishers per `Thing` post connection to broker"""
+ """Setup MQTT publishers per `Thing` post connection to broker."""
eventloop = get_current_async_loop()
for thing in self.things:
eventloop.create_task(self.start_publishers(thing))
def stop(self):
- """stop publishing, the client is not closed automatically"""
+ """Stop publishing, the client is not closed automatically."""
for publisher in self.publishers.values():
publisher.stop()
def add_thing(self, thing: CoreThing):
- """Add a `Thing` to the MQTT publisher and start publishing its events/observable properties"""
+ """Add a `Thing` to the MQTT publisher and start publishing its events/observable properties."""
if self.things is None:
self.things = list()
self.things.append(thing)
diff --git a/hololinked/server/mqtt/services.py b/hololinked/server/mqtt/services.py
index 9632925b..6dfb4b77 100644
--- a/hololinked/server/mqtt/services.py
+++ b/hololinked/server/mqtt/services.py
@@ -1,6 +1,8 @@
+"""Add all service-like classes here."""
+
import copy
-from typing import Any
+from typing import Any, cast
import structlog
@@ -10,7 +12,8 @@
class ThingDescriptionService:
"""
- Generates Thing Descriptions for `Thing`s.
+ Generates MQTT Thing Descriptions for `Thing`s.
+
This object would be a service in layered architecture.
"""
@@ -22,6 +25,8 @@ def __init__(
ssl: bool = True,
) -> None:
"""
+ Initializes the `ThingDescriptionService`.
+
Parameters
----------
hostname: str
@@ -96,7 +101,7 @@ def add_properties(
if name in skip_names:
continue
try:
- affordance = PropertyAffordance.from_TD(name, ZMQ_TD)
+ affordance = cast(PropertyAffordance, PropertyAffordance.from_TD(name, ZMQ_TD))
if not affordance.observable:
TD["properties"].pop(name)
continue
diff --git a/hololinked/server/repository.py b/hololinked/server/repository.py
index cb68cf1f..7e1d4cde 100644
--- a/hololinked/server/repository.py
+++ b/hololinked/server/repository.py
@@ -27,7 +27,7 @@
class BrokerThing(BaseModel):
- """Repository Layer of a Thing over the internal message broker"""
+ """Repository Layer of a Thing over the internal message broker."""
id: str
"""Thing ID"""
@@ -84,6 +84,10 @@ async def execute(
The server execution context
thing_execution_context: ThingExecutionContext, optional
The thing execution context
+
+ Raises
+ ------
+ RuntimeError
"""
if self.req_rep_client is None:
raise RuntimeError("Not connected to broker")
diff --git a/hololinked/server/security.py b/hololinked/server/security.py
index 413418b2..1cf252d8 100644
--- a/hololinked/server/security.py
+++ b/hololinked/server/security.py
@@ -1,4 +1,8 @@
-"""Implementation of security schemes for a server"""
+"""
+Implementation of security schemes that can be used by a server.
+
+Not all security schemes may be supported by all protocols.
+"""
import base64
import json
@@ -22,7 +26,7 @@
class Security(BaseModel):
- """Type definition for security schemes"""
+ """Type definition for security schemes."""
pass
@@ -33,6 +37,7 @@ class Security(BaseModel):
class BcryptBasicSecurity(Security):
"""
A username and password based security scheme using bcrypt.
+
The password is stored as a hash.
The request must supply an authorization header in of the following formats:
@@ -43,10 +48,9 @@ class BcryptBasicSecurity(Security):
The username and password are expected to be base64 encoded, by default.
Set `expect_base64=False` if you want to use plain text credentials without base64 encoding.
- Use bcrypt when you are constrained in terms of memory. Use Argon2BasicSecurity
+ Use bcrypt when you are constrained in terms of memory. Use `Argon2BasicSecurity`
when you can afford more memory (which is the recommended username-password implementation).
-
- Note: bcrypt is a cpu-hard hashing algorithm, which means it is resistant to brute-force attacks.
+ Bcrypt is a cpu-hard hashing algorithm, which means it is resistant to brute-force attacks.
"""
username: str
@@ -57,6 +61,8 @@ class BcryptBasicSecurity(Security):
def __init__(self, username: str, password: str, expect_base64: bool = True, name: str = "") -> None:
"""
+ Initialize the BcryptBasicSecurity scheme with a username and password.
+
Parameters
----------
username: str
@@ -77,7 +83,7 @@ def __init__(self, username: str, password: str, expect_base64: bool = True, nam
def validate_input(self, username: str, password: str) -> bool:
"""
- plain validate a username and password
+ Validate a username and password.
Returns
-------
@@ -91,12 +97,18 @@ def validate_input(self, username: str, password: str) -> bool:
def validate_base64(self, b64_str: str) -> bool:
"""
Validate a base64 encoded string containing username and password.
- Please strip the 'Basic ' prefix before passing as argument.
+
+ Please strip the `Basic ` prefix before passing as argument.
Returns
-------
bool
True if the username and password are valid, False otherwise
+
+ Raises
+ ------
+ ValueError
+ If the security scheme is not configured to expect base64 encoded credentials but this method is called.
"""
if not self.expect_base64:
raise ValueError("base64 encoded credentials not expected, please reconfigure if needed")
@@ -112,6 +124,7 @@ def validate_base64(self, b64_str: str) -> bool:
class BcryptBasicSecurity(Security):
"""
Placeholder for BcryptBasicSecurity when bcrypt is not installed.
+
Please install the `bcrypt` library to use Bcrypt password security and see the actual docstrings.
"""
@@ -125,6 +138,7 @@ def __init__(self, username: str, password: str, expect_base64: bool = True, nam
class Argon2BasicSecurity(Security):
"""
A username and password based security scheme using Argon2.
+
The password is stored as a hash.
The request must supply an authorization header in of the following formats:
@@ -147,6 +161,8 @@ class Argon2BasicSecurity(Security):
def __init__(self, username: str, password: str, expect_base64: bool = True, name: str = "") -> None:
"""
+ Initialize the Argon2BasicSecurity scheme with a username and password.
+
Parameters
----------
username: str
@@ -168,7 +184,7 @@ def __init__(self, username: str, password: str, expect_base64: bool = True, nam
def validate_input(self, username: str, password: str) -> bool:
"""
- plain validate a username and password
+ Plain validate a username and password.
Returns
-------
@@ -185,12 +201,18 @@ def validate_input(self, username: str, password: str) -> bool:
def validate_base64(self, b64_str: str) -> bool:
"""
Validate a base64 encoded string containing username and password.
- Please strip the 'Basic ' prefix before passing.
+
+ Please strip the `Basic ` prefix before passing.
Returns
-------
bool
True if the username and password are valid, False otherwise
+
+ Raises
+ ------
+ ValueError
+ If the security scheme is not configured to expect base64 encoded credentials but this method is called.
"""
if not self.expect_base64:
raise ValueError("base64 encoded credentials not expected, please reconfigure if needed")
@@ -206,6 +228,7 @@ def validate_base64(self, b64_str: str) -> bool:
class Argon2BasicSecurity(Security):
"""
Placeholder for Argon2BasicSecurity when argon2 is not installed.
+
Please install the `argon2-cffi` library to use Argon2 password security.
"""
@@ -217,25 +240,46 @@ def __init__(self, username: str, password: str, expect_base64: bool = True, nam
import argon2
class APIKeyRecord(BaseModel):
- """An API key record dataclass"""
+ """An API key record dataclass."""
name: str
+ """Name of the API key. A unique identifier used when storing or providing metadata about the API key."""
id: str
+ """A short random ID part of the API key, used for lookup before verifying the secret part."""
apikey_hash: str
+ """The hash of the API key, the ID is included in the hash value."""
description: str
+ """A human readable description about the API key, its purpose or any other relevant information."""
created_at: datetime
+ """The datetime when the API key was created."""
expiry_at: datetime
+ """The datetime when the API key will expire."""
hasher: str = "argon2"
+ """The hashing algorithm used, `argon2` or `bcrypt` is supported."""
@field_serializer("created_at", "expiry_at")
def serialize_datetime(self, dt: datetime, _info) -> str:
- """Serialize datetime to ISO format string"""
+ """
+ Serialize datetime to ISO format string.
+
+ Returns
+ -------
+ str
+ The datetime in ISO format string.
+ """
return dt.isoformat()
@field_validator("created_at", "expiry_at", mode="before")
@classmethod
def parse_datetime(cls, value):
- """Parse datetime from string or return datetime object"""
+ """
+ Parse datetime from string or return datetime object.
+
+ Returns
+ -------
+ datetime
+ The parsed datetime object.
+ """
if isinstance(value, str):
return datetime.fromisoformat(value)
return value
@@ -267,6 +311,8 @@ class APIKeySecurity(Security):
def __init__(self, name: str, file: str = "apikeys.json", hasher: Any = None) -> None:
"""
+ Initialize the APIKeySecurity scheme with a name and optional file for storage.
+
Parameters
----------
name: str
@@ -293,7 +339,9 @@ def create(
override: bool = False,
) -> str:
"""
- Create a new API key. Use this method to generate and store a new API key before running your application.
+ Create a new API key.
+
+ Use this method to generate and store a new API key before running your application.
The validity period, specified in minutes (default 30 days), is stored as validation metadata and not a part
of the key itself. Nevertheless, this is checked during validation.
@@ -350,7 +398,7 @@ def create(
def hash(self, apikey: str) -> str:
"""
- Create a hash of the API key for storage
+ Create a hash of the API key for storage.
Returns
-------
@@ -360,7 +408,23 @@ def hash(self, apikey: str) -> str:
return self._ph.hash(apikey)
def save(self, record: APIKeyRecord, filename: str = "apikeys.json", override: bool = False) -> None:
- """Save the security scheme data to persistent storage"""
+ """
+ Save the security scheme data to persistent storage.
+
+ Parameters
+ ----------
+ record: APIKeyRecord
+ The API key record to save.
+ filename: str
+ The file to save the API key record to, defaults to "apikeys.json".
+ override: bool
+ Whether to override an existing API key record with the same name, defaults to False.
+
+ Raises
+ ------
+ ValueError
+ If an API key with the same name already exists and override is set to False.
+ """
filepath = os.path.join(global_config.TEMP_DIR_SECRETS, filename)
existing_data = {}
if os.path.exists(filepath):
@@ -373,7 +437,14 @@ def save(self, record: APIKeyRecord, filename: str = "apikeys.json", override: b
json.dump(existing_data, file, indent=4)
def load(self) -> None:
- """load the security scheme data from persistent storage"""
+ """
+ Load the security scheme data from persistent storage.
+
+ Raises
+ ------
+ ValueError
+ If the stored data is invalid or the API key record cannot be found.
+ """
if not os.path.exists(self.file):
return
with open(self.file, "r") as file:
@@ -386,12 +457,24 @@ def load(self) -> None:
def validate_input(self, apikey: str) -> bool:
"""
- Validate an API key against a stored hash. API key ID, expiry and hash are all validated.
+ Validate an API key against a stored hash.
+
+ API key ID, expiry and hash are all validated.
+
+ Parameters
+ ----------
+ apikey: str
+ The API key to validate.
Returns
-------
bool
- True if the API key is valid, False otherwise
+ True if the API key is valid, False otherwise.
+
+ Raises
+ ------
+ ValueError
+ If no API key record is loaded for validation.
"""
if self.record is None:
raise ValueError("No API key loaded for validation")
@@ -413,6 +496,7 @@ def validate_input(self, apikey: str) -> bool:
class APIKeySecurity(Security):
"""
Placeholder for APIKeySecurity when argon2 is not installed.
+
Please install the `argon2-cffi` library to use API key security and see the actual docstrings.
"""
@@ -427,9 +511,10 @@ def __init__(self, name: str, file: str = "apikeys.json") -> None:
class OIDCSecurity(Security):
"""
- Generic OIDC JWT validation meant to be used with 'openid' scope to validate login sessions, requires the PyJWT
- library to be installed. A *JWT access token* is validated by verifying its signature against the provider's
- JWKS endpoint (JWK Set) and optionally validating issuer/audience.
+ Generic OIDC JWT validation meant to be used with 'openid' scope to validate login sessions.
+
+ Requires the PyJWT library to be installed. A *JWT access token* is validated by verifying its signature
+ against the provider's JWKS endpoint (JWK Set) and optionally validating issuer/audience.
This security scheme is not meant to be used with OAuth flows where the resource server is a third party
application from where data is fetched on behalf of the user. The server using this scheme is expected to
@@ -474,6 +559,8 @@ def __init__(
name: str = "oidc-security",
) -> None:
"""
+ Initialize the OIDCSecurity scheme with issuer, audience and JWKS URL.
+
Parameters
----------
issuer: str
@@ -559,9 +646,10 @@ def _get_jwk_client(issuer: str, jwks_uri: str | None, verify_ssl: bool) -> jwt.
logger.info(f"Discovered JWKS URI for issuer {issuer}: {jwks_uri}", component="Security")
return jwk_client
+ # TODO move this method outside the class
@staticmethod
def _get_dict_attr_by_path(payload: dict[str, Any], path: str) -> Any:
- """Get a nested value from a dict using dot-separated keys."""
+ """Get a nested value from a dict using dot-separated keys.""" # noqa
cur: Any = payload
for part in path.split("."):
if not isinstance(cur, dict):
@@ -570,7 +658,19 @@ def _get_dict_attr_by_path(payload: dict[str, Any], path: str) -> Any:
return cur
def decode_and_validate(self, token: str) -> dict[str, Any]:
- """Decode and validate a JWT, returning the verified payload."""
+ """
+ Decode and validate a JWT, returning the verified payload.
+
+ Parameters
+ ----------
+ token: str
+ The JWT access token to decode and validate.
+
+ Returns
+ -------
+ dict[str, Any]
+ The decoded JWT claims if the token is valid.
+ """
signing_key = self._jwk_client.get_signing_key_from_jwt(token).key
return jwt.decode(
@@ -583,7 +683,19 @@ def decode_and_validate(self, token: str) -> dict[str, Any]:
)
def validate_input(self, jwt_token: str) -> bool:
- """Validate a JWT access token."""
+ """
+ Validate a JWT access token.
+
+ Parameters
+ ----------
+ jwt_token: str
+ The JWT access token to validate.
+
+ Returns
+ -------
+ bool
+ True if the token is valid, False otherwise.
+ """
try:
self.decode_and_validate(jwt_token)
return True
@@ -592,11 +704,34 @@ def validate_input(self, jwt_token: str) -> bool:
return False
def userinfo(self, jwt_token: str) -> dict[str, Any]:
- """Return the verified JWT claims. This does not call a remote userinfo endpoint."""
+ """
+ Validate and return the JWT claims.
+
+ Parameters
+ ----------
+ jwt_token: str
+ The JWT access token.
+
+ Returns
+ -------
+ dict[str, Any]
+ The decoded JWT claims if the token is valid.
+ """
return self.decode_and_validate(jwt_token)
def user_has_role(self, claims: dict[str, Any]) -> bool:
- """Return True if any configured allowed role is present in the JWT claims."""
+ """
+ Return `True` if a role is present in the JWT claims.
+
+ Parameters
+ ----------
+ claims: dict[str, Any]
+ The decoded JWT claims.
+
+ Returns
+ -------
+ bool
+ """
if not self.allowed_roles:
return True
@@ -619,6 +754,7 @@ def user_has_role(self, claims: dict[str, Any]) -> bool:
class KeycloakOIDCSecurity(OIDCSecurity):
"""
Keycloak specific OIDC security, extends the generic `OIDCSecurity` class.
+
Only adds convenience around constructing the issuer URL. See `OIDCSecurity` for actual details and usage.
Keycloak issuer is typically: {server_url}/realms/{realm}
"""
@@ -644,6 +780,7 @@ def __init__(
class OIDCSecurity(Security):
"""
Placeholder for OIDCSecurity when PyJWT is not installed.
+
If you see this doc, you need to install the `PyJWT` library to use OIDC security.
"""
@@ -662,6 +799,7 @@ def __init__(
class KeycloakOIDCSecurity(Security):
"""
Placeholder for KeycloakOIDCSecurity.
+
If you see this doc, you need to install the `PyJWT` library to use OIDC security.
"""
diff --git a/hololinked/server/server.py b/hololinked/server/server.py
index 14957938..8715f4dd 100644
--- a/hololinked/server/server.py
+++ b/hololinked/server/server.py
@@ -1,3 +1,5 @@
+"""Base protocol server to be subclassed by all protocols."""
+
import asyncio
import logging
import threading
@@ -5,6 +7,7 @@
from io import StringIO
from types import SimpleNamespace # noqa: F401
+from typing import Sequence
import structlog
@@ -76,12 +79,15 @@ def add_things(self, *things: Thing) -> None:
self.add_thing(thing)
def add_property(self, property: PropertyAffordance | Property) -> None:
+ """Overload property endpoint."""
raise NotImplementedError("Not implemented for this protocol")
def add_action(self, action: ActionAffordance | Action) -> None:
+ """Overload action endpoint."""
raise NotImplementedError("Not implemented for this protocol")
def add_event(self, event: EventAffordance | Event) -> None:
+ """Overload event endpoint."""
raise NotImplementedError("Not implemented for this protocol")
async def _instantiate_broker(
@@ -122,7 +128,7 @@ async def _instantiate_broker(
self.zmq_client_pool.register(client, thing_id)
broker_thing.req_rep_client = self.zmq_client_pool
- self.config.thing_repository[thing_id] = broker_thing
+ self.config.thing_repository[thing_id] = broker_thing # type: ignore[unresolved-attribute]
except ConnectionError:
self.logger.warning(
@@ -133,10 +139,12 @@ async def _instantiate_broker(
self.logger.exception(ex)
async def setup(self) -> None:
+ """Setup the server."""
# This method should not block, just create side-effects
raise NotImplementedError("Not implemented for this protocol")
async def start(self) -> None:
+ """Start the server."""
# This method should not block, just create side-effects
# await self.setup() # call setup() here, this is only an example
raise NotImplementedError("Not implemented for this protocol")
@@ -144,7 +152,7 @@ async def start(self) -> None:
@forkable
def run(self, forked: bool = False, print_welcome_message: bool = True) -> None:
"""
- Run the server and serve your things
+ Run the server and serve your things.
Parameters
----------
@@ -158,13 +166,14 @@ def run(self, forked: bool = False, print_welcome_message: bool = True) -> None:
run(self, print_welcome_message=print_welcome_message)
def stop(self):
+ """Stop the server."""
raise NotImplementedError("Not implemented for this protocol")
@forkable
def run(*servers: BaseProtocolServer, forked: bool = False, print_welcome_message: bool = True) -> None:
"""
- run servers and serve your things
+ Run servers and serve your things.
Parameters
----------
@@ -174,6 +183,11 @@ def run(*servers: BaseProtocolServer, forked: bool = False, print_welcome_messag
whether to run in a forked thread
print_welcome_message: bool, default True
whether to print a welcome message on startup, like the ports and access points
+
+ Raises
+ ------
+ ValueError
+ If more than one ZMQServer or RPCServer instances are provided, since they are not designed to run in parallel.
"""
from . import ZMQServer
@@ -223,7 +237,7 @@ async def shutdown():
def stop():
- """shutdown all running servers started with run()"""
+ """Shutdown all running servers started with `run()`."""
if hasattr(run, "shutdown_event"):
run.shutdown_event.set()
return
@@ -234,6 +248,31 @@ def stop():
def parse_params(id: str, access_points: list[tuple[str, str | int | dict | list[str]]]) -> list[BaseProtocolServer]:
+ """
+ Utility function to parse parameters passed to servers.
+
+ Parameters
+ ----------
+ id: str
+ Unique identifier for the server, used for things like the RPCServer broker.
+ access_points: list of tuples (protocol, params)
+ List of tuples where the first element is the protocol name (e.g., "HTTP", "MQTT", "ZMQ") and
+ the second element is either a port number (int), a string (e.g., hostname for MQTT or access point for ZMQ),
+ a dict of parameters, or a list of strings (e.g., multiple access points for ZMQ).
+
+ Returns
+ -------
+ list[BaseProtocolServer]
+ A list of instantiated protocol server objects based on the provided parameters.
+
+ Raises
+ ------
+ TypeError
+ If access_points is not provided as a list of tuples.
+ ValueError
+ If the parameters for a protocol are not in the expected format (e.g., HTTP params not being an int or dict).
+ """
+ # TODO improve input argument names
from .http import HTTPServer
from .mqtt import MQTTPublisher
from .zmq import ZMQServer
@@ -249,7 +288,7 @@ def parse_params(id: str, access_points: list[tuple[str, str | int | dict | list
params = dict(port=params)
if not isinstance(params, dict):
raise ValueError("HTTP server parameters must be supplied as a dict or just the port as an integer.")
- http_server = HTTPServer(**params)
+ http_server = HTTPServer(**params) # type: ignore
servers.append(http_server)
elif protocol.upper() == "ZMQ":
if isinstance(params, int):
@@ -259,21 +298,21 @@ def parse_params(id: str, access_points: list[tuple[str, str | int | dict | list
elif isinstance(params, list):
params = dict(access_points=params)
if not isinstance(params.get("access_points", None), list):
- params["access_points"] = [params["access_points"]]
+ params["access_points"] = [params["access_points"]] # type: ignore
if not any(isinstance(ap, str) and ap.upper().startswith("INPROC") for ap in params["access_points"]):
params["access_points"].append("INPROC")
if len(params["access_points"]) == 1 and params["access_points"][0] == "INPROC":
- server = RPCServer(id=id, **params)
+ server = RPCServer(id=id, **params) # type: ignore
else:
- server = ZMQServer(id=id, **params)
+ server = ZMQServer(id=id, **params) # type: ignore
servers.append(server)
elif protocol.upper() == "MQTT":
if isinstance(params, str):
params = dict(hostname=params)
if not isinstance(params, dict):
raise ValueError("MQTT parameters must be supplied as a dictionary or the broker hostname as a string.")
- mqtt_publisher = MQTTPublisher(**params)
+ mqtt_publisher = MQTTPublisher(**params) # type: ignore
servers.append(mqtt_publisher)
else:
warnings.warn(f"Unsupported protocol: {protocol}", category=UserWarning)
@@ -281,8 +320,8 @@ def parse_params(id: str, access_points: list[tuple[str, str | int | dict | list
return servers
-def _print_welcome_message(servers: list[BaseProtocolServer]) -> None:
- """prints a welcome message to the console/log"""
+def _print_welcome_message(servers: Sequence[BaseProtocolServer]) -> None:
+ """Prints a welcome message to the console/log."""
from . import HTTPServer, MQTTPublisher
buffer = StringIO()
diff --git a/hololinked/server/zmq/__init__.py b/hololinked/server/zmq/__init__.py
index af0159e4..a61153f0 100644
--- a/hololinked/server/zmq/__init__.py
+++ b/hololinked/server/zmq/__init__.py
@@ -1 +1,3 @@
+"""ZMQ server with TCP, IPC and INPROC support."""
+
from .server import ZMQServer # noqa: F401
diff --git a/hololinked/server/zmq/server.py b/hololinked/server/zmq/server.py
index 3150a545..e6eb0caa 100644
--- a/hololinked/server/zmq/server.py
+++ b/hololinked/server/zmq/server.py
@@ -1,5 +1,6 @@
+"""ZMQ server with TCP, IPC and INPROC support."""
+
import structlog
-import zmq
import zmq.asyncio
from ...constants import ZMQ_TRANSPORTS
@@ -11,7 +12,11 @@
class ZMQServer(RPCServer, BaseProtocolServer):
- """Server to expose `Thing` over `ZeroMQ` protocol. Extends `RPCServer` to support `IPC` & `TCP`"""
+ """
+ Server to expose `Thing` over `ZeroMQ` protocol.
+
+ Extends `RPCServer` to support `IPC` & `TCP`.
+ """
def __init__(
self,
@@ -23,6 +28,8 @@ def __init__(
**kwargs,
) -> None:
"""
+ Initialize ZMQServer.
+
Parameters
----------
id: str
@@ -39,6 +46,11 @@ def __init__(
- `logger`: `structlog.stdlib.BoundLogger`, custom logger instance.
- `poll_timeout`: `int`, polling timeout in milliseconds.
+
+ Raises
+ ------
+ TypeError
+ If `access_points` is not a string or list of strings.
"""
self.ipc_server = self.tcp_server = None
self.ipc_event_publisher = self.tcp_event_publisher = self.inproc_events_proxy = None
@@ -106,10 +118,18 @@ def __init__(
)
def add_thing(self, thing: Thing) -> None:
- """Adds a thing to the list of things to serve"""
+ """
+ Adds a thing to the list of things to serve.
+
+ Parameters
+ ----------
+ thing: Thing
+ The `Thing` instance to be added to the server.
+ """
+ # Written in this way because of multiple inheritance. Otherwise, an explicit override is not necessary.
return RPCServer.add_thing(self, thing)
- def run_zmq_request_listener(self) -> None:
+ def run_zmq_request_listener(self) -> None: # noqa: D102
# doc in parent class
eventloop = get_current_async_loop()
if self.ipc_server is not None:
@@ -120,7 +140,7 @@ def run_zmq_request_listener(self) -> None:
eventloop.create_task(self.tunnel_events_from_inproc())
super().run_zmq_request_listener()
- async def tunnel_events_from_inproc(self) -> None:
+ async def tunnel_events_from_inproc(self) -> None: # noqa: D102
if not self.inproc_events_proxy:
return
self.logger.info("starting to tunnel events from inproc to external publishers")
@@ -142,7 +162,7 @@ async def tunnel_events_from_inproc(self) -> None:
self.logger.error(f"error in tunneling events from inproc: {e}")
self.logger.info("stopped tunneling events from inproc")
- def stop(self) -> None:
+ def stop(self) -> None: # noqa: D102
# doc in parent class
if self.ipc_server is not None:
self.ipc_server.stop_polling()
@@ -152,7 +172,7 @@ def stop(self) -> None:
self.inproc_events_proxy.stop()
super().stop()
- def exit(self) -> None:
+ def exit(self) -> None: # noqa: D102
# doc in parent class
try:
self.stop()
@@ -192,8 +212,8 @@ def __str__(self):
paths += "\n)"
return paths
- async def start(self) -> None:
+ async def start(self) -> None: # noqa: D102
raise NotImplementedError("Use the blocking run() method to start the ZMQServer.")
- async def setup(self) -> None:
+ async def setup(self) -> None: # noqa: D102
raise NotImplementedError("Use the blocking run() method to start the ZMQServer, no need to setup separately.")
diff --git a/hololinked/storage/database.py b/hololinked/storage/database.py
index 6b07df61..d0ec4726 100644
--- a/hololinked/storage/database.py
+++ b/hololinked/storage/database.py
@@ -586,7 +586,7 @@ def load_conf(cls, config_file: str | None) -> dict[str, Any]:
return {}
elif config_file.endswith(".json"):
with open(config_file, "r") as file:
- return JSONSerializer.load(file) # type: ignore[invalid-return-type]
+ return JSONSerializer.load(file)
else:
raise ValueError(f"config files of extension - ['json'] expected, given file name {config_file}")
diff --git a/pyproject.toml b/pyproject.toml
index 0197ad53..99934af0 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -114,8 +114,23 @@ filterwarnings = [
[tool.ruff]
exclude = [
- "hololinked/core/properties.py",
- "hololinked/param"
+ "hololinked/utils.py",
+ "hololinked/constants.py",
+ "hololinked/logger.py",
+ "hololinked/config.py",
+ "hololinked/core",
+ "hololinked/param",
+ "hololinked/server",
+ "hololinked/td"
+]
+
+[tool.ty.src]
+exclude = [
+ "hololinked/*.py",
+ "hololinked/core",
+ "hololinked/param",
+ "hololinked/server",
+ "hololinked/td"
]
[tool.ruff.lint]
From 68b03ea5840ef701424a0237cf38f151d3496760 Mon Sep 17 00:00:00 2001
From: Vignesh Venkatasubramanian Vaidyanathan
<62492557+VigneshVSV@users.noreply.github.com>
Date: Thu, 14 May 2026 08:47:31 +0200
Subject: [PATCH 12/15] add ty job to ci cd
---
.github/workflows/ci-pipeline.yml | 18 +++++++++++++++++-
1 file changed, 17 insertions(+), 1 deletion(-)
diff --git a/.github/workflows/ci-pipeline.yml b/.github/workflows/ci-pipeline.yml
index ff48f6ea..1ddd9f83 100644
--- a/.github/workflows/ci-pipeline.yml
+++ b/.github/workflows/ci-pipeline.yml
@@ -11,9 +11,14 @@ on:
jobs:
codestyle:
- name: ruff codestyle check/linting
+ name: codestyle check/linting (${{ matrix.tool }})
runs-on: ubuntu-latest
+ strategy:
+ fail-fast: false
+ matrix:
+ tool: [ruff, ty]
+
steps:
- name: checkout code
uses: actions/checkout@v4
@@ -24,14 +29,25 @@ jobs:
python-version: 3.11
- name: install ruff
+ if: matrix.tool == 'ruff'
run: pip install ruff
- name: run ruff linter src directory
+ if: matrix.tool == 'ruff'
run: ruff check hololinked
- name: run ruff linter tests directory
+ if: matrix.tool == 'ruff'
run: ruff check tests/*.py tests/things/*.py tests/helper-scripts/*.py
+ - name: install ty
+ if: matrix.tool == 'ty'
+ run: pip install ty
+
+ - name: run ty type checker
+ if: matrix.tool == 'ty'
+ run: ty check hololinked
+
scan:
name: security scan (${{ matrix.tool }})
runs-on: ubuntu-latest
From b3bb6eef5ff973ac5530f844e0ad768ca5a349eb Mon Sep 17 00:00:00 2001
From: Vignesh Venkatasubramanian Vaidyanathan
<62492557+VigneshVSV@users.noreply.github.com>
Date: Thu, 14 May 2026 08:49:03 +0200
Subject: [PATCH 13/15] use config file while checking
---
.github/workflows/ci-pipeline.yml | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/.github/workflows/ci-pipeline.yml b/.github/workflows/ci-pipeline.yml
index 1ddd9f83..a0b5aa71 100644
--- a/.github/workflows/ci-pipeline.yml
+++ b/.github/workflows/ci-pipeline.yml
@@ -34,11 +34,11 @@ jobs:
- name: run ruff linter src directory
if: matrix.tool == 'ruff'
- run: ruff check hololinked
+ run: ruff check --config pyproject.toml hololinked
- name: run ruff linter tests directory
if: matrix.tool == 'ruff'
- run: ruff check tests/*.py tests/things/*.py tests/helper-scripts/*.py
+ run: ruff check --config pyproject.toml tests/*.py tests/things/*.py tests/helper-scripts/*.py
- name: install ty
if: matrix.tool == 'ty'
@@ -46,7 +46,7 @@ jobs:
- name: run ty type checker
if: matrix.tool == 'ty'
- run: ty check hololinked
+ run: ty check --config pyproject.toml hololinked
scan:
name: security scan (${{ matrix.tool }})
From 9c491c8d10f1a11def29dc84dd5ac60933ed578a Mon Sep 17 00:00:00 2001
From: Vignesh Venkatasubramanian Vaidyanathan
<62492557+VigneshVSV@users.noreply.github.com>
Date: Thu, 14 May 2026 08:54:30 +0200
Subject: [PATCH 14/15] pin ruff and ty version
---
.github/workflows/ci-pipeline.yml | 10 +++++-----
1 file changed, 5 insertions(+), 5 deletions(-)
diff --git a/.github/workflows/ci-pipeline.yml b/.github/workflows/ci-pipeline.yml
index a0b5aa71..1b1fc173 100644
--- a/.github/workflows/ci-pipeline.yml
+++ b/.github/workflows/ci-pipeline.yml
@@ -30,23 +30,23 @@ jobs:
- name: install ruff
if: matrix.tool == 'ruff'
- run: pip install ruff
+ run: pip install ruff==0.14.2
- name: run ruff linter src directory
if: matrix.tool == 'ruff'
- run: ruff check --config pyproject.toml hololinked
+ run: ruff check hololinked
- name: run ruff linter tests directory
if: matrix.tool == 'ruff'
- run: ruff check --config pyproject.toml tests/*.py tests/things/*.py tests/helper-scripts/*.py
+ run: ruff check tests/*.py tests/things/*.py tests/helper-scripts/*.py
- name: install ty
if: matrix.tool == 'ty'
- run: pip install ty
+ run: pip install ty==0.0.24
- name: run ty type checker
if: matrix.tool == 'ty'
- run: ty check --config pyproject.toml hololinked
+ run: ty check hololinked
scan:
name: security scan (${{ matrix.tool }})
From 9a7dbd6742c1b95057f55e7d9aaab7813dc334e6 Mon Sep 17 00:00:00 2001
From: Vignesh Venkatasubramanian Vaidyanathan
<62492557+VigneshVSV@users.noreply.github.com>
Date: Thu, 14 May 2026 08:57:25 +0200
Subject: [PATCH 15/15] allow failure ruff and ty
---
.github/workflows/ci-pipeline.yml | 1 +
1 file changed, 1 insertion(+)
diff --git a/.github/workflows/ci-pipeline.yml b/.github/workflows/ci-pipeline.yml
index 1b1fc173..52a96e18 100644
--- a/.github/workflows/ci-pipeline.yml
+++ b/.github/workflows/ci-pipeline.yml
@@ -14,6 +14,7 @@ jobs:
name: codestyle check/linting (${{ matrix.tool }})
runs-on: ubuntu-latest
+ continue-on-error: true
strategy:
fail-fast: false
matrix: