diff --git a/.github/workflows/ci-pipeline.yml b/.github/workflows/ci-pipeline.yml index 4c198ae4..a3ccf04b 100644 --- a/.github/workflows/ci-pipeline.yml +++ b/.github/workflows/ci-pipeline.yml @@ -10,27 +10,82 @@ on: - main jobs: + setup-venv: + name: set up shared virtual environment + runs-on: ubuntu-latest + + steps: + - name: checkout code + uses: actions/checkout@v4 + + - name: set up python 3.13 + uses: actions/setup-python@v5 + with: + python-version: "3.13" + + - name: install uv + run: curl -LsSf https://astral.sh/uv/install.sh | sh + + - name: cache virtual environment + uses: actions/cache@v4 + with: + path: .venv + key: ${{ runner.os }}-shared-venv-${{ github.run_id }} + + - name: sync virtual environment + run: | + uv sync --group all + codestyle: name: ruff codestyle check/linting runs-on: ubuntu-latest + needs: setup-venv + + strategy: + fail-fast: false + matrix: + tool: [ruff, ty, ruff-extensive] steps: - name: checkout code uses: actions/checkout@v4 - - name: set up python 3.11 - uses: actions/setup-python@v3 + - name: set up python 3.13 + uses: actions/setup-python@v5 with: - python-version: 3.11 + python-version: "3.13" - - name: install ruff - run: pip install ruff + - name: restore virtual environment + uses: actions/cache@v4 + with: + path: .venv + key: ${{ runner.os }}-shared-venv-${{ github.run_id }} + restore-keys: | + ${{ runner.os }}-shared-venv- - name: run ruff linter src directory - run: ruff check hololinked + if: matrix.tool == 'ruff' + run: | + source .venv/bin/activate + ruff check --config pyproject.toml hololinked - name: run ruff linter tests directory - run: ruff check tests/*.py tests/things/*.py tests/helper-scripts/*.py + if: matrix.tool == 'ruff' + run: | + source .venv/bin/activate + ruff check --config pyproject.toml tests/*.py tests/things/*.py tests/helper-scripts/*.py + + - name: run ruff linter src directory + if: matrix.tool == 'ruff-extensive' + run: | + source .venv/bin/activate + ruff check --config ruff.toml hololinked/client + + - name: run ty type checker + if: matrix.tool == 'ty' + run: | + source .venv/bin/activate + ty check hololinked/client scan: name: security scan (${{ matrix.tool }}) @@ -49,25 +104,32 @@ jobs: fetch-depth: 0 # ---------------- Bandit branch ---------------- - - name: set up python 3.11 + - name: set up python 3.13 if: matrix.tool == 'bandit' uses: actions/setup-python@v5 with: - python-version: "3.11" + python-version: "3.13" - - name: install bandit + - name: restore virtual environment if: matrix.tool == 'bandit' - run: pip install bandit + uses: actions/cache@v4 + with: + path: .venv + key: ${{ runner.os }}-shared-venv-${{ github.run_id }} + restore-keys: | + ${{ runner.os }}-shared-venv- - name: run bandit scan if: matrix.tool == 'bandit' run: | + source .venv/bin/activate bandit -c pyproject.toml -r hololinked/ -b .bandit-baseline.json # this is the step that will fail the job if new issues are found - name: generate JSON report if: matrix.tool == 'bandit' run: | + source .venv/bin/activate echo "Rerunning to generate bandit report in JSON format..." bandit -c pyproject.toml -r hololinked/ -f json -b .bandit-baseline.json -o bandit-report.json @@ -81,6 +143,7 @@ jobs: - name: display existing issues, which have already been accounted if: matrix.tool == 'bandit' run: | + source .venv/bin/activate echo "Rerunning to display existing issues which are included in the baseline..." bandit -c pyproject.toml -r hololinked/ || true 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 60c943ad..4d5989ab 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,26 +25,31 @@ 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 collections.abc import Callable from dataclasses import dataclass -from typing import Any, Callable +from typing import TYPE_CHECKING, Any 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 + + 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, @@ -49,11 +58,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 +77,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 +106,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 +126,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 +141,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 ---------- @@ -144,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`. @@ -162,21 +177,29 @@ 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 + + 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. + Parameters ---------- resource: PropertyAffordance @@ -194,7 +217,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 +248,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 +261,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 +274,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 +288,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 +307,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 +320,7 @@ def oneway_set(self, value: Any) -> None: def observe(self, *callbacks: Callable) -> None: """ - Observe property value changes + Observe property value changes. Parameters ---------- @@ -297,11 +331,11 @@ 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") - 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`. @@ -320,21 +354,29 @@ 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 + + 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, + resource: EventAffordance | PropertyAffordance, logger: structlog.stdlib.BoundLogger, owner_inst: Any, ) -> None: """ + Initialize a consumed thing event. + 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 @@ -345,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 = [] @@ -358,7 +400,7 @@ def subscribe( # create_new_connection: bool = False, ) -> None: """ - subscribe to the event + Subscribe to the event. Parameters ---------- @@ -371,32 +413,48 @@ 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) - 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): - """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 +470,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 +496,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 +505,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: @@ -451,12 +514,11 @@ 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(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: """ - 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 +527,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: @@ -480,87 +542,58 @@ 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(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: """ - 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 - ---------- event: str - event name, defaults to "message" + """event name, defaults to message""" data: Any - event data, defaults to empty string - id: Optional[str] - event id, defaults to None - retry: Optional[int] - reconnection time in milliseconds, defaults to None, currently unused. - """ + """event data, defaults to empty string""" + id: str | int | None + """unique event id, defaults to None""" + retry: int | None + """reconnection time in milliseconds, defaults to None, currently unused.""" - __slots__ = ("event", "data", "id", "retry") + __slots__ = ("data", "event", "id", "retry") - def __init__(self): + def __init__(self) -> None: self.clear() - def clear(self): - """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 clear(self) -> None: + """Reset to default/empty values.""" + self.event = "message" + self.data = "" + self.id = None + self.retry = 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 cb996d8a..f744ce4c 100644 --- a/hololinked/client/exceptions.py +++ b/hololinked/client/exceptions.py @@ -1,10 +1,57 @@ +"""Client side exceptions. Not fully formalized.""" + +import builtins + +from typing import Any + + 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""" + """Raise and catch to exit a loop from within another function or method.""" + + +def raise_local_exception(error_message: dict[str, Any] | str) -> None: + """ + 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` - pass + 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: + 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 9c4f922d..de767560 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 @@ -11,47 +13,52 @@ from paho.mqtt.client import CallbackAPIVersion, MQTTMessage, MQTTProtocolVersion from paho.mqtt.client import Client as PahoMQTTClient -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.client.abstractions import ( + ConsumedThingAction, + ConsumedThingEvent, + ConsumedThingProperty, +) +from hololinked.client.proxy import ObjectProxy +from hololinked.client.security import ( + BasicSecurity, + OAuth2Security, + OAuthDirectAccessGrant, +) +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 .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, -) +from hololinked.utils import uuid_hex 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 +67,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 +121,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,8 +133,17 @@ def zmq( Returns ------- ObjectProxy - An ObjectProxy instance representing the remote Thing + 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 @@ -106,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 + 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( @@ -136,7 +193,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 +203,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 +223,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 +252,31 @@ def zmq( ) return object_proxy - @classmethod - def http(self, url: str, **kwargs) -> ObjectProxy: + @staticmethod + def http(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 +289,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,8 +312,13 @@ 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. """ + from hololinked.client.http.consumed_interactions import ( + HTTPAction, + HTTPEvent, + HTTPProperty, + ) # config skip_interaction_affordances = kwargs.get("skip_interaction_affordances", []) @@ -309,7 +395,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 +405,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 +416,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 +428,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,48 +440,63 @@ 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. """ + 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", @@ -417,7 +518,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 @@ -427,7 +528,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: @@ -436,8 +537,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 +565,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 +574,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,45 +585,34 @@ 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""" - 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), - ) + @staticmethod + def add_action(client, action: ConsumedThingAction) -> None: + """Add action to the client instance.""" + 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) - @classmethod - def add_property(self, client, property: ConsumedThingProperty) -> None: - """add property to 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), + @staticmethod + def add_property(client, property: ConsumedThingProperty) -> None: + """Add property to the client instance.""" + 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) - @classmethod - def add_event(cls, client, event: ConsumedThingEvent) -> None: - """add event to 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), - ) + @staticmethod + def add_event(client, event: ConsumedThingEvent) -> None: + """Add event to the client instance.""" + 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/__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 426aa769..04548542 100644 --- a/hololinked/client/http/consumed_interactions.py +++ b/hololinked/client/http/consumed_interactions.py @@ -1,66 +1,81 @@ -""" -Classes that contain the client logic for the HTTP protocol. -""" +"""Concrete implementation of HTTP based consumed property, action or event.""" import asyncio 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 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, ) 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, + 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 self.owner_inst: ObjectProxy @@ -71,9 +86,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 ---------- @@ -88,6 +104,11 @@ def get_body_from_response( ------- 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 @@ -106,25 +127,34 @@ 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 {} 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 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 +177,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 +187,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 ------- @@ -172,21 +202,23 @@ def read_reply(self, form: Form, message_id: str, timeout: float = None) -> Any: 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 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 +227,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__( @@ -215,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}") @@ -227,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}") @@ -239,8 +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): - """Invoke the action without waiting for a response.""" + 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}") @@ -253,10 +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: - """Invoke the action in non-blocking mode.""" + 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}") @@ -273,28 +302,30 @@ 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 def __init__( self, - resource: ActionAffordance, - sync_client: httpx.Client = None, - async_client: httpx.AsyncClient = None, + resource: PropertyAffordance, + 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 @@ -322,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}") @@ -332,8 +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: - """Synchronous set of the property value.""" + 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) @@ -345,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}") @@ -355,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) @@ -367,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)) @@ -382,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}") @@ -398,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}") @@ -419,28 +446,30 @@ 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 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 +498,13 @@ def __init__( execution_timeout=execution_timeout, ) - def listen(self, form: Form, callbacks: list[Callable], concurrent: bool = False, 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() @@ -500,20 +535,20 @@ def listen(self, form: Form, callbacks: list[Callable], concurrent: bool = False 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], - 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 +560,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 @@ -542,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 @@ -550,14 +585,27 @@ 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) + done, _ = await asyncio.wait({next_line, stopper}, return_when=asyncio.FIRST_COMPLETED) if stopper in done: next_line.cancel() @@ -578,7 +626,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,13 +653,21 @@ 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 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" @@ -618,4 +688,8 @@ def unsubscribe(self) -> None: return super().unsubscribe() -__all__ = [HTTPProperty.__name__, HTTPAction.__name__, HTTPEvent.__name__] +__all__ = [ + "HTTPAction", + "HTTPEvent", + "HTTPProperty", +] 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..2d2ca79c 100644 --- a/hololinked/client/mqtt/consumed_interactions.py +++ b/hololinked/client/mqtt/consumed_interactions.py @@ -1,4 +1,7 @@ -from typing import Any, Callable +"""Concrete implementation of MQTT based consumed property or event.""" + +from collections.abc import Callable +from typing import Any import aiomqtt import structlog @@ -6,16 +9,32 @@ 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 + __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, @@ -26,6 +45,8 @@ def __init__( owner_inst: Any, ) -> None: """ + Initialize the MQTT consumer. + Parameters ---------- sync_client: PahoMQTTClient @@ -47,7 +68,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}" @@ -65,20 +92,25 @@ 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(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.error(f"Error handling MQTT message for topic {topic} - {ex}", exc_info=True) 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__() @@ -102,18 +134,17 @@ async def async_listen(self, form: Form, callbacks: list[Callable], concurrent: 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(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.error(f"Error handling MQTT message for topic {topic} - {ex}", exc_info=True) 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/proxy.py b/hololinked/client/proxy.py index 59321cef..f020c192 100644 --- a/hololinked/client/proxy.py +++ b/hololinked/client/proxy.py @@ -1,17 +1,36 @@ -from typing import Any, Callable +"""Implementation of procedural/scripting client for Thing.""" + +from collections.abc import Callable +from typing import Any 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: """ - 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 +50,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 @@ -58,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 @@ -118,7 +145,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 +158,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 ------- @@ -148,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) @@ -161,55 +188,61 @@ 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): + if not action: raise AttributeError(f"No remote action named {name}") return await action.async_call(*args, **kwargs) 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): + if not prop: raise AttributeError(f"No property named {name}") if noblock: return prop.noblock_get() @@ -218,29 +251,29 @@ 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): + if not prop: raise AttributeError(f"No property named {name}") if oneway: prop.oneway_set(value) @@ -251,72 +284,80 @@ 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): + if not prop: raise AttributeError(f"No property named {name}") return await prop.async_get() 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): + if not prop: raise AttributeError(f"No property named {name}") await prop.async_set(value) 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 +374,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 +388,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 +410,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 +440,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,29 +473,31 @@ 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): + if not event: raise AttributeError(f"No events for property {name} or property not found") self.subscribe_event( name=f"{name}_change_event", @@ -450,20 +509,20 @@ 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): + if not event: raise AttributeError(f"No events for property {name} or property not found") event.unsubscribe() @@ -477,31 +536,32 @@ 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): + 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: @@ -516,26 +576,26 @@ 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): + if not event: raise AttributeError(f"No event named {name}") event.unsubscribe() 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 +603,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,28 +621,28 @@ 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 -__all__ = [ObjectProxy.__name__] +__all__ = ["ObjectProxy"] diff --git a/hololinked/client/security.py b/hololinked/client/security.py index a23121d2..9261c5e3 100644 --- a/hololinked/client/security.py +++ b/hololinked/client/security.py @@ -1,6 +1,9 @@ +"""Implementation of security schemes for authentication and authorization to be used by clients.""" + import base64 import threading import time +import warnings import httpx @@ -10,15 +13,34 @@ class BasicSecurity(BaseModel): """ Basic Security Scheme with username and password. - The credentials are added into the `Authorization` header. + + 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: """ + Initialize BasicSecurity with username and password. + Parameters ---------- username: str @@ -36,43 +58,102 @@ 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 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`. + + ```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: + """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. + + 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. + + ```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`. """ 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 @@ -83,12 +164,47 @@ 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: + 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. + + 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) revocation_endpoint = kwargs.get("revocation_endpoint", None) @@ -114,85 +230,100 @@ def __init__( class OAuth2Security: """ - OAuth2 Security Scheme, Currently only supports 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, oidc_settings: OAuthDirectAccessGrant, - req_rep_sync_client: httpx.Client, - req_rep_async_client: httpx.AsyncClient, refresh_interval_fraction: 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: + 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` + 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 @property def http_header(self) -> str: + """Value for the Authorization header, containing the access token.""" 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() + """Login with username and password and obtain tokens.""" + 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( + 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""" - 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", - ) - 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, + """Logout and invalidate tokens.""" + if not self.tokens or not self.oidc_settings.revocation_endpoint: + return + 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( + self.oidc_settings.revocation_endpoint, data=body, headers={"Content-Type": "application/x-www-form-urlencoded"}, ) @@ -201,32 +332,56 @@ def logout(self) -> None: self._refresh = False def refresh_tokens(self) -> None: - """refresh tokens, even forcibly by relogin if necessary""" - try: - self._refresh_lock.acquire() - body = dict( - grant_type="refresh_token", - client_id=self._oidc_settings.client_id, - refresh_token=self.tokens.refresh_token, + """ + 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: + warnings.warn( + "OIDC refresh token not available, cannot refresh tokens." + + "You need to login again to obtain new tokens.", + UserWarning, ) - 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, + return + try: + 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( + 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)) + """Background thread to refresh tokens periodically.""" + 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/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 b5acfe64..c4021c90 100644 --- a/hololinked/client/zmq/consumed_interactions.py +++ b/hololinked/client/zmq/consumed_interactions.py @@ -1,20 +1,24 @@ +"""Concrete implementation of ZMQ based consumed property, action or event.""" + import asyncio import threading import traceback import uuid import warnings -from typing import Any, Callable +from collections.abc import Callable +from typing import Any 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 ( @@ -45,33 +49,35 @@ 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__( 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: @@ -85,12 +91,16 @@ 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 + + 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 +108,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,11 +132,34 @@ 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 | 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 + + Returns + ------- + Any + reply of the action call + + Raises + ------ + RuntimeError + if the message_id does not belong to this property or action + ReplyNotArrivedError + if the reply did not arrive within the timeout + + """ 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) @@ -126,7 +169,7 @@ def read_reply(self, message_id: str, timeout: int = None) -> Any: 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 +183,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,34 +214,28 @@ 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, 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 - 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, @@ -199,20 +245,18 @@ async def async_call(self, *args, **kwargs) -> Any: 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 - 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, @@ -222,19 +266,17 @@ def oneway(self, *args, **kwargs) -> None: 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, ) - 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, @@ -244,17 +286,17 @@ def noblock(self, *args, **kwargs) -> str: 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.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 +310,8 @@ def __init__( **kwargs, ) -> None: """ + Initialize a ZMQProperty instance. + Parameters ---------- resource: PropertyAffordance @@ -280,6 +324,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 +342,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, @@ -300,32 +352,30 @@ def set(self, value: Any) -> None: 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 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, 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 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, @@ -335,32 +385,30 @@ async def async_set(self, value: Any) -> None: 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 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, 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 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, @@ -370,28 +418,28 @@ def oneway_set(self, value: Any) -> None: 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: + 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, 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.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, @@ -401,34 +449,50 @@ def noblock_set(self, value: Any) -> None: 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.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, - resource: EventAffordance, + resource: EventAffordance | PropertyAffordance, logger: structlog.stdlib.BoundLogger, 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}", @@ -452,16 +516,22 @@ def listen(self, form: Form, callbacks: list[Callable], concurrent: bool, deseri 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, ) - 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 +539,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 +555,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 +563,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 +616,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 +657,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..81e420e0 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, - ) -> bytes: + server_execution_context: ServerExecutionContext | dict = default_server_execution_context, + thing_execution_context: ThingExecutionContext | dict = default_thing_execution_context, + ) -> str: """ - send request message to server. + Send request message to server. Parameters ---------- @@ -1104,7 +1103,7 @@ def send_request( ) return request_message.id - def recv_response(self, message_id: bytes) -> ResponseMessage: + def recv_response(self, message_id: str) -> ResponseMessage: """ Receives response from server. Messages are identified by message id, and out of order messages are sent to a cache which may be popped later. This method blocks until the expected message is received or `stop_polling()` @@ -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)}" diff --git a/pyproject.toml b/pyproject.toml index 6edac2f0..094c333a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -72,6 +72,7 @@ dev = [ "pip>=25.2", "ruff>=0.12.10", "pre-commit>=4.5.0", + "ty>=0.0.24", ] test = [ "requests==2.32.3", @@ -85,7 +86,12 @@ test = [ ] scanning = [ "bandit>=1.9.1", -] +] +all = [ + {include-group = "dev"}, + {include-group = "test"}, + {include-group = "scanning"}, +] [tool.pytest.ini_options] minversion = "8.0" diff --git a/ruff.toml b/ruff.toml new file mode 100644 index 00000000..6082be44 --- /dev/null +++ b/ruff.toml @@ -0,0 +1,27 @@ +exclude = [ + "hololinked/core/properties.py", + "hololinked/param" +] + +[lint] +preview = true +extend-select = [ + "I", # isort + "D", # pydocstyle + "DOC" +] +ignore = [ + "G201", # logging-exc-info: do not require exc_info in logger.exception calls + "D401", # imperative mode for summary line + "D105", # magic methods dont need a docstring +] + +[lint.isort] +lines-between-types = 1 +lines-after-imports = 2 + +[lint.per-file-ignores] +"hololinked/*.py" = ["DOC502"] + +[lint.pydocstyle] +convention = "numpy" diff --git a/tests/test_99_global_config.py b/tests/test_99_global_config.py index 2526b1ed..41693b82 100644 --- a/tests/test_99_global_config.py +++ b/tests/test_99_global_config.py @@ -99,6 +99,8 @@ def test_05_temp_data_folders(): assert os.path.exists(folder) assert os.path.isdir(folder) + if os.path.exists(os.path.join(global_config.TEMP_DIR_SECRETS, "apikeys.json")): + raise pytest.skip("apikeys.json already exists, skipping test to avoid overwriting existing secrets.") assert not os.path.exists(os.path.join(global_config.TEMP_DIR_SECRETS, "apikeys.json")) security = APIKeySecurity(name="test-api-key-security") security.create(print_value=False) diff --git a/uv.lock b/uv.lock index 29f35c72..fbde7c1b 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" }, @@ -754,6 +754,24 @@ dependencies = [ ] [package.dev-dependencies] +all = [ + { name = "bandit" }, + { name = "faker" }, + { name = "ipython" }, + { name = "jupyter" }, + { name = "numpy" }, + { name = "pandas" }, + { name = "pip" }, + { name = "pre-commit" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-cov" }, + { name = "pytest-order" }, + { name = "requests" }, + { name = "ruff" }, + { name = "testcontainers" }, + { name = "ty" }, +] dev = [ { name = "ipython" }, { name = "jupyter" }, @@ -762,6 +780,7 @@ dev = [ { name = "pip" }, { name = "pre-commit" }, { name = "ruff" }, + { name = "ty" }, ] scanning = [ { name = "bandit" }, @@ -802,6 +821,24 @@ requires-dist = [ ] [package.metadata.requires-dev] +all = [ + { name = "bandit", specifier = ">=1.9.1" }, + { name = "faker", specifier = "==37.5.0" }, + { name = "ipython", specifier = "==8.12.3" }, + { name = "jupyter", specifier = ">=1.1.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 = "pytest", specifier = ">=8.0.0" }, + { name = "pytest-asyncio", specifier = ">=1.3.0" }, + { name = "pytest-cov", specifier = ">=4.0.0" }, + { name = "pytest-order", specifier = ">=1.0.0" }, + { name = "requests", specifier = "==2.32.3" }, + { name = "ruff", specifier = ">=0.12.10" }, + { name = "testcontainers", specifier = ">=4.14.1" }, + { name = "ty", specifier = ">=0.0.24" }, +] dev = [ { name = "ipython", specifier = "==8.12.3" }, { name = "jupyter", specifier = ">=1.1.1" }, @@ -810,6 +847,7 @@ dev = [ { 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 = [ @@ -2743,6 +2781,31 @@ 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.35" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4e/53/440e7b1212c4b0abbd4adb7aed93f4971aa1f8dca386ac5515930afa9172/ty-0.0.35.tar.gz", hash = "sha256:8375c240ab38138a19db07996c9808fb7a92047c1492e1ce587c2ef5112ad3a9", size = 5629237 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d4/84/19662ee881675815b7fafff940a365be1985730465afd9b75cb2edd5f8b3/ty-0.0.35-py3-none-linux_armv6l.whl", hash = "sha256:85ae1e59b9fb0b40e9d84fe61b29653c5f2f5e78b487ece371a7a38c20c781cf", size = 11198741 }, + { url = "https://files.pythonhosted.org/packages/62/df/7e5b6f83d85b4d2e5b72b5dceb388f440acc10679417bd46f829b9200fab/ty-0.0.35-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:709dbb7af4fcadb1196863c00b8791bbbbcc9dacbe15a0ff17f0af82b35d415b", size = 10948304 }, + { url = "https://files.pythonhosted.org/packages/59/94/72d7263aca055cde427f0ebcf08d6a74e5a5fee1d1e7fdd553696089cecb/ty-0.0.35-py3-none-macosx_11_0_arm64.whl", hash = "sha256:2cb0877419ab0c8708b6925cb0c2800b263842bd3c425113f200538772f3a0cc", size = 10407413 }, + { url = "https://files.pythonhosted.org/packages/b6/23/fda6fae8a81ce0cb5f24cdfe63260e110c7af8844e31fa07d1e6e8ef0232/ty-0.0.35-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e7afbcfc61904b7e82e7fe1a1db832a40d8f01e69dee1775f6594e552980536c", size = 10932614 }, + { url = "https://files.pythonhosted.org/packages/72/3d/b98d8d4aa1a5ed6daaf15864e838f605ca7b1e8b93b7e17b96ed4bc4dfed/ty-0.0.35-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b61498cc3e4178031c079951257fbdb209a891b4feb10ad6c40f615a51846f41", size = 10962982 }, + { url = "https://files.pythonhosted.org/packages/18/c4/2881aad71bf6fb2f8df17fc8e4bc89e904e54490a3ee747b5ef73f98ac85/ty-0.0.35-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:573b1eacda349fc8dba0d767b41631c3a6f66412363127c5bf2b1b40a1d898d2", size = 11476274 }, + { url = "https://files.pythonhosted.org/packages/34/0f/7717650adaeaddd23eea70470e2c26d3f0b9b18fdc7f26ec9552d6001f17/ty-0.0.35-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a7209746158d6393c1040aa64b3ca29622e212ea7d8bae22ba50dbcbb4f96f0a", size = 12012027 }, + { url = "https://files.pythonhosted.org/packages/22/c9/1a16cb4aab6f4707d8f550772e91abc26d1c8870f19b5e2453ad10bb8209/ty-0.0.35-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4466a1470aa4418d49a9aa45d9da7de42033addd0a2837c5b2b0eb71d3c2bcd3", size = 11648894 }, + { url = "https://files.pythonhosted.org/packages/18/a1/a977c0e07e9f88db9c67f90c6342a4dc4422c8091fa07bf26521870687c5/ty-0.0.35-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eb44bb742d52c309dcaa6598bcf4d82eb4bf1241b9e4940461e522e30093fe8b", size = 11560482 }, + { url = "https://files.pythonhosted.org/packages/d6/c1/a5fb11227d5cc4ac3f29a115d8c8bc817578e8ef6907d1e4c914ddbf45ee/ty-0.0.35-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:34b219250736c989b2670a03782c61315f523f3a2be37f1f90b1207e2212c188", size = 11718495 }, + { url = "https://files.pythonhosted.org/packages/3c/cb/e92e4317388b6d1fd821a46941b448a8a1ff0bf13e22147c5167d8fa1b00/ty-0.0.35-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:88e2ac497decc0940ef1a07571dee8a746112a93a09cdc7f8bca0099752e2e05", size = 10900815 }, + { url = "https://files.pythonhosted.org/packages/e9/4f/03bd87388a92567f262f35ac64e10d2be047d258f2dfcf1405f500fa2b90/ty-0.0.35-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:02cae51b53e6ec17d5d827ff1a3a76fd119705b56a92156e04399eda6e911596", size = 10998051 }, + { url = "https://files.pythonhosted.org/packages/b4/60/6edbc375ee6073973200096168f644e1081e5e55a7d42596826465b275de/ty-0.0.35-py3-none-musllinux_1_2_i686.whl", hash = "sha256:11871d730c9400d899ac0b9f3d660ed2e7e433377c8725549f8250a36a7f2620", size = 11148910 }, + { url = "https://files.pythonhosted.org/packages/4d/b1/a845d2066ed521c477450f436d4bd353d107e7c02dd6536a485944aaf892/ty-0.0.35-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:1ad0a2f0530d0933dcc99ad36ac556c63e384ea72ab9a18d23ad2e2c9fd61c73", size = 11671005 }, + { url = "https://files.pythonhosted.org/packages/73/81/1d5912a54fb66b2f95ac828ae61d422ef5afeae1263e4d231e40796c229f/ty-0.0.35-py3-none-win32.whl", hash = "sha256:0e25d63ec4ab116e7f6757e44d16ca9216bca679d19ecc36d119cf80faada61a", size = 10481096 }, + { url = "https://files.pythonhosted.org/packages/3b/36/1c7f8632bfec1c321f01581d4c940a3617b24bd3e8b37c8a7363d33fbfc4/ty-0.0.35-py3-none-win_amd64.whl", hash = "sha256:6a0a6d259f6f2f8f2f954c6f013d4e0b5eba68af6b353bf19a47d59ec254a3d5", size = 11555691 }, + { url = "https://files.pythonhosted.org/packages/7a/fb/59325221bce52f6e833d6865ce8360ef7d5e1e21151b38df6dc77c4327a7/ty-0.0.35-py3-none-win_arm64.whl", hash = "sha256:619c52c0fb2aa21961a848a1995135ad3b6d0a9aa54da0194e60f679cc200e13", size = 10925457 }, +] + [[package]] name = "typing-extensions" version = "4.15.0"