diff --git a/.github/workflows/ci-pipeline.yml b/.github/workflows/ci-pipeline.yml index 428ce217..fbc4a334 100644 --- a/.github/workflows/ci-pipeline.yml +++ b/.github/workflows/ci-pipeline.yml @@ -83,9 +83,11 @@ jobs: ruff check --config ruff.toml hololinked/serializers ruff check --config ruff.toml hololinked/schema_validators ruff check --config ruff.toml hololinked/storage + ruff check --config ruff.toml hololinked/metadata/td ruff check --config ruff.toml hololinked/serialization.py ruff check --config ruff.toml hololinked/schemas.py ruff check --config ruff.toml hololinked/persistence.py + ruff check --config ruff.toml hololinked/ddl.py - name: run ty type checker if: matrix.tool == 'ty' @@ -95,9 +97,11 @@ jobs: ty check hololinked/serializers ty check hololinked/schema_validators ty check hololinked/storage + ty check hololinked/metadata/td ty check hololinked/serialization.py ty check hololinked/schemas.py ty check hololinked/persistence.py + ty check hololinked/ddl.py scan: name: security scan (${{ matrix.tool }}) diff --git a/hololinked/client/abstractions.py b/hololinked/client/abstractions.py index 4d5989ab..b6e49e40 100644 --- a/hololinked/client/abstractions.py +++ b/hololinked/client/abstractions.py @@ -36,8 +36,8 @@ import structlog from hololinked.constants import Operations -from hololinked.td import ActionAffordance, EventAffordance, PropertyAffordance -from hololinked.td.forms import Form +from hololinked.metadata.td import ActionAffordance, EventAffordance, PropertyAffordance +from hololinked.metadata.td.forms import Form from hololinked.utils import get_current_async_loop diff --git a/hololinked/client/factory.py b/hololinked/client/factory.py index bdb03e38..574c7b40 100644 --- a/hololinked/client/factory.py +++ b/hololinked/client/factory.py @@ -27,7 +27,7 @@ ) from hololinked.constants import ZMQ_TRANSPORTS from hololinked.core import Thing -from hololinked.td.interaction_affordance import ( +from hololinked.metadata.td.interaction_affordance import ( ActionAffordance, EventAffordance, PropertyAffordance, @@ -164,7 +164,7 @@ def zmq( # Fetch the TD Thing.get_thing_model # noqa: B018 # type: Action - FetchTDAffordance = Thing.get_thing_model.to_affordance() + FetchTDAffordance = Thing.get_thing_model.to_metadata() FetchTDAffordance.override_defaults(name="get_thing_description", thing_id=thing_id) FetchTD = ZMQAction( resource=FetchTDAffordance, @@ -193,7 +193,7 @@ def zmq( # add properties for name in TD.get("properties", []): - affordance = cast(PropertyAffordance, PropertyAffordance.from_TD(name, TD)) + affordance = PropertyAffordance.from_TD(name, TD) consumed_property = ZMQProperty( resource=affordance, sync_client=sync_zmq_client, @@ -213,7 +213,7 @@ def zmq( ClientFactory.add_event(object_proxy, consumed_observable) # add actions for action in TD.get("actions", []): - affordance = cast(ActionAffordance, ActionAffordance.from_TD(action, TD)) + affordance = ActionAffordance.from_TD(action, TD) consumed_action = ZMQAction( resource=affordance, sync_client=sync_zmq_client, @@ -226,7 +226,7 @@ def zmq( ClientFactory.add_action(object_proxy, consumed_action) # add events for event in TD.get("events", []): - affordance = cast(EventAffordance, EventAffordance.from_TD(event, TD)) + affordance = EventAffordance.from_TD(event, TD) consumed_event = ZMQEvent( resource=affordance, owner_inst=object_proxy, @@ -395,7 +395,7 @@ def http(url: str, **kwargs) -> ObjectProxy: object_proxy = ObjectProxy(id, td=TD, logger=logger, security=security, **kwargs) for name in TD.get("properties", []): - affordance = cast(PropertyAffordance, PropertyAffordance.from_TD(name, TD)) + affordance = PropertyAffordance.from_TD(name, TD) consumed_property = HTTPProperty( resource=affordance, sync_client=req_rep_sync_client, @@ -418,7 +418,7 @@ def http(url: str, **kwargs) -> ObjectProxy: ) ClientFactory.add_event(object_proxy, consumed_event) for action in TD.get("actions", []): - affordance = cast(ActionAffordance, ActionAffordance.from_TD(action, TD)) + affordance = ActionAffordance.from_TD(action, TD) consumed_action = HTTPAction( resource=affordance, sync_client=req_rep_sync_client, @@ -430,7 +430,7 @@ def http(url: str, **kwargs) -> ObjectProxy: ) ClientFactory.add_action(object_proxy, consumed_action) for event in TD.get("events", []): - affordance = cast(EventAffordance, EventAffordance.from_TD(event, TD)) + affordance = EventAffordance.from_TD(event, TD) consumed_event = HTTPEvent( resource=affordance, sync_client=sse_sync_client, diff --git a/hololinked/client/http/consumed_interactions.py b/hololinked/client/http/consumed_interactions.py index 1bda4de0..10179226 100644 --- a/hololinked/client/http/consumed_interactions.py +++ b/hololinked/client/http/consumed_interactions.py @@ -21,8 +21,8 @@ ) from hololinked.client.exceptions import raise_local_exception from hololinked.constants import Operations -from hololinked.td.forms import Form -from hololinked.td.interaction_affordance import ( +from hololinked.metadata.td.forms import Form +from hololinked.metadata.td.interaction_affordance import ( ActionAffordance, EventAffordance, PropertyAffordance, diff --git a/hololinked/client/mqtt/consumed_interactions.py b/hololinked/client/mqtt/consumed_interactions.py index 1c90d181..195920e6 100644 --- a/hololinked/client/mqtt/consumed_interactions.py +++ b/hololinked/client/mqtt/consumed_interactions.py @@ -12,8 +12,11 @@ from hololinked import Serializers from hololinked.client.abstractions import SSE, ConsumedThingEvent from hololinked.core.interfaces import BaseSerializer # noqa: F401 -from hololinked.td.forms import Form -from hololinked.td.interaction_affordance import EventAffordance, PropertyAffordance +from hololinked.metadata.td.forms import Form +from hololinked.metadata.td.interaction_affordance import ( + EventAffordance, + PropertyAffordance, +) class MQTTConsumer(ConsumedThingEvent): # noqa: D101 diff --git a/hololinked/client/zmq/consumed_interactions.py b/hololinked/client/zmq/consumed_interactions.py index 8470d6f8..eb0eb347 100644 --- a/hololinked/client/zmq/consumed_interactions.py +++ b/hololinked/client/zmq/consumed_interactions.py @@ -17,28 +17,26 @@ ConsumedThingEvent, ConsumedThingProperty, ) -from hololinked.client.exceptions import raise_local_exception - -from ...constants import Operations -from ...core import Action, Thing # noqa: F401 -from ...core.zmq.brokers import ( +from hololinked.client.exceptions import ReplyNotArrivedError, raise_local_exception +from hololinked.constants import Operations +from hololinked.core import Action, Thing # noqa: F401 +from hololinked.core.zmq.brokers import ( AsyncEventConsumer, AsyncZMQClient, BreakLoop, EventConsumer, SyncZMQClient, ) -from ...core.zmq.message import ( +from hololinked.core.zmq.message import ( EMPTY_BYTE, ERROR, INVALID_MESSAGE, TIMEOUT, ResponseMessage, ) -from ...core.zmq.payloads import SerializableData -from ...td import ActionAffordance, EventAffordance, PropertyAffordance -from ...td.forms import Form -from ..exceptions import ReplyNotArrivedError +from hololinked.core.zmq.payloads import SerializableData +from hololinked.metadata.td import ActionAffordance, EventAffordance, PropertyAffordance +from hololinked.metadata.td.forms import Form __error_message_types__ = [TIMEOUT, ERROR, INVALID_MESSAGE] @@ -604,7 +602,7 @@ def __init__( timeout for execution of action or property read/write """ action = Thing._set_properties # type: Action - resource = action.to_affordance(Thing) + resource = action.to_metadata(Thing) resource._thing_id = owner_inst.thing_id super().__init__( resource=resource, @@ -645,7 +643,7 @@ def __init__( timeout for execution of action or property read/write """ action = Thing._get_properties # type: Action - resource = action.to_affordance(Thing) + resource = action.to_metadata(Thing) resource._thing_id = owner_inst.thing_id super().__init__( resource=resource, diff --git a/hololinked/core/actions.py b/hololinked/core/actions.py index fc4781a9..191422d4 100644 --- a/hololinked/core/actions.py +++ b/hololinked/core/actions.py @@ -1,9 +1,13 @@ +"""Concrete definition of an Action. Implemention of async and sync versions, action decorator.""" + +from __future__ import annotations + import warnings from enum import Enum from inspect import getfullargspec, iscoroutinefunction from types import FunctionType, MethodType -from typing import Any +from typing import TYPE_CHECKING, Any import jsonschema @@ -12,6 +16,7 @@ from hololinked import SchemaValidatorClasses from hololinked.constants import JSON from hololinked.core.exceptions import StateMachineError +from hololinked.core.interfaces.metadata import ActionMetadata from hololinked.param.parameterized import ParameterizedFunction from hololinked.utils import ( get_input_model_from_signature, @@ -24,6 +29,11 @@ from .dataklasses import ActionInfoValidator +if TYPE_CHECKING: + from hololinked.core.interfaces import ActionMetadata + from hololinked.core.thing import Thing + + class Action: """ Object that models an action. @@ -91,7 +101,7 @@ def execution_info(self, value: ActionInfoValidator) -> None: raise TypeError("execution_info must be of type ActionInfoValidator") self._execution_info = value # type: ActionInfoValidator - def to_affordance(self, owner_inst=None): + def to_metadata(self, owner_inst: Thing | None = None, format: str = "wot") -> ActionMetadata: """ Generates a `ActionAffordance` TD fragment for this Action. @@ -105,9 +115,12 @@ def to_affordance(self, owner_inst=None): ActionAffordance the affordance TD fragment for this action """ - from ..td import ActionAffordance + from hololinked.ddl import MetadataFormats - return ActionAffordance.generate(self, owner_inst or self.owner) + return MetadataFormats.get(format).action.from_descriptor( + self, + owner_inst or self.owner, + ) class BoundAction: @@ -132,7 +145,7 @@ def __init__(self, obj: FunctionType, descriptor: Action, owner_inst, owner) -> def __post_init__(self): # never called, neither possible to call, only type hinting - from .thing import Thing, ThingMeta + from .thing import ThingMeta # owner class and instance self.owner: ThingMeta @@ -145,6 +158,7 @@ def __post_init__(self): def validate_call(self, args, kwargs: dict[str, Any]) -> None: """ Validate the call to the action, like payload, state machine state etc. + Errors are raised as exceptions. Parameters @@ -153,6 +167,13 @@ def validate_call(self, args, kwargs: dict[str, Any]) -> None: positional arguments to the action kwargs: dict keyword arguments to the action + + Raises + ------ + StateMachineError + if the action cannot be executed in the current state of the owning thing + RuntimeError + if the action explicity accepts only keyword arguments but some positional arguments are given """ if self.execution_info.isparameterized and len(args) > 0: raise RuntimeError("parameterized functions cannot have positional arguments") @@ -202,7 +223,7 @@ def __getattribute__(self, name): return self.obj.__doc__ return super().__getattribute__(name) - def to_affordance(self): + def to_metadata(self, owner_inst: Thing | None = None, format: str = "wot") -> ActionMetadata: """ Generates a `ActionAffordance` TD fragment for this Action. @@ -216,17 +237,19 @@ def to_affordance(self): ActionAffordance the affordance TD fragment for this action """ - return Action.to_affordance(self.descriptor, self.owner_inst or self.owner) + return Action.to_metadata(self.descriptor, owner_inst or self.owner_inst or self.owner, format=format) class BoundSyncAction(BoundAction): """ - non async(io) action call. The call is passed to the method as-it-is to allow local + Non-async(io) action call. + + The call is passed to the method as-it-is to allow local invocation without state machine checks. Use `external_call` to have validation. """ def external_call(self, *args, **kwargs): - """Validated call to the action with state machine and payload checks""" + """Validated call to the action with state machine and payload checks.""" self.validate_call(args, kwargs) return self.__call__(*args, **kwargs) @@ -238,12 +261,14 @@ def __call__(self, *args, **kwargs): class BoundAsyncAction(BoundAction): """ - async(io) action call. The call is passed to the method as-it-is to allow local + async(io) action call. + + The call is passed to the method as-it-is to allow local invocation without state machine checks. Use `external_call` to have validation. """ async def external_call(self, *args, **kwargs): - """Validated call to the action with state machine and payload checks""" + """Validated call to the action with state machine and payload checks.""" self.validate_call(args, kwargs) return await self.__call__(*args, **kwargs) @@ -263,8 +288,9 @@ def action( **kwargs, ) -> Action: """ - Decorate on your methods to make them accessible remotely or create 'actions' out of them. When used with hardware, - actions generally command the hardware to do something. + Decorate on your methods to make them accessible remotely or create 'actions' out of them. + + When used with hardware, actions generally command the hardware to do something. Parameters ---------- @@ -298,6 +324,13 @@ def action( Action returns the callable object wrapped in an `Action` object. When accessed at instance level, a `BoundSyncAction` or `BoundAsyncAction` object is returned. + + Raises + ------ + TypeError + if the decorated object is not a function or method, or if the input/output schema is of invalid type + ValueError + if the decorated function is a dunder method, or if unknown keyword arguments are provided """ def inner(obj): diff --git a/hololinked/core/dataklasses.py b/hololinked/core/dataklasses.py index e978b841..13a5ff9f 100644 --- a/hololinked/core/dataklasses.py +++ b/hololinked/core/dataklasses.py @@ -1,8 +1,12 @@ """ -The following is a list of all dataclasses used to store information on the exposed -resources on the network. These classese are generally not for consumption by the package-end-user. +The following is a list of all dataclasses used to store information on the exposed resources on the network. + +These will be deprecated ASAP, initially used for validation purposes as the related objects were not so well defined, +currently a nuisance. These classese are generally not for consumption by the package-end-user. """ +from __future__ import annotations + from enum import Enum from types import FunctionType, MethodType from typing import Any @@ -19,9 +23,11 @@ # TODO, this class will be removed in future and merged directly into the corresponding object class RemoteResourceInfoValidator: """ - A validator class for saving remote access related information on a resource. Currently callables (functions, - methods and those with__call__) and class/instance property store this information as their own attribute under - the variable ``_execution_info_validator``. This is later split into information suitable for HTTP server, ZMQ client & ``EventLoop``. + A validator class for saving remote access related information on a resource. + + Currently callables (functions, methods and those with__call__) and class/instance property store this information + as their own attribute under the variable ``_execution_info_validator``. This is later split into information + suitable for HTTP server, ZMQ client & ``EventLoop``. Attributes ---------- @@ -78,7 +84,9 @@ def __init__(self, **kwargs) -> None: class ActionInfoValidator(RemoteResourceInfoValidator): """ - request_as_argument : bool, default False + Action validator. + + request_as_argument: bool, default False if True, http/ZMQ request object will be passed as an argument to the callable. The user is warned to not use this generally. argument_schema: JSON, default None @@ -156,7 +164,7 @@ def _set_return_value_schema(self, value): value = self.action_payload_schema_validator(value) setattr(self, "_return_value_schema", value) - def action_payload_schema_validator(self, value: Any) -> Any: + def action_payload_schema_validator(self, value: Any) -> Any: # noqa if value is None or isinstance(value, dict) or issubklass(value, (BaseModel, RootModel)): return value raise TypeError("Schema must be None, a dict, or a subclass of BaseModel or RootModel") diff --git a/hololinked/core/events.py b/hololinked/core/events.py index f7130764..176c31c1 100644 --- a/hololinked/core/events.py +++ b/hololinked/core/events.py @@ -1,17 +1,29 @@ -from typing import Any, overload +"""Concrete definition of an Event.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, overload import jsonschema -from ..config import global_config -from ..constants import JSON -from ..param.parameterized import Parameterized, ParameterizedMetaclass +from hololinked.config import global_config +from hololinked.constants import JSON +from hololinked.core.interfaces.metadata import EventMetadata +from hololinked.param.parameterized import Parameterized, ParameterizedMetaclass + + +if TYPE_CHECKING: + from hololinked.core.events import EventMetadata + from hololinked.core.thing import Thing + from hololinked.core.zmq.brokers import EventPublisher class Event: """ - Asynchronously push arbitrary messages to clients (as-in messages that cannot be properly timed) without - the client requesting the data every time. Events are pushed from the server to the clients - that have subscribed to them. + Asynchronously push arbitrary messages to clients without the client requesting the data every time. + + Asynchronous as-in messages that cannot be properly timed, not necessary `async`. Events are pushed from the server + to the clients that have subscribed to them. """ __slots__ = ["name", "_internal_name", "_publisher", "_observable", "doc", "schema", "label", "owner"] @@ -23,6 +35,8 @@ def __init__( label: str | None = None, ) -> None: """ + Initialize an event. + Parameters ---------- doc: str @@ -64,9 +78,9 @@ def __get__(self, obj: Parameterized, objtype: ParameterizedMetaclass = None): "Event object not yet initialized, please dont access now." + " Access after Thing is running." ) - def to_affordance(self, owner_inst: Any = None): + def to_metadata(self, owner_inst: Thing | None = None, format: str = "wot") -> EventMetadata: """ - Generates a `EventAffordance` TD fragment for this Event + Generates a `EventAffordance` TD fragment for this Event. Parameters ---------- @@ -78,15 +92,16 @@ def to_affordance(self, owner_inst: Any = None): EventAffordance the affordance TD fragment for this event """ - from ..td import EventAffordance + from hololinked.ddl import MetadataFormats - return EventAffordance.generate(self, owner_inst or self.owner) + return MetadataFormats.get(format).event.from_descriptor(self, owner_inst or self.owner) class EventDispatcher: """ - The worker that pushes an event. The separation is necessary between `Event` and - `EventDispatcher` to allow class level definitions of the `Event` + The worker that pushes an event. + + The separation is necessary between `Event` and `EventDispatcher` to allow class level definitions of the `Event` """ __slots__ = ["_unique_identifier", "_publisher", "_owner_inst", "_descriptor"] @@ -94,7 +109,7 @@ class EventDispatcher: def __init__( self, unique_identifier: str, - publisher: "EventPublisher", # noqa TODO fix + publisher: EventPublisher, owner_inst: ParameterizedMetaclass, descriptor: Event, ) -> None: @@ -104,12 +119,12 @@ def __init__( self.publisher = publisher @property - def publisher(self) -> "EventPublisher": # noqa TODO fix - """Event publishing PUB socket owning object""" + def publisher(self) -> EventPublisher: + """Event publishing PUB socket owning object.""" return self._publisher @publisher.setter - def publisher(self, value: "EventPublisher") -> None: # noqa TODO fix + def publisher(self, value: EventPublisher) -> None: # TODO fix this once the architecture is resolved from .zmq.brokers import EventPublisher # noqa: E402 @@ -122,7 +137,9 @@ def publisher(self, value: "EventPublisher") -> None: # noqa TODO fix def push(self, data: Any) -> None: """ - Publish the event. Multipart payloads are not supported. Supply either a serializable object or a + Publish the event. + + Multipart payloads are not supported. Supply either a serializable object or a bytes object for binary data, not both. Parameters @@ -134,18 +151,28 @@ def push(self, data: Any) -> None: def receive_acknowledgement(self, timeout: float | int | None) -> bool: """ + Receive acknowledgement for an event that was just pushed. + Not Implemented. - Receive acknowledgement for an event that was just pushed. + Parameters + ---------- + timeout: float | int | None + timeout for receiving the acknowledgement, in seconds. If None, wait indefinitely. + + Returns + ------- + bool + True if acknowledgement is received, False if timeout is reached. """ raise NotImplementedError("Event acknowledgement is not implemented yet.") return self._synchronize_event.wait(timeout=timeout) def _set_acknowledgement(self, *args, **kwargs) -> None: """ - Not Implemented. - Once an acknowledgement is received from the client, this function is called to set the event. + + Not Implemented. """ raise NotImplementedError("Event acknowledgement is not implemented yet.") self._synchronize_event.set() diff --git a/hololinked/core/interfaces/__init__.py b/hololinked/core/interfaces/__init__.py index 79ae1873..701cc980 100644 --- a/hololinked/core/interfaces/__init__.py +++ b/hololinked/core/interfaces/__init__.py @@ -8,3 +8,12 @@ from .configuration import BaseConfigurationRepository as BaseConfigurationRepository from .schema_validators import BaseSchemaValidator as BaseSchemaValidator from .serializer import BaseSerializer as BaseSerializer + + +from .metadata import ( # isort: skip + Metadata as Metadata, + PropertyMetadata as PropertyMetadata, + ActionMetadata as ActionMetadata, + EventMetadata as EventMetadata, + InteractionMetadata as InteractionMetadata, +) diff --git a/hololinked/core/interfaces/configuration.py b/hololinked/core/interfaces/configuration.py index b239f8c9..72ec0905 100644 --- a/hololinked/core/interfaces/configuration.py +++ b/hololinked/core/interfaces/configuration.py @@ -16,7 +16,7 @@ class BaseConfigurationRepository: """ Repository/Persistence for a `Thing` instance that can be used for storing configuration. - Model your device setting's as properties and set `db_persist=True` in the property's argument to `True` + Model your device setting's as properties and set `db_persist=True`/`db_commit=True` in the property's argument to activate persistance. If your server dies and restarts, the settings will be reloaded and applied to your device. Different storage backends (like JSON file, SQLAlchemy, MongoDB) are supported and custom backends can be @@ -26,7 +26,7 @@ class BaseConfigurationRepository: def __init__(self, thing: Thing) -> None: self.thing = thing - def fetch_own_info(self): # -> ThingInformation: + def fetch_own_info(self) -> Any: """Fetch `Thing` instance's own information (some useful metadata which could help the `Thing` run).""" def get_property(self, property: str | Property, deserialized: bool = True) -> Any: diff --git a/hololinked/core/interfaces/metadata.py b/hololinked/core/interfaces/metadata.py new file mode 100644 index 00000000..113cf1dd --- /dev/null +++ b/hololinked/core/interfaces/metadata.py @@ -0,0 +1,420 @@ +"""Metadata Management Abstract Base Class.""" + +from __future__ import annotations + +from enum import Enum +from typing import TYPE_CHECKING, Any, ClassVar, Optional, Self + +from pydantic import BaseModel, ConfigDict + +from hololinked.constants import ResourceTypes + + +if TYPE_CHECKING: + from hololinked.core.actions import Action + from hololinked.core.events import Event + from hololinked.core.property import Property + from hololinked.core.thing import Thing, ThingMeta + + +class Metadata(BaseModel): + """A base class to generate device or Thing metadata and conversely produce a Thing instance from the metadata.""" + + model_config = ConfigDict(extra="allow") + + def __init__( + self, + thing: Thing | None = None, + ignore_errors: bool = False, + skip_names: Optional[list[str]] = [], + ) -> None: + """ + Initialize the Metadata. + + Parameters + ---------- + thing: Thing | None, optional + The `Thing` instance for which the metadata is being generated. None, if the Thing instance is to be + produced from the metadata. + ignore_errors: bool, optional + Whether to ignore errors during metadata generation. Defaults to False. + skip_names: list[str], optional + List of property, action, or event names to skip when generating the metadata. Defaults to an empty list. + """ + super().__init__() + self.thing = thing + self.ignore_errors = ignore_errors + self.skip_names = skip_names or [] + + def generate(self) -> Metadata: + """Populate the metadata from the Thing instance.""" + raise NotImplementedError("Implement generate() in subclass") + + def produce(self) -> Thing: + """Produce a Thing instance from the metadata.""" + raise NotImplementedError("Implement produce() in subclass") + + skip_properties: list[str] + """ + List of default property names to skip when generating the metadata. Different from `skip_names` as + this list is supposed to be used as a builtin blacklist. + """ + + skip_actions: list[str] + """ + List of default action names to skip when generating the metadata. Different from `skip_names` as + this list is supposed to be used as a builtin blacklist. + """ + + skip_events: list[str] + """ + List of default event names to skip when generating the metadata. Different from `skip_names` as + this list is supposed to be used as a builtin blacklist. + """ + + def add_interactions(self) -> None: + """ + Add interaction(-affordances) to the metadata - properties, actions and events. + + This is to be tailored for the specific standard. + """ + raise NotImplementedError("Implement add_interactions() in subclass") + + def json(self, **kwargs) -> dict[str, Any]: + """ + Return the JSON string representation. + + Returns + ------- + dict[str, Any] + The JSON string representation. + """ + return self.model_dump(**kwargs) + + +class InteractionMetadata(BaseModel): + """ + Generate metadata for a property, action or event. + + A property, action or event is called as an interaction(-affordance), and the metadata generated for it + is named here as interaction metadata. This base class defines metadata methods common to all of properties, + actions or events, and could be common to different metadata or device description standards. Specific standards + need to extend this class. + """ + + _custom_metadata_generators: ClassVar[dict] + + def __init__(self): + super().__init__() + self._name = None + self._objekt = None + self._thing_id = None + self._thing_cls = None + self._owner = None + + @property + def what(self) -> Enum: + """Whether it is a property, action or event.""" + raise NotImplementedError("Unknown interaction (property, action, or event?), implement in subclass") + + @property + def owner(self) -> Thing: + """ + Owning `Thing` instance or `Thing` class of the interaction. + + Depending on how this object was created, returns either an instance or a class. + + Raises + ------ + AttributeError + If the owner is not set, which means this interaction is not properly bound to a `Thing` + instance or class. Dont explicitly instantiate this class without the context of a `Thing` instance + or class, or at least dont prematurely access this attribute. + """ + if self._owner is None: + raise AttributeError("owner is not set for this interaction") + return self._owner + + @property + def owner_cls(self) -> ThingMeta: + """ + Return the owning `Thing` class of the interaction. + + Raises + ------ + AttributeError + If the owner is not set, which means this interaction is not properly bound to a `Thing` + instance or class. Dont explicitly instantiate this class without the context of a `Thing` instance + or class, or at least dont prematurely access this attribute. + """ + if self._thing_cls is None: + raise AttributeError("owner_cls is not set for this interaction") + return self._thing_cls + + @property + def objekt(self) -> Property | Action | Event: + """ + Object instance of the interaction, instance of `Property`, `Action` or `Event`. + + Raises + ------ + AttributeError + If the metadata is not bound to any interaction object. + Use `Thing..to_metadata()` only method to generate this metadata + from a property object or similarly for action and event. + """ + if self._objekt is None: + raise AttributeError("Metadata bound to unknown object (property, action or event).") + return self._objekt + + @property + def name(self) -> str: + """ + Name of the interaction that could be used as a key in the metadata. + + Raises + ------ + AttributeError + If the metadata is not bound to any interaction object. This usually happens + when the metadata is not generated from an interaction object, but created manually or from a + different source. Use `Thing..to_metadata()` only to generate the metadata + from a property object, and similarly for action and event. + """ + if self._name is None: + raise AttributeError("Metadata bound to unknown object (property, action or event).") + return self._name + + @property + def thing_id(self) -> str: + """ + ID of the `Thing` instance owning the interaction, if available, otherwise None. + + Raises + ------ + AttributeError + If the metadata is not bound to any `Thing` instance. This usually happens + when the metadata is not generated from an interaction object, but created manually or from a + different source. Use `thing.properties.descriptors[].to_metadata()` + only to generate the metadata from a property object, and similarly for an action and event. + """ + if self._thing_id is None: + raise AttributeError("Metadata bound to unknown Thing (property, action or event's owner unknown).") + return self._thing_id + + @property + def thing_cls(self) -> ThingMeta: + """ + `Thing` class owning the interaction. + + Raises + ------ + AttributeError + If the metadata is not bound to any `Thing` class. This usually happens + when the metadata is not generated from a `Thing` class, but created manually or from a + different source. Use `thing.properties.descriptors[].to_metadata()` or `Thing.properties.descriptors[].to_metadata()` only method to generate the metadata from a property object, and similarly for action and event. + only method to generate the metadata from a property object, and similarly for action and event. + """ + if self._thing_cls is None: + raise AttributeError("Metadata bound to unknown Thing class (property, action or event's owner unknown).") + return self._thing_cls + + def build(self) -> None: + """Populate the fields of the metadata for the specific interaction.""" + raise NotImplementedError("build must be implemented in subclass of InteractionMetadata") + + @classmethod + def from_descriptor( + cls, + interaction: Property | Action | Event, + owner: Thing | ThingMeta, + ) -> Self: + """ + Instantitate and build the metadata for the specific interaction. + + Use the `json()` method to get the JSON representation of the metadata. + + Note that this method is different from `build()` method as its supposed to be used as a classmethod + to create an instance. Although, it internally calls `build()`, and some additional steps can be included. + + Parameters + ---------- + interaction: Property | Action | Event + interaction object for which the metadata is to be built + owner: Thing | ThingMeta + owner of the interaction + + Returns + ------- + PropertyMetadata | ActionMetadata | EventMetadata + Instance of this class with the metadata fields populated. + """ + raise NotImplementedError("from_descriptor() must be implemented in subclass of InteractionMetadata") + + @classmethod + def from_metadata(cls, name: str, metadata: dict[str, Any]) -> Self: + """ + Populate the metadata from the provided JSON and return it as an instance of this class. + + It is assumed that the interaction is a key in the provided JSON, so one needs to supply the name. + + Parameters + ---------- + name: str + name of the interaction used as key in the metadata + metadata: JSON + metadata JSON dictionary (the entire one, not just the component of the interaction) + + Returns + ------- + Self + Instance of this class. + + Raises + ------ + ValueError + If the interaction type cannot be determined from the metadata. + """ + raise NotImplementedError + + def to_descriptor(self) -> Property | Action | Event: + """ + Convert the metadata back to a `Property`, `Action` or `Event` descriptor object. + + Returns + ------- + Property | Action | Event + The corresponding descriptor object of the interaction. + + Raises + ------ + NotImplementedError + If the method is not implemented in the subclass, or if the metadata cannot be converted to any of the descriptor objects. + """ + raise NotImplementedError("to_descriptor() must be implemented in subclass of InteractionMetadata") + + @classmethod + def register_descriptor( + cls, + descriptor: Property | Action | Event, + metadata_generator: type[InteractionMetadata], + ) -> None: + """ + Register a custom metadata generator for a descriptor. + + Parameters + ---------- + descriptor: Property | Action | Event + The descriptor class + metadata_generator: type[InteractionMetadata] + `InteractionMetadata` subclass that implements the custom metadata generation logic for the descriptor. + Either override the `from_descriptor()` method or the `build()` method. + + Raises + ------ + TypeError + If the descriptor is not an instance of `Property`, `Action` or `Event`, or if the metadata generator is not an + instance of `InteractionMetadata`. + """ + raise NotImplementedError + + def build_non_compliant_metadata(self) -> None: + """If there is additional non standard metadata to be added, they can be added here.""" + pass + + def override_defaults(self, **kwargs): + """ + Override default values with provided keyword arguments, especially thing_id, owner name, object name etc. + + Any logic to trigger side effects while setting those values should be handled here, either by + reimplementing the method in the subclass or calling the super().override_defaults(**kwargs). + """ + for key, value in kwargs.items(): + if key == "name": + self._name = value + elif key == "thing_id": + self._thing_id = value + elif key == "owner": + self._owner = value + elif key == "thing_cls": + self._thing_cls = value + elif hasattr(self, key) or key in self.model_fields: + setattr(self, key, value) + + def __hash__(self): + return hash( + self.thing_id if self.thing_id else "" + self.thing_cls.__name__ if self.thing_cls else "" + self.name + ) + + def __str__(self): + if self.thing_cls: + return f"{self.__class__.__name__}({self.thing_cls.__name__}({self.thing_id}).{self.name})" + return f"{self.__class__.__name__}({self.name} of {self.thing_id})" + + def __eq__(self, value): + if not isinstance(value, self.__class__): + return False + if self.thing_id is None or value.thing_id is None: + if self.owner is None or value.owner is None: + # cannot determine anymore + return False + # basically you need to have an owner for the interaction affordance + # and a name to determine its equality. We should never check the owner + # by the name, but by the object, otherwise the equality cannot be gauranteed + if (self.owner == value.owner or self.thing_cls == value.thing_cls) and self.name == value.name: + return True + return False + return self.thing_id == value.thing_id and self.name == value.name + + def __deepcopy__(self, memo): + raise NotImplementedError("Implement in subclass") + + def __getstate__(self): + state = self.__dict__.copy() + # Remove possibly unpicklable entries + if "_owner" in state: + del state["_owner"] + if "_thing_cls" in state: + del state["_thing_cls"] + if "_objekt" in state: + del state["_objekt"] + return state + + def json(self) -> dict[str, Any]: + """Return the JSON representation.""" + raise NotImplementedError("json() must be implemented in subclass of InteractionMetadata") + + +class PropertyMetadata(InteractionMetadata): + """Generate property metadata from `Property` descriptor object.""" + + @property + def what(self) -> Enum: # noqa: D102 + return ResourceTypes.PROPERTY + + @classmethod + def from_descriptor(cls, property: Property, owner: Thing | ThingMeta) -> PropertyMetadata: # noqa: D102 + raise NotImplementedError + + +class ActionMetadata(InteractionMetadata): + """Generate action metadata from `Action` descriptor object.""" + + @property + def what(self) -> Enum: # noqa: D102 + return ResourceTypes.ACTION + + @classmethod + def from_descriptor(cls, action: Action, owner: Thing | ThingMeta) -> ActionMetadata: # noqa: D102 + raise NotImplementedError + + +class EventMetadata(InteractionMetadata): + """Generate event metadata from `Event` descriptor object.""" + + @property + def what(self) -> Enum: # noqa: D102 + return ResourceTypes.EVENT + + @classmethod + def from_descriptor(cls, event: Event, owner: Thing | ThingMeta) -> EventMetadata: # noqa: D102 + raise NotImplementedError diff --git a/hololinked/core/meta.py b/hololinked/core/meta.py index 513b2c5e..2eb2682e 100644 --- a/hololinked/core/meta.py +++ b/hololinked/core/meta.py @@ -822,7 +822,7 @@ def properties(self) -> PropertiesRegistry: return self._properties_registry # we need to specification define it as an action to for the possibility of getting an - # Affordance object associated with it i.e _get_properties.to_affordance() function needs to work. + # Affordance object associated with it i.e _get_properties.to_metadata() function needs to work. # TODO - fix this anomaly @action() def _get_properties(self, **kwargs) -> dict[str, Any]: diff --git a/hololinked/core/property.py b/hololinked/core/property.py index f9d855a3..3407af8f 100644 --- a/hololinked/core/property.py +++ b/hololinked/core/property.py @@ -1,7 +1,9 @@ +"""Concrete implementation of a `Property` through python's descriptor procotol.""" + from __future__ import annotations from enum import Enum -from typing import Any, Callable, Type +from typing import TYPE_CHECKING, Any, Callable, Type from pydantic import BaseModel, ConfigDict, RootModel, create_model @@ -14,9 +16,15 @@ from .exceptions import StateMachineError +if TYPE_CHECKING: + from hololinked.core.interfaces import PropertyMetadata + from hololinked.core.thing import Thing + + class Property(Parameter): """ get/set/delete an object/instance attribute with type definitions, validations, post-set effects, metadata and more. + Please note the capital 'P' in `Property` to differentiate from python's own `property`. `Property` objects are similar to python's `property` but not a subclass of it due to limitations and redundancy. """ @@ -62,6 +70,8 @@ def __init__( metadata: dict | None = None, ) -> None: """ + Initialize a `Property` object. + Parameters ---------- default: None or corresponding to property type @@ -203,10 +213,11 @@ def __get__(self, obj: Parameterized, objtype: ParameterizedMetaclass) -> Any: self.push_change_event(obj, read_value) return read_value - def push_change_event(self, obj, value: Any) -> None: + def push_change_event(self, obj: Thing, value: Any) -> None: """ - Pushes change event both on read and write if an event publisher object is available - on the owning `Thing`. + Pushes change event both on read and write if an event publisher object is available on the owning `Thing`. + + An event publisher will be available upon serving the `Thing` instance. Parameters ---------- @@ -233,8 +244,19 @@ def push_change_event(self, obj, value: Any) -> None: def validate_and_adapt(self, value) -> Any: """ - Validate the given value and adapt it if a proper logical reasoning can be given, - for example, cropping a number to its bounds. Returns modified value. + Validate the given value and adapt it if a proper logical reasoning can be given. + + For example, cropping a number to its bounds. + + Returns + ------- + Any + modified or original value. + + Raises + ------ + ValueError + If the value is None but None is not allowed. """ if value is None: if self.allow_None: @@ -252,8 +274,14 @@ def validate_and_adapt(self, value) -> Any: def external_set(self, obj: Parameterized, value: Any) -> None: """ - Method called when the value of the property is set from an external source, e.g. a remote client. - Usually introduces a state machine check before allowing the set operation. + Called when the value of the property is set from an external source, e.g. a remote client. + + Introduces a state machine check before allowing the set operation. + + Raises + ------ + StateMachineError + If the `Thing` instance is in a state where this property cannot be written. """ if self.execution_info.state is None or ( hasattr(obj, "state_machine") and obj.state_machine.current_state in self.execution_info.state @@ -274,8 +302,7 @@ def _post_value_set(self, obj, value: Any) -> None: def comparator(self, func: Callable) -> Callable: """ - Register a comparator method using this decorator to decide when to push - a change event. + Register a comparator method using this decorator to decide when to push a change event. Signature of the comparator method must be: ``` @@ -301,31 +328,40 @@ def func(self, old_value, new_value) -> bool @property def is_remote(self): - """`False` if the property is not remotely accessible""" + """`False` if the property is not remotely accessible.""" return self._execution_info_validator is not None @property def observable(self) -> bool: - """`True` if the property pushes change events on read and write""" + """`True` if the property pushes change events on read and write.""" return self._observable_event_descriptor is not None - def to_affordance(self, owner_inst=None): + def to_metadata(self, owner_inst: Thing | None = None, format: str = "wot") -> PropertyMetadata: """ - Generates a `PropertyAffordance` TD fragment for this Property + Generates a `PropertyAffordance` TD fragment for this Property. Parameters ---------- owner_inst: Thing, optional The instance of the owning `Thing` object. If not supplied, the class is used. + format: str, default "wot" + The metadata format to generate the affordance in. Currently, the only supported format is "wot" or + W3C Web of Things. Returns ------- PropertyAffordance the affordance TD fragment for this property + + Raises + ------ + ValueError + If the specified format is not supported. Currently, the only supported format is + "wot" or W3C Web of Things. """ - from ..td import PropertyAffordance + from hololinked.ddl import MetadataFormats - return PropertyAffordance.generate(self, owner_inst or self.owner) + return MetadataFormats.get(format).property.from_descriptor(self, owner_inst or self.owner) class ModelRoot(RootModel): @@ -340,6 +376,12 @@ def wrap_plain_types_in_rootmodel(model: type) -> Type[BaseModel] | Type[RootMod through unchanged. Otherwise, we wrap the type in a RootModel. In the future, we may explicitly check that the argument is a type and not a model instance. + + Returns + ------- + Type[BaseModel] | Type[RootModel] + a `BaseModel` subclass which can be used for validation. If the input was already a `BaseModel` subclass, + it is returned unchanged. Otherwise, a new `RootModel` subclass is returned which wraps the input type. """ if model is None: return diff --git a/hololinked/core/state_machine.py b/hololinked/core/state_machine.py index cd54b957..93011cb7 100644 --- a/hololinked/core/state_machine.py +++ b/hololinked/core/state_machine.py @@ -13,7 +13,9 @@ class StateMachine: """ - A finite state machine to constrain property and action execution. Each `Thing` class can only have one state machine + A finite state machine to constrain property and action execution. + + Each `Thing` class can only have one state machine instantiated in a reserved class-level attribute named `state_machine`. Other instantiations are not respected. The `state` attribute defined as a `Thing`'s property reflects the current state of the state machine and can be subscribed for state change events. When `state_machine` is accessed by a `Thing` instance, @@ -134,8 +136,7 @@ def __set_name__(self, owner: ThingMeta, name: str) -> None: self.owner = owner def validate(self, owner: Thing) -> None: - """validate the state machine, whether the properties, actions and states are correctly specified""" - + """Validate the state machine, whether the properties, actions and states are correctly specified""" # cannot merge this with __set_name__ because descriptor objects are not ready at that time. # reason - metaclass __init__ is called after __set_name__ of descriptors, therefore the new "proper" desriptor # registries are available only after that. Until then only the inherited descriptor registries are available, @@ -276,7 +277,7 @@ def __init__(self, owner: Thing, state_machine: StateMachine) -> None: def get_state(self) -> str | StrEnum | None: """ - return the current state, one can also access it using the property `current state`. + Return the current state, one can also access it using the property `current state`. Returns ------- @@ -290,7 +291,7 @@ def get_state(self) -> str | StrEnum | None: def set_state(self, value: str | StrEnum | Enum, push_event: bool = True, skip_callbacks: bool = False) -> None: """ - set state of state machine. Also triggers state change callbacks if `skip_callbacks=False` and pushes a state + Set state of state machine. Also triggers state change callbacks if `skip_callbacks=False` and pushes a state change event when `push_event=True` (when __init__ argument `push_state_change_event=True`). One can also set state using the '=' operator of the `current_state` property, in which case `skip_callbacks=False` and `push_event=True` will be used. @@ -361,32 +362,32 @@ def __contains__(self, state: str | StrEnum) -> bool: @property def initial_state(self): - """initial state of the machine""" + """Initial state of the machine""" return self.descriptor.initial_state @property def states(self): - """list of allowed states""" + """List of allowed states""" return self.descriptor.states @property def on_enter(self): - """callbacks to execute when a certain state is entered""" + """Callbacks to execute when a certain state is entered""" return self.descriptor.on_enter @property def on_exit(self): - """callbacks to execute when certain state is exited""" + """Callbacks to execute when certain state is exited""" return self.descriptor.on_exit @property def machine(self): - """the machine specification with state as key and objects as list""" + """The machine specification with state as key and objects as list""" return self.descriptor.machine def prepare_object_FSM(instance: Thing) -> None: - """validate and prepare the state machine attached to a Thing class""" + """Validate and prepare the state machine attached to a Thing class""" cls = instance.__class__ if cls.state_machine and isinstance(cls.state_machine, StateMachine): cls.state_machine.validate(instance) diff --git a/hololinked/core/thing.py b/hololinked/core/thing.py index a0345903..34c150b7 100644 --- a/hololinked/core/thing.py +++ b/hololinked/core/thing.py @@ -1,3 +1,7 @@ +"""Concrete Implementation of a `Thing` that represents a physical or virtual object.""" + +from __future__ import annotations + import inspect import logging import ssl @@ -6,7 +10,7 @@ import structlog -from hololinked.core.interfaces import BaseConfigurationRepository +from hololinked.core.interfaces import BaseConfigurationRepository, Metadata from ..constants import ZMQ_TRANSPORTS from ..utils import forkable, getattr_without_descriptor_read @@ -19,10 +23,12 @@ class Thing(Propertized, RemoteInvokable, EventSource, metaclass=ThingMeta): """ - Subclass from here to expose hardware or python objects on the network. Remotely accessible members of a `Thing` are - segregated into properties, actions & events. Utilize properties for data that can be read and written, - actions to instruct the object to perform tasks and events to get notified of any relevant information. State Machines - can be used to constrain operations on properties and actions. + Subclass from here to expose hardware or python objects on the network. + + Remotely accessible members of a `Thing` are segregated into properties, actions & events. + Utilize properties for data that can be read and written, actions to instruct the object to perform tasks + and events to get notified of any relevant information. State Machines can be used to constrain operations on + properties and actions. [UML Diagram](https://docs.hololinked.dev/UML/PDF/Thing.pdf) """ @@ -94,6 +100,8 @@ def __init__( **kwargs: dict[str, Any], ) -> None: """ + Initialize a `Thing`. + Parameters ---------- id: str @@ -201,9 +209,10 @@ def sub_things(self) -> dict[str, "Thing"]: return things @action() - def get_thing_model(self, ignore_errors: bool = False, skip_names: list[str] = []): + def get_thing_model(self, ignore_errors: bool = False, skip_names: list[str] = [], format: str = "wot") -> Metadata: """ Generate the [Thing Model](https://www.w3.org/TR/wot-thing-description11/#introduction-tm) of the object. + The model is a JSON that describes the object's properties, actions, events and their metadata, without the protocol information. The model can be used by a client to understand the object's capabilities. @@ -218,15 +227,23 @@ def get_thing_model(self, ignore_errors: bool = False, skip_names: list[str] = [ Returns ------- - hololinked.td.ThingModel + hololinked.metadata.td.ThingModel represented as an object in python, gets automatically serialized to JSON when pushed out of the socket. """ # allow_loose_schema: bool, optional, Default False # Experimental properties, actions or events for which schema was not given will be supplied with a suitable # inaccurate but truthy value. In other words, schema validation will always pass. - from ..td.tm import ThingModel - - return ThingModel(instance=self, ignore_errors=ignore_errors, skip_names=skip_names).generate() + from hololinked.ddl import MetadataFormats + + return ( + MetadataFormats.get(format) + .thing( + thing=self, + ignore_errors=ignore_errors, + skip_names=skip_names, + ) + .generate() + ) thing_model = property(get_thing_model, doc=get_thing_model.__doc__) @@ -238,8 +255,9 @@ def run_with_zmq_server( **kwargs: dict[str, Any], ) -> None: """ - Quick-start to serve `Thing` over ZMQ. This method is fully blocking, call `exit()` (`hololinked.server.exit()`) - to unblock. + Quick-start to serve `Thing` over ZMQ. + + This method is fully blocking, call `exit()` (`hololinked.server.exit()`) to unblock. Parameters ---------- @@ -293,8 +311,9 @@ def run_with_http_server( **kwargs: dict[str, Any], ) -> None: """ - Quick-start to serve `Thing` over HTTP. This method is fully blocking, call `exit()` (`hololinked.server.exit()`) - to unblock. + Quick-start to serve `Thing` over HTTP. + + This method is fully blocking, call `exit()` (`hololinked.server.exit()`) to unblock. Parameters ---------- @@ -341,8 +360,9 @@ def run( **kwargs: dict[str, Any], ) -> None: """ - Expose the object with the given servers. This method is blocking until `exit()` (`hololinked.server.exit()`) - is called. + Expose the object with the given servers. + + This method is blocking until `exit()` (`hololinked.server.exit()`) is called. >>> Thing(id='example_id').run(servers=[http_server, zmq_server, mqtt_publisher]) @@ -361,6 +381,11 @@ def run( - `servers`: list[BaseProtocolServer] list of instantiated servers to expose the object. + + Raises + ------ + ValueError + if neither `access_points` nor `servers` is provided, or if both are provided. """ from ..server.server import BaseProtocolServer, parse_params, run # noqa: F401 @@ -381,7 +406,9 @@ def run( @action() def exit(self) -> None: """ - Stop serving the object. This method usually needs to be called remotely. + Stop serving the object. + + This method usually needs to be called remotely. The servers are not stopped, just the object run loop is exited. """ if self.rpc_server is None: @@ -397,7 +424,9 @@ def exit(self) -> None: @action() def ping(self) -> None: """ - Ping to see if it is alive. Successful when action succeeds with no return value and + Ping to see if it is alive. + + Successful when action succeeds with no return value and no timeout or exception raised on the client side. """ pass diff --git a/hololinked/core/zmq/rpc_server.py b/hololinked/core/zmq/rpc_server.py index 9323a8ff..a7968214 100644 --- a/hololinked/core/zmq/rpc_server.py +++ b/hololinked/core/zmq/rpc_server.py @@ -667,8 +667,8 @@ def get_thing_description( """ TM = instance.get_thing_model(ignore_errors=ignore_errors, skip_names=skip_names).json() # type: dict[str, Any] TD = copy.deepcopy(TM) - from ...td import ActionAffordance, EventAffordance, PropertyAffordance - from ...td.forms import Form + from ...metadata.td import ActionAffordance, EventAffordance, PropertyAffordance + from ...metadata.td.forms import Form if protocol.lower() == "inproc": req_rep_socket_address = self.req_rep_server.socket_address diff --git a/hololinked/ddl.py b/hololinked/ddl.py new file mode 100644 index 00000000..a385f81f --- /dev/null +++ b/hololinked/ddl.py @@ -0,0 +1,60 @@ +""" +Dependency definition layer for metadata formats/device description languages. + +Please delay the import of this module as much as possible. +""" + +from dataclasses import dataclass + +from hololinked.core.interfaces import ( + ActionMetadata, + EventMetadata, + InteractionMetadata, + Metadata, + PropertyMetadata, +) +from hololinked.metadata.td import ( # noqa: F401 + ActionAffordance, + EventAffordance, + InteractionAffordance, + PropertyAffordance, + ThingModel, +) +from hololinked.utils import MappableSingleton + + +@dataclass +class MetadataClasses: + """Metadata class for each core component.""" + + thing: type[Metadata] + property: type[PropertyMetadata] + action: type[ActionMetadata] + event: type[EventMetadata] + interaction: type[InteractionMetadata] + + +class MetadataFormats(MappableSingleton): + """Supported metadata formats.""" + + wot = MetadataClasses( + thing=ThingModel, + property=PropertyAffordance, + action=ActionAffordance, + event=EventAffordance, + interaction=InteractionAffordance, + ) + + @classmethod + def get(cls, format: str) -> MetadataClasses: + """ + Get the MetadataClasses for a given format. + + Returns + ------- + MetadataClasses + The metadata classes for the given format, containing the thing, property, action and event classes. + """ + if format.lower() == "wot": + return cls.wot + raise NotImplementedError(f"Metadata format {format} is not supported.") diff --git a/hololinked/td/__init__.py b/hololinked/metadata/td/__init__.py similarity index 72% rename from hololinked/td/__init__.py rename to hololinked/metadata/td/__init__.py index 1c7ef12a..e7aab02c 100644 --- a/hololinked/td/__init__.py +++ b/hololinked/metadata/td/__init__.py @@ -1,3 +1,5 @@ +"""W3C Web of Things based Thing Descriptions (TD) and Models (TM).""" + from .interaction_affordance import ( # noqa: F401 ActionAffordance, EventAffordance, diff --git a/hololinked/td/base.py b/hololinked/metadata/td/base.py similarity index 59% rename from hololinked/td/base.py rename to hololinked/metadata/td/base.py index 580206e6..a690fa82 100644 --- a/hololinked/td/base.py +++ b/hololinked/metadata/td/base.py @@ -1,3 +1,7 @@ +"""Base Schema class for all WoT schema components.""" + +from __future__ import annotations + import inspect from typing import Any, ClassVar @@ -5,16 +9,24 @@ from pydantic import BaseModel -class Schema(BaseModel): +class WoTSchema(BaseModel): """ Base pydantic model for all WoT schema components (as in, parts within the schema). - Call `model_dump` or `json` method to get the JSON representation of the schema. + + Call `model_dump` or `json` method to get the JSON representation of the schema (or the JSON Schema). """ skip_keys: ClassVar = [] # override this to skip some dataclass attributes in the schema def model_dump(self, **kwargs) -> dict[str, Any]: - """Return the JSON representation of the schema""" + """ + Return the JSON representation of the schema. + + Returns + ------- + dict[str, Any] + JSON representation + """ # we need to override this to work with our JSON serializer kwargs["mode"] = "json" kwargs["by_alias"] = True @@ -30,13 +42,29 @@ def model_dump(self, **kwargs) -> dict[str, Any]: ] return super().model_dump(**kwargs) - def json(self) -> dict[str, Any]: - """same as model_dump""" + def json(self) -> dict[str, Any]: # ty: ignore[invalid-method-override] + """ + Same as model_dump. + + Overrides pydantic's base class `json()` method. + + Returns + ------- + dict[str, Any] + JSON representation + """ # noqa: D401 return self.model_dump() @classmethod - def format_doc(cls, doc: str): - """strip tabs, newlines, whitespaces etc. to format the docstring nicely""" + def format_doc(cls, doc: str) -> str: + """ + Strip tabs, newlines, whitespaces etc. to format the docstring nicely. + + Returns + ------- + str + Formatted docstring + """ doc = inspect.cleandoc(doc) # Remove everything after "Parameters\n-----" if present (when using numpydoc) marker = "Parameters\n-----" diff --git a/hololinked/td/data_schema.py b/hololinked/metadata/td/data_schema.py similarity index 82% rename from hololinked/td/data_schema.py rename to hololinked/metadata/td/data_schema.py index b532ec4b..83d73d44 100644 --- a/hololinked/td/data_schema.py +++ b/hololinked/metadata/td/data_schema.py @@ -1,12 +1,15 @@ +"""Implementations of Data Schema.""" + +from __future__ import annotations + from typing import Any, ClassVar, Optional from pydantic import BaseModel, ConfigDict, Field, RootModel from hololinked import JSONSchema - -from ..constants import JSON, JSONSerializable -from ..core import Property -from ..core.properties import ( +from hololinked.constants import JSON, JSONSerializable +from hololinked.core import Property +from hololinked.core.properties import ( Boolean, ClassSelector, Filename, @@ -23,23 +26,22 @@ TypedKeyMappingsDict, TypedList, ) -from ..utils import issubklass -from .base import Schema -from .utils import get_summary +from hololinked.metadata.td.base import WoTSchema +from hololinked.metadata.td.utils import get_summary +from hololinked.utils import issubklass -class DataSchema(Schema): +class DataSchema(WoTSchema): """ - Implements Data Schema, usually used to represent payloads of properties, actions and events in a - WoT Thing Description. + Usually represents payloads of properties, actions and events in a WoT Thing Description. - [Vocabulary Definitions](https://www.w3.org/TR/wot-thing-description11/#sec-data-schema-vocabulary-definition) - [Supported Fields](https://www.w3.org/TR/wot-thing-description11/#data-schema-fields) """ - title: str = None + title: Optional[str] = None titles: Optional[dict[str, str]] = None - description: str = None + description: Optional[str] = None descriptions: Optional[dict[str, str]] = None const: Optional[bool] = None default: Optional[Any] = None @@ -49,7 +51,7 @@ class DataSchema(Schema): format: Optional[str] = None unit: Optional[str] = None type: Optional[str] = None - oneOf: Optional[list[JSON]] = None + oneOf: Optional[list[dict[str, Any]]] = None model_config = ConfigDict(extra="allow") _custom_schema_generators: ClassVar = dict() @@ -58,8 +60,9 @@ def __init__(self): super().__init__() def ds_build_fields_from_property(self, property: Property) -> None: - """Populates schema information from property descriptor object""" - self.title = get_summary(property.doc) + """Populates schema information from property descriptor object.""" + if property.doc: + self.title = get_summary(property.doc) if property.constant: self.const = property.constant if property.readonly: @@ -67,10 +70,9 @@ def ds_build_fields_from_property(self, property: Property) -> None: if property.default is not None: self.default = property.default if property.doc: - self.description = Schema.format_doc(property.doc) + self.description = WoTSchema.format_doc(property.doc) if self.title and self.description.startswith(self.title): - self.description.lstrip(self.title) - self.description.lstrip(".").lstrip() + self.description = self.description.lstrip(self.title).lstrip(".").lstrip() self.title = "" if property.metadata and property.metadata.get("unit", None) is not None: self.unit = property.metadata["unit"] @@ -88,8 +90,20 @@ def ds_build_fields_from_property(self, property: Property) -> None: # you dont know what you are building, whether the data schema or something else when viewed from property affordance def ds_build_from_property(self, property: Property) -> None: """ - Generates the schema specific to the property type, - calls `ds_build_fields_from_property()` after choosing the right type + Generates the schema specific to the property type. + + Calls `ds_build_fields_from_property()` after choosing the right type. + + Parameters + ---------- + property: Property + property descriptor object to generate the schema from + + Raises + ------ + TypeError + if the property type is not supported for schema generation. Custom schema generators need to + be registered for custom defined properties. """ if self._custom_schema_generators.get(property, NotImplemented) is not NotImplemented: data_schema = self._custom_schema_generators[property]() @@ -136,21 +150,27 @@ def ds_build_from_property(self, property: Property) -> None: setattr(self, field_name, field_value) def _move_own_type_to_oneOf(self): - """Move a type to oneOf""" + """Move a type to oneOf, usually when allow None is True.""" pass + def __getitem__(self, item): + if hasattr(self, item): + return getattr(self, item) + else: + raise KeyError(f"{item} is not a valid field of {self.__class__}") + class BooleanSchema(DataSchema): """ - boolean schema - https://www.w3.org/TR/wot-thing-description11/#booleanschema - used by Boolean descriptor + Boolean Schema - https://www.w3.org/TR/wot-thing-description11/#booleanschema. + + Used by Boolean descriptor. """ def __init__(self): super().__init__() - def ds_build_fields_from_property(self, property) -> None: - """Generates the schema""" + def ds_build_fields_from_property(self, property) -> None: # noqa: D102 self.type = "boolean" super().ds_build_fields_from_property(property) @@ -165,8 +185,9 @@ def _move_own_type_to_oneOf(self): class StringSchema(DataSchema): """ - string schema - https://www.w3.org/TR/wot-thing-description11/#stringschema - used by String, Filename, Foldername, Path descriptors + String Schema - https://www.w3.org/TR/wot-thing-description11/#stringschema. + + Used by String, Filename, Foldername, Path descriptors. """ pattern: Optional[str] = None @@ -176,8 +197,7 @@ class StringSchema(DataSchema): def __init__(self): super().__init__() - def ds_build_fields_from_property(self, property) -> None: - """Generates the schema""" + def ds_build_fields_from_property(self, property) -> None: # noqa: D102 self.type = "string" if isinstance(property, String): if property.regex is not None: @@ -199,8 +219,9 @@ def _move_own_type_to_oneOf(self): class NumberSchema(DataSchema): """ - number schema - https://www.w3.org/TR/wot-thing-description11/#numberschema - used by Number and Integer descriptors + Number Schema - https://www.w3.org/TR/wot-thing-description11/#numberschema. + + Used by Number and Integer descriptors. """ minimum: Optional[int | float] = None @@ -212,8 +233,7 @@ class NumberSchema(DataSchema): def __init__(self): super().__init__() - def ds_build_fields_from_property(self, property) -> None: - """Generates the schema""" + def ds_build_fields_from_property(self, property) -> None: # noqa: D102 if isinstance(property, Integer): self.type = "integer" elif isinstance(property, Number): # dont change order - one is subclass of other @@ -249,19 +269,19 @@ def _move_own_type_to_oneOf(self): class ArraySchema(DataSchema): """ - array schema - https://www.w3.org/TR/wot-thing-description11/#arrayschema - Used by list, Tuple, TypedList and TupleSelector + Array Schema - https://www.w3.org/TR/wot-thing-description11/#arrayschema. + + Used by List, Tuple, TypedList and TupleSelector. """ - items: Optional[DataSchema | list[DataSchema] | JSON | JSONSerializable] = None + items: Optional[DataSchema | list[DataSchema | dict[str, Any]] | dict[str, Any]] = None maxItems: Optional[int] = Field(None, ge=0) minItems: Optional[int] = Field(None, ge=0) def __init__(self): super().__init__() - def ds_build_fields_from_property(self, property) -> None: - """Generates the schema""" + def ds_build_fields_from_property(self, property) -> None: # noqa: D102 self.type = "array" self.items = [] if isinstance(property, (List, Tuple, TypedList)) and property.item_type is not None: @@ -305,8 +325,9 @@ def _move_own_type_to_oneOf(self): class ObjectSchema(DataSchema): """ - object schema - https://www.w3.org/TR/wot-thing-description11/#objectschema - Used by TypedDict where the key type must be a string + Object Schema - https://www.w3.org/TR/wot-thing-description11/#objectschema. + + Used by TypedDict where the key type must be a string. """ properties: Optional[JSON] = None @@ -315,11 +336,12 @@ class ObjectSchema(DataSchema): def __init__(self): super().__init__() - def ds_build_fields_from_property(self, property) -> None: - """Generates the schema""" + def ds_build_fields_from_property(self, property) -> None: # noqa: D102 super().ds_build_fields_from_property(property) properties = None required = None + if not self.oneOf: + self.oneOf = [] if hasattr(property, "json_schema"): # Code will not reach here for now as have not implemented schema for typed dictionaries. properties = property.json_schema["properties"] @@ -342,7 +364,8 @@ def ds_build_fields_from_property(self, property) -> None: class SelectorSchema(DataSchema): """ - custom schema to deal with ClassSelector & Selector to fill oneOf field correctly + Custom schema to deal with ClassSelector & Selector to fill oneOf field correctly. + https://www.w3.org/TR/wot-thing-description11/#dataschema """ @@ -356,8 +379,7 @@ class SelectorSchema(DataSchema): def __init__(self): super().__init__() - def ds_build_fields_from_property(self, property) -> None: - """Generates the schema""" + def ds_build_fields_from_property(self, property) -> None: # noqa: D102 self.oneOf = [] if isinstance(property, ClassSelector): if not property.isinstance: @@ -419,7 +441,8 @@ def _move_own_type_to_oneOf(self): class EnumSchema(SelectorSchema): """ - custom schema to fill enum field correctly + Custom schema to fill enum field correctly. + https://www.w3.org/TR/wot-thing-description11/#dataschema """ @@ -428,22 +451,20 @@ class EnumSchema(SelectorSchema): def __init__(self): super().__init__() - def ds_build_fields_from_property(self, property) -> None: - """Generates the schema""" + def ds_build_fields_from_property(self, property) -> None: # noqa: D102 if not isinstance(property, Selector): raise TypeError(f"EnumSchema compatible property is only Selector, not {property.__class__}") self.enum = list(property.objects) super().ds_build_fields_from_property(property) def _move_own_type_to_oneOf(self): + schema: dict[str, Any] = dict() if hasattr(self, "type") and self.type is not None: - schema = dict(type=self.type) + schema["type"] = self.type del self.type elif hasattr(self, "oneOf") and self.oneOf: - schema = dict(oneOf=self.oneOf) + schema["oneOf"] = self.oneOf del self.oneOf - else: - schema = dict() if self.enum is not None: schema["enum"] = self.enum del self.enum diff --git a/hololinked/td/forms.py b/hololinked/metadata/td/forms.py similarity index 67% rename from hololinked/td/forms.py rename to hololinked/metadata/td/forms.py index b28ef0a8..a24065ab 100644 --- a/hololinked/td/forms.py +++ b/hololinked/metadata/td/forms.py @@ -1,15 +1,19 @@ +"""Implementation of Forms.""" + +from __future__ import annotations + from typing import Any, Optional from pydantic import Field -from ..constants import JSON -from .base import Schema +from hololinked.metadata.td.base import WoTSchema -class ExpectedResponse(Schema): +class ExpectedResponse(WoTSchema): """ - Form property. - schema - https://www.w3.org/TR/wot-thing-description11/#expectedresponse + Form field for the expected response of an interaction. + + https://www.w3.org/TR/wot-thing-description11/#expectedresponse """ contentType: str @@ -18,30 +22,33 @@ def __init__(self): super().__init__() -class AdditionalExpectedResponse(Schema): +class AdditionalExpectedResponse(WoTSchema): """ Form field for additional responses which are different from the usual response. - schema - https://www.w3.org/TR/wot-thing-description11/#additionalexpectedresponse + + https://www.w3.org/TR/wot-thing-description11/#additionalexpectedresponse """ success: bool = Field(default=False) contentType: str = Field(default="application/json") - response_schema: Optional[JSON] = Field(default="exception", alias="schema") + # response_schema: Optional[JSON] = Field(default="exception", alias="schema") + # TODO reinstate def __init__(self): super().__init__() -class Form(Schema): +class Form(WoTSchema): """ Form hypermedia. - schema - https://www.w3.org/TR/wot-thing-description11/#form + + https://www.w3.org/TR/wot-thing-description11/#form """ - href: str = None - op: str = None - htv_methodName: str = Field(default=None, alias="htv:methodName") - mqv_topic: str = Field(default=None, alias="mqv:topic") + href: str = "" + op: Optional[str] = None + htv_methodName: Optional[str] = Field(default=None, alias="htv:methodName") + mqv_topic: Optional[str] = Field(default=None, alias="mqv:topic") contentType: Optional[str] = "application/json" additionalResponses: Optional[list[AdditionalExpectedResponse]] = None contentEncoding: Optional[str] = None @@ -78,5 +85,5 @@ def from_TD(cls, form_json: dict[str, Any]) -> "Form": setattr(form, field, form_json[field]) return form - def __str__(self) -> str: + def __str__(self) -> str: # noqa: D105 return f"Form(href={self.href}, op={self.op}, htv_methodName={self.htv_methodName}, contentType={self.contentType})" diff --git a/hololinked/td/interaction_affordance.py b/hololinked/metadata/td/interaction_affordance.py similarity index 61% rename from hololinked/td/interaction_affordance.py rename to hololinked/metadata/td/interaction_affordance.py index b5b12c04..41d0e4ca 100644 --- a/hololinked/td/interaction_affordance.py +++ b/hololinked/metadata/td/interaction_affordance.py @@ -1,26 +1,38 @@ +"""Implementation of Interaction Affordances.""" + +from __future__ import annotations + import copy from enum import Enum -from typing import Any, ClassVar, Optional +from typing import Any, Callable, ClassVar, Optional, Self, cast # noqa: F401 from pydantic import BaseModel, ConfigDict, RootModel -from ..constants import JSON, ResourceTypes -from ..core.actions import Action -from ..core.events import Event -from ..core.property import Property -from ..core.thing import Thing, ThingMeta -from ..utils import issubklass -from .base import Schema -from .data_schema import DataSchema -from .forms import Form -from .pydantic_extensions import type_to_dataschema -from .utils import get_summary +from hololinked.constants import JSON, ResourceTypes +from hololinked.core.interfaces import ( + ActionMetadata, + EventMetadata, + InteractionMetadata, + PropertyMetadata, +) +from hololinked.metadata.td.base import WoTSchema +from hololinked.metadata.td.data_schema import DataSchema +from hololinked.metadata.td.forms import Form +from hololinked.metadata.td.pydantic_extensions import type_to_dataschema +from hololinked.metadata.td.utils import get_summary +from hololinked.utils import issubklass + +from hololinked.core.actions import Action # isort: skip +from hololinked.core.events import Event # isort: skip +from hololinked.core.property import Property # isort: skip +from hololinked.core.thing import Thing, ThingMeta # isort: skip -class InteractionAffordance(Schema): + +class InteractionAffordance(WoTSchema, InteractionMetadata): """ - Implements schema information common to all interaction affordances. + Implements schema fields common to all interaction affordances, property, action or event. [Specification Definitions](https://www.w3.org/TR/wot-thing-description11/#interactionaffordance)
[UML Diagram](https://docs.hololinked.dev/UML/PDF/InteractionAffordance.pdf)
@@ -33,28 +45,25 @@ class InteractionAffordance(Schema): forms: Optional[list[Form]] = None # uri variables - _custom_schema_generators: ClassVar = dict() + _custom_metadata_generators: ClassVar = dict() model_config = ConfigDict(extra="allow") - def __init__(self): - super().__init__() - self._name = None - self._objekt = None - self._thing_id = None - self._thing_cls = None - self._owner = None - - @property - def what(self) -> Enum: - """Whether it is a property, action or event""" - raise NotImplementedError("Unknown interaction affordance - implement in subclass of InteractionAffordance") - @property def owner(self) -> Thing: """ Owning `Thing` instance or `Thing` class of the interaction affordance. - Depends on how this object was created, whether using an instance or a class. + + Depending on how this object was created, returns either an instance or a class. + + Raises + ------ + AttributeError + If the owner is not set, which means this interaction is not properly bound to a `Thing` + instance or class. Dont explicitly instantiate this class without the context of a `Thing` instance + or class, or at least dont prematurely access this attribute. """ + if self._owner is None: + raise AttributeError("owner is not set for this affordance") return self._owner @owner.setter @@ -75,12 +84,22 @@ def owner(self, value): @property def objekt(self) -> Property | Action | Event: - """Object instance of the interaction affordance - `Property`, `Action` or `Event`""" + """ + Object instance of the interaction affordance, instance of `Property`, `Action` or `Event`. + + Raises + ------ + AttributeError + If the metadata is not bound to any interaction object. + Use `Thing..to_metadata()` only method to generate this metadata + from a property object or similarly for action and event. + """ + if self._objekt is None: + raise AttributeError("Metadata bound to unknown object (property, action or event).") return self._objekt @objekt.setter def objekt(self, value: Property | Action | Event) -> None: - """Set the object instance of the interaction affordance - `Property`, `Action` or `Event`""" if self._objekt is not None: raise ValueError( f"object is already set for this {self.what.name.lower()} affordance, " @@ -100,28 +119,9 @@ def objekt(self, value: Property | Action | Event) -> None: self._objekt = value self._name = value.name - @property - def name(self) -> str: - """Name of the interaction affordance used as key in the TD""" - return self._name - - @property - def thing_id(self) -> str: - """ID of the `Thing` instance owning the interaction affordance, if available, otherwise None""" - return self._thing_id - - @property - def thing_cls(self) -> ThingMeta: - """`Thing` class owning the interaction affordance""" - return self._thing_cls - - def build(self) -> None: - """Populate the fields of the schema for the specific interaction affordance""" - 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 ---------- @@ -168,46 +168,29 @@ def pop_form(self, op: str, default: Any = None) -> Form: return default @classmethod - def generate( - cls, - interaction: Property | Action | Event, - owner: Thing, - ) -> "PropertyAffordance | ActionAffordance | EventAffordance": - """ - 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 - to create an instance. Although, it internally calls build(), and some additional steps are included. - - Parameters - ---------- - interaction: Property | Action | Event - interaction object for which the schema is to be built - owner: Thing - owner of the interaction affordance - - Returns - ------- - "PropertyAffordance | ActionAffordance | EventAffordance" - """ - raise NotImplementedError("generate_schema must be implemented in subclass of InteractionAffordance") - - @classmethod - def from_TD(cls, name: str, TD: JSON) -> "PropertyAffordance | ActionAffordance | EventAffordance": + def from_metadata(cls, name: str, metadata: dict[str, Any]) -> Self: """ Populate the schema from the TD and return it as an instance of this class. + You need to supply both the TD and the name of the affordance, because the affordance definition in the TD + does not include its name and determine its type. + Parameters ---------- name: str name of the interaction affordance used as key in the TD - TD: JSON + metadata: dict[str, Any] Thing Description JSON dictionary (the entire one, not just the component of the affordance) Returns ------- - "PropertyAffordance | ActionAffordance | EventAffordance" + PropertyAffordance | ActionAffordance | EventAffordance + Instance of this class with the schema fields populated from the TD. + + Raises + ------ + ValueError + If the affordance type cannot be determined from the TD. """ if cls == PropertyAffordance: affordance_name = "properties" @@ -217,7 +200,7 @@ def from_TD(cls, name: str, TD: JSON) -> "PropertyAffordance | ActionAffordance affordance_name = "events" else: raise ValueError(f"unknown affordance type - {cls}, cannot create object from TD") - affordance_json = TD[affordance_name][name] # type: dict[str, JSON] + affordance_json = metadata[affordance_name][name] # type: dict[str, JSON] affordance = cls() for field in cls.model_fields: if field in affordance_json: @@ -226,74 +209,71 @@ def from_TD(cls, name: str, TD: JSON) -> "PropertyAffordance | ActionAffordance else: setattr(affordance, field, affordance_json[field]) affordance._name = name - affordance._thing_id = TD["id"] + affordance._thing_id = metadata["id"] return affordance + @classmethod + def from_TD(self, name: str, TD: JSON) -> Self: + """ + Populate the schema from the TD and return it as an instance of this class. + + You need to supply both the TD and the name of the affordance, because the affordance definition in the TD + does not include its name and determine its type. + + Parameters + ---------- + name: str + name of the interaction affordance used as key in the TD + TD: JSON + Thing Description JSON dictionary (the entire one, not just the component of the affordance) + + Returns + ------- + PropertyAffordance | ActionAffordance | EventAffordance + Instance of this class with the schema fields populated from the TD. + + Raises + ------ + ValueError + If the affordance type cannot be determined from the TD. + """ + return self.from_metadata(name, TD) + @classmethod def register_descriptor( cls, descriptor: Property | Action | Event, - schema_generator: "InteractionAffordance", + metadata_generator: type[InteractionAffordance] | type[InteractionMetadata], ) -> None: - """Register a custom schema generator for a descriptor""" + """ + Register a custom schema generator for a descriptor. + + Parameters + ---------- + descriptor: Property | Action | Event + The descriptor class + metadata_generator: `InteractionAffordance` + `InteractionAffordance` subclass that implements the custom schema generation logic for the descriptor. + Either override the `from_descriptor()` method or the `build()` method. + + Raises + ------ + TypeError + If the descriptor is not an instance of `Property`, `Action` or `Event`, or if the schema generator is not an + instance of `InteractionAffordance`. + """ if not isinstance(descriptor, (Property, Action, Event)): raise TypeError( "custom schema generator can also be registered for Property." + f" Given type {type(descriptor)}" ) - if not isinstance(schema_generator, InteractionAffordance): + if not isinstance(metadata_generator, InteractionAffordance): raise TypeError( - "schema generator for Property must be subclass of PropertyAfforance. " - + f"Given type {type(schema_generator)}" + "metadata generator for Property must be subclass of PropertyAfforance. " + + f"Given type {type(metadata_generator)}" ) - InteractionAffordance._custom_schema_generators[descriptor] = schema_generator - - def build_non_compliant_metadata(self) -> None: - """If by chance, there is additional non standard metadata to be added, they can be added here""" - pass + InteractionAffordance._custom_metadata_generators[descriptor] = metadata_generator - def override_defaults(self, **kwargs): - """ - Override default values with provided keyword arguments, especially thing_id, owner name, object name etc. - Any logic to trigger side effects while setting those values should be handled here. - """ - for key, value in kwargs.items(): - if key == "name": - self._name = value - elif key == "thing_id": - self._thing_id = value - elif key == "owner": - self._owner = value - elif key == "thing_cls": - self._thing_cls = value - elif hasattr(self, key) or key in self.model_fields: - setattr(self, key, value) - - def __hash__(self): - return hash( - self.thing_id if self.thing_id else "" + self.thing_cls.__name__ if self.thing_cls else "" + self.name - ) - - def __str__(self): - if self.thing_cls: - return f"{self.__class__.__name__}({self.thing_cls.__name__}({self.thing_id}).{self.name})" - return f"{self.__class__.__name__}({self.name} of {self.thing_id})" - - def __eq__(self, value): - if not isinstance(value, self.__class__): - return False - if self.thing_id is None or value.thing_id is None: - if self.owner is None or value.owner is None: - # cannot determine anymore - return False - # basically you need to have an owner for the interaction affordance - # and a name to determine its equality. We should never check the owner - # by the name, but by the object, otherwise the equality cannot be gauranteed - if (self.owner == value.owner or self.thing_cls == value.thing_cls) and self.name == value.name: - return True - return False - return self.thing_id == value.thing_id and self.name == value.name - - def __deepcopy__(self, memo): + def __deepcopy__(self, memo): # noqa: D105 if self.__class__ == PropertyAffordance: result = PropertyAffordance() elif self.__class__ == ActionAffordance: @@ -306,19 +286,19 @@ def __deepcopy__(self, memo): setattr(result, k, copy.deepcopy(v, memo)) return result - def __getstate__(self): - state = self.__dict__.copy() - # Remove possibly unpicklable entries - if "_owner" in state: - del state["_owner"] - if "_thing_cls" in state: - del state["_thing_cls"] - if "_objekt" in state: - del state["_objekt"] - return state + def json(self) -> dict[str, Any]: + """ + Return the JSON representation. + + Returns + ------- + dict[str, Any] + JSON representation + """ + return WoTSchema.json(self) -class PropertyAffordance(DataSchema, InteractionAffordance): +class PropertyAffordance(DataSchema, InteractionAffordance, PropertyMetadata): """ Implements property affordance schema from `Property` descriptor object. @@ -333,17 +313,17 @@ def __init__(self): super().__init__() @property - def what(self) -> Enum: + def what(self) -> Enum: # noqa: D102 return ResourceTypes.PROPERTY - def build(self) -> None: - property = self.objekt + def build(self) -> None: # noqa: D102 + property = cast(Property, self.objekt) self.ds_build_from_property(property) if property.observable: self.observable = property.observable @classmethod - def generate(cls, property, owner=None): + def from_descriptor(cls, property: Property, owner: Thing | ThingMeta) -> "PropertyAffordance": # noqa: D102 if not isinstance(property, Property): raise TypeError(f"property must be instance of Property, given type {type(property)}") affordance = PropertyAffordance() @@ -354,7 +334,7 @@ def generate(cls, property, owner=None): return affordance -class ActionAffordance(InteractionAffordance): +class ActionAffordance(InteractionAffordance, ActionMetadata): """ creates action affordance schema from actions (or methods). @@ -363,21 +343,21 @@ class ActionAffordance(InteractionAffordance): """ # [Supported Fields]()
- input: JSON = None - output: JSON = None - safe: bool = None - idempotent: bool = None - synchronous: bool = None + input: Optional[JSON] = None + output: Optional[JSON] = None + safe: Optional[bool] = None + idempotent: Optional[bool] = None + synchronous: Optional[bool] = None def __init__(self): super().__init__() @property - def what(self): + def what(self): # noqa: D102 return ResourceTypes.ACTION - def build(self) -> None: - action = self.objekt # type: Action + def build(self) -> None: # noqa: D102 + action = cast(Action, self.objekt) if action.obj.__doc__: title = get_summary(action.obj.__doc__) description = self.format_doc(action.obj.__doc__) @@ -419,7 +399,7 @@ def build(self) -> None: self.safe = action.execution_info.safe @classmethod - def generate(cls, action: Action, owner, **kwargs) -> "ActionAffordance": + def from_descriptor(cls, action: Action, owner: Thing | ThingMeta, **kwargs) -> "ActionAffordance": # noqa: D102 if not isinstance(action, Action): raise TypeError(f"action must be instance of Action, given type {type(action)}") affordance = ActionAffordance() @@ -430,7 +410,7 @@ def generate(cls, action: Action, owner, **kwargs) -> "ActionAffordance": return affordance -class EventAffordance(InteractionAffordance): +class EventAffordance(InteractionAffordance, EventMetadata): """ creates event affordance schema from events. @@ -439,21 +419,22 @@ class EventAffordance(InteractionAffordance): """ # [Supported Fields]()
- subscription: str = None - data: JSON = None + subscription: Optional[str] = None + data: Optional[JSON] = None def __init__(self): super().__init__() @property - def what(self): + def what(self): # noqa: D102 return ResourceTypes.EVENT - def build(self) -> None: - event = self.objekt # type: Event - if event.__doc__: - title = get_summary(event.doc) - description = self.format_doc(event.doc) + def build(self) -> None: # noqa: D102 + event = cast(Event, self.objekt) + doc = event.__doc__ or event.doc + if doc: + title = get_summary(doc) + description = self.format_doc(doc) if title and not description.startswith(title): self.title = title self.description = description @@ -468,7 +449,7 @@ def build(self) -> None: raise ValueError(f"unknown schema definition for event data, given type: {type(event.schema)}") @classmethod - def generate(cls, event: Event, owner, **kwargs) -> "EventAffordance": + def from_descriptor(cls, event: Event, owner: Thing | ThingMeta, **kwargs) -> "EventAffordance": # noqa: D102 if not isinstance(event, Event): raise TypeError(f"event must be instance of Event, given type {type(event)}") affordance = EventAffordance() diff --git a/hololinked/metadata/td/metadata.py b/hololinked/metadata/td/metadata.py new file mode 100644 index 00000000..2cd8ecc5 --- /dev/null +++ b/hololinked/metadata/td/metadata.py @@ -0,0 +1,33 @@ +"""Include a general metadata like links, version info, etc. here.""" + +from __future__ import annotations + +from typing import Optional + +from pydantic import Field + +from hololinked.metadata.td.base import WoTSchema + + +class Link(WoTSchema): + """ + Impelements the Link schema for linking to other resources. + + https://www.w3.org/TR/wot-thing-description11/#link + """ + + href: str + anchor: Optional[str] + rel: Optional[str] + type: Optional[str] = Field(default="application/json") + + +class VersionInfo(WoTSchema): + """ + Represents version info. + + https://www.w3.org/TR/wot-thing-description11/#versioninfo + """ + + instance: str + model: str diff --git a/hololinked/td/pydantic_extensions.py b/hololinked/metadata/td/pydantic_extensions.py similarity index 57% rename from hololinked/td/pydantic_extensions.py rename to hololinked/metadata/td/pydantic_extensions.py index 66f34481..028205ea 100644 --- a/hololinked/td/pydantic_extensions.py +++ b/hololinked/metadata/td/pydantic_extensions.py @@ -1,3 +1,32 @@ +""" +pydantic specific utility functions for the TD module. + +This module is largely copied from LabThings fast API. +Copyright belongs to LabThings, Richard Bowman and developers, licensed under MIT License. + +MIT License + +Copyright (c) 2024 Richard William Bowman + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +""" + from __future__ import annotations from typing import Any, Dict, List, Mapping, Optional, Sequence, Union @@ -21,29 +50,58 @@ def is_a_reference(d: JSONSchemaType) -> bool: """ - Return True if a JSONSchema dict is a reference + Return True if a JSONSchema dict is a reference. JSON Schema references are one-element dictionaries with a single key, `$ref`. `pydantic` sometimes breaks this rule and so I don't check that it's a single key. + + Parameters + ---------- + d: JSONSchemaType + The JSONSchema dict to check + + Returns + ------- + bool + True if the dict is a reference, False otherwise """ return "$ref" in d -def look_up_reference(reference: str, d: JSONSchemaType) -> JSONSchemaType: +def look_up_reference(reference: str, d: dict[str, Any]) -> dict[str, Any]: """ - Look up a reference in a JSONSchema + Look up a reference in a JSONSchema. This first asserts the reference is local (i.e. starts with # so it's relative to the current file), then looks up each path component in turn. + + Parameters + ---------- + reference: str + The reference to look up, e.g. "#/components/schemas/MySchema" + d: JSONSchemaType + The JSONSchema dict to look up the reference in + + Returns + ------- + JSONSchema + The JSONSchema dict that the reference points to + + Raises + ------ + NotImplementedError + If the reference is not local (i.e. does not start with #) + KeyError + If the reference cannot be found in the JSONSchema """ if not reference.startswith("#/"): raise NotImplementedError( "Built-in resolver can only dereference internal JSON references (i.e. starting with #)." ) try: - resolved: JSONSchemaType = d + resolved: dict[str, Any] = d for key in reference[2:].split("/"): resolved = resolved[key] return resolved @@ -52,12 +110,36 @@ def look_up_reference(reference: str, d: JSONSchemaType) -> JSONSchemaType: def is_an_object(d: JSONSchemaType) -> bool: - """Determine whether a JSON schema dict is an object""" + """ + Determine whether a JSON schema dict is an object. + + Parameters + ---------- + d : JSONSchemaType + The JSONSchema dict to check. + + Returns + ------- + bool + True if the dict represents an object type, False otherwise. + """ return "type" in d and d["type"] == "object" def convert_object(d: JSONSchemaType) -> JSONSchemaType: - """Convert an object from JSONSchemaType to Thing Description""" + """ + Convert an object from JSONSchema to Thing Description. + + Parameters + ---------- + d : JSONSchemaType + The JSONSchema dict representing an object. + + Returns + ------- + JSONSchema + The converted JSONSchema dict compatible with Thing Description. + """ out: JSONSchemaType = d.copy() # AdditionalProperties is not supported by Thing Description, and it is ambiguous # whether this implies it's false or absent. I will, for now, ignore it, so we @@ -69,12 +151,22 @@ def convert_object(d: JSONSchemaType) -> JSONSchemaType: def convert_anyof(d: JSONSchemaType) -> JSONSchemaType: """ - Convert the anyof key to oneof + Convert the anyof key to oneof. JSONSchema makes a distinction between "anyof" and "oneof", where the former means "any of these fields can be present" and the latter means "exactly one of these fields must be present". Thing Description does not have this distinction, so we convert anyof to oneof. + + Parameters + ---------- + d : JSONSchemaType + The JSONSchema dict to convert. + + Returns + ------- + JSONSchema + The converted JSONSchema dict with ``anyOf`` replaced by ``oneOf``. """ if "anyOf" not in d: return d @@ -86,7 +178,7 @@ def convert_anyof(d: JSONSchemaType) -> JSONSchemaType: def convert_prefixitems(d: JSONSchemaType) -> JSONSchemaType: """ - Convert the prefixitems key to items + Convert the prefixitems key to items. JSONSchema 2019 (as used by thing description) used `items` with a list of values in the same way that JSONSchema @@ -98,6 +190,21 @@ def convert_prefixitems(d: JSONSchemaType) -> JSONSchemaType: additional items, and we raise a ValueError if that happens. This behaviour may be relaxed in the future. + + Parameters + ---------- + d : JSONSchemaType + The JSONSchema dict to convert. + + Returns + ------- + JSONSchemaType + The converted JSONSchema dict with ``prefixItems`` replaced by ``items``. + + Raises + ------ + ValueError + If the ``items`` key already exists in the schema, as it would be overwritten. """ if "prefixItems" not in d: return d @@ -110,10 +217,22 @@ def convert_prefixitems(d: JSONSchemaType) -> JSONSchemaType: def convert_additionalproperties(d: JSONSchemaType) -> JSONSchemaType: - """Move additionalProperties into properties, or remove it""" + """ + Move additionalProperties into properties, or remove it. + + Parameters + ---------- + d : JSONSchemaType + The JSONSchema dict to convert. + + Returns + ------- + JSONSchemaType + The converted JSONSchema dict with ``additionalProperties`` moved or removed. + """ if "additionalProperties" not in d: return d - out: JSONSchemaType = d.copy() + out: dict[str, Any] = d.copy() # type if "properties" in out and "additionalProperties" not in out["properties"]: out["properties"]["additionalProperties"] = out["additionalProperties"] del out["additionalProperties"] @@ -121,19 +240,33 @@ def convert_additionalproperties(d: JSONSchemaType) -> JSONSchemaType: def check_recursion(depth: int, limit: int): - """Check the recursion count is less than the limit""" + """ + Check the recursion count is less than the limit. + + Parameters + ---------- + depth : int + The current recursion depth. + limit : int + The maximum allowed recursion depth. + + Raises + ------ + ValueError + If the recursion depth exceeds the limit. + """ if depth > limit: raise ValueError(f"Recursion depth of {limit} exceeded - perhaps there is a circular reference?") def jsonschema_to_dataschema( - d: JSONSchemaType, - root_schema: Optional[JSONSchemaType] = None, + d: dict[str, Any], + root_schema: Optional[dict[str, Any]] = None, recursion_depth: int = 0, recursion_limit: int = 99, -) -> JSONSchemaType: +) -> dict[str, Any]: """ - Remove references and change field formats + Remove references and change field formats. JSONSchema allows schemas to be replaced with `{"$ref": "#/path/to/schema"}`. Thing Description does not allow this. `dereference_jsonschema_dict` takes a @@ -146,6 +279,22 @@ def jsonschema_to_dataschema( `DataSchema` objects. This function does not yet do that conversion. This generates a copy of the document, to avoid messing up `pydantic`'s cache. + + Parameters + ---------- + d : JSONSchemaType + The JSONSchema dict to convert. + root_schema : JSONSchemaType, optional + The root JSONSchema document used to resolve ``$ref`` references. Defaults to ``d``. + recursion_depth : int, optional + The current recursion depth, used to detect circular references. Defaults to 0. + recursion_limit : int, optional + The maximum allowed recursion depth. Defaults to 99. + + Returns + ------- + JSONSchemaType + The converted JSONSchema dict compatible with Thing Description. """ root_schema = root_schema or d check_recursion(recursion_depth, recursion_limit) @@ -170,7 +319,7 @@ def jsonschema_to_dataschema( "recursion_depth": recursion_depth + 1, "recursion_limit": recursion_limit, } - output: JSONSchemaType = {} + output: dict[str, Any] = {} for k, v in d.items(): if isinstance(v, dict): # Any items that are Mappings (i.e. sub-dictionaries) must be recursed into @@ -178,7 +327,7 @@ def jsonschema_to_dataschema( elif isinstance(v, Sequence) and len(v) > 0 and isinstance(v[0], Mapping): # We can also have lists of mappings (i.e. Array[DataSchema]), so we # recurse into these. - output[k] = [jsonschema_to_dataschema(item, **rkwargs) for item in v] + output[k] = [jsonschema_to_dataschema(item, **rkwargs) for item in v if isinstance(item, dict)] else: output[k] = v return output @@ -186,7 +335,7 @@ def jsonschema_to_dataschema( def type_to_dataschema(t: Union[type, BaseModel], **kwargs) -> dict: """ - Convert a Python type to a Thing Description DataSchema + Convert a Python type to a Thing Description DataSchema. This makes use of pydantic's `schema_of` function to create a json schema, then applies some fixes to make a DataSchema @@ -197,6 +346,19 @@ def type_to_dataschema(t: Union[type, BaseModel], **kwargs) -> dict: and will override the fields generated from the type that is passed in. Typically you'll want to use this for the `title` field. + + Parameters + ---------- + t: type or BaseModel + The Python type or pydantic model to convert. + **kwargs: dict[str, Any] + Additional fields to merge into the resulting DataSchema, overriding any + auto-generated values. + + Returns + ------- + dict + The Thing Description DataSchema representation of the given type. """ if isinstance(t, BaseModel): json_schema = t.model_json_schema(schema_generator=GenerateJsonSchemaWithoutDefaultTitles) @@ -204,7 +366,7 @@ def type_to_dataschema(t: Union[type, BaseModel], **kwargs) -> dict: json_schema = TypeAdapter(t).json_schema(schema_generator=GenerateJsonSchemaWithoutDefaultTitles) if "title" in json_schema: # Remove the title if it was autogenerated from the class name - if json_schema["title"] == t.__name__: + if isinstance(t, type) and json_schema["title"] == t.__name__: del json_schema["title"] schema_dict = jsonschema_to_dataschema(json_schema) # Definitions of referenced ($ref) schemas are put in a @@ -220,10 +382,24 @@ def type_to_dataschema(t: Union[type, BaseModel], **kwargs) -> dict: class GenerateJsonSchemaWithoutDefaultTitles(GenerateJsonSchema): - """Drops autogenerated titles from JSON Schema""" + """Drops autogenerated titles from JSON Schema.""" # https://stackoverflow.com/questions/78679812/pydantic-v2-to-json-schema-translation-how-to-suppress-autogeneration-of-title def field_title_should_be_set(self, schema: CoreSchemaOrField) -> bool: + """ + Return False for core schemas to suppress autogenerated field titles. + + Parameters + ---------- + schema: CoreSchemaOrField + The pydantic core schema or field schema being evaluated. + + Returns + ------- + bool + False if the schema is a core schema and the parent would set a title, + otherwise the parent class result. + """ return_value = super().field_title_should_be_set(schema) if return_value and is_core_schema(schema): return False diff --git a/hololinked/td/security_definitions.py b/hololinked/metadata/td/security_definitions.py similarity index 57% rename from hololinked/td/security_definitions.py rename to hololinked/metadata/td/security_definitions.py index 68ab1018..bce19b76 100644 --- a/hololinked/td/security_definitions.py +++ b/hololinked/metadata/td/security_definitions.py @@ -1,18 +1,23 @@ +"""Implements security scheme definitions for the TD.""" + +from __future__ import annotations + from typing import Optional from pydantic import Field -from .base import Schema +from hololinked.metadata.td.base import WoTSchema -class SecurityScheme(Schema): +class SecurityScheme(WoTSchema): """ - Represents a security scheme. - schema - https://www.w3.org/TR/wot-thing-description11/#sec-security-vocabulary-definition + Subclass from here to implement Security Scheme metadata. + + https://www.w3.org/TR/wot-thing-description11/#sec-security-vocabulary-definition """ - scheme: str = None - description: str = None + scheme: Optional[str] = None + description: Optional[str] = None descriptions: Optional[dict[str, str]] = None proxy: Optional[str] = None @@ -20,47 +25,52 @@ def __init__(self): super().__init__() def build(self): + """Populate the security scheme metadata.""" raise NotImplementedError("Please implement specific security scheme builders") class NoSecurityScheme(SecurityScheme): - """No Security Scheme""" + """No Security Scheme.""" - def build(self): + def build(self): # noqa: D102 self.scheme = "nosec" self.description = "currently no security scheme supported" class BasicSecurityScheme(SecurityScheme): - """Basic Security Scheme, username and password""" + """Basic Security Scheme - username and password.""" in_: str = Field(default="header", alias="in") - def build(self): + def build(self): # noqa: D102 self.scheme = "basic" self.description = "HTTP Basic Authentication" self.in_ = "header" class APIKeySecurityScheme(SecurityScheme): - """API Key Security Scheme""" + """API Key Security Scheme.""" in_: str = Field(default="header", alias="in") - def build(self): + def build(self): # noqa: D102 self.scheme = "apikey" self.description = "API Key Authentication" self.in_ = "header" class OIDCSecurityScheme(SecurityScheme): - """OIDC Security Scheme""" + """OIDC Security Scheme.""" scheme: str = "oauth2" token: str = "" scopes: list[str] = Field(default_factory=list) - def build(self, token_url: str, scopes: list[str] | None = ["openid"]): + def build( # noqa: D102 + self, + token_url: str, + scopes: list[str] | None = ["openid"], + ) -> None: # ty: ignore[invalid-method-override] self.description = "OpenID Connect Authentication" self.token = token_url if scopes is not None: diff --git a/hololinked/td/tm.py b/hololinked/metadata/td/tm.py similarity index 52% rename from hololinked/td/tm.py rename to hololinked/metadata/td/tm.py index 42a5de7b..fa338c75 100644 --- a/hololinked/td/tm.py +++ b/hololinked/metadata/td/tm.py @@ -1,31 +1,42 @@ +""" +Implemetation of Thing Model. + +Thing Descriptions are always generated by the specific protocols. +""" + +from __future__ import annotations + +from collections.abc import ItemsView from typing import Any, Optional from pydantic import ConfigDict, Field -from ..core import Thing -from ..core.state_machine import BoundFSM -from .base import Schema -from .data_schema import DataSchema -from .interaction_affordance import ( +from hololinked.core.interfaces import Metadata +from hololinked.metadata.td.base import WoTSchema +from hololinked.metadata.td.data_schema import DataSchema +from hololinked.metadata.td.interaction_affordance import ( ActionAffordance, EventAffordance, PropertyAffordance, ) -from .metadata import VersionInfo +from hololinked.metadata.td.metadata import VersionInfo + + +from hololinked.core import Thing # noqa # isort: skip -class ThingModel(Schema): +class ThingModel(WoTSchema, Metadata): """ - Thing Model as per W3C WoT Thing Description v1.1 + Thing Model as per W3C WoT Thing Description v1.1. - [Specification](https://www.w3.org/TR/wot-thing-description11/)
- [UML Diagram](https://docs.hololinked.dev/UML/PDF/ThingModel.pdf)
+ - [Specification](https://www.w3.org/TR/wot-thing-description11/) + - [UML Diagram](https://docs.hololinked.dev/UML/PDF/ThingModel.pdf) """ context: list[str | dict[str, str]] = Field(["https://www.w3.org/2022/wot/td/v1.1"], alias="@context") type: Optional[str | list[str]] = None - id: str = None - title: str = None + id: Optional[str] = None + title: Optional[str] = None description: Optional[str] = None version: Optional[VersionInfo] = None created: Optional[str] = None @@ -40,25 +51,42 @@ class ThingModel(Schema): def __init__( self, - instance: "Thing", + thing: Thing | None = None, allow_loose_schema: Optional[bool] = False, ignore_errors: bool = False, skip_names: Optional[list[str]] = [], ) -> None: - super().__init__() - self.instance = instance + WoTSchema.__init__(self) + Metadata.__init__( + self, + thing=thing, + ignore_errors=ignore_errors, + skip_names=skip_names, + ) self.allow_loose_schema = allow_loose_schema - self.ignore_errors = ignore_errors - self.skip_names = skip_names or [] - def generate(self) -> "ThingModel": - """populate the thing model""" - self.id = self.instance.id - self.title = self.instance.__class__.__name__ + def generate(self) -> ThingModel: + """ + Populate the thing model. + + Returns + ------- + ThingModel + + Raises + ------ + ValueError + If the thing instance is not attached to the thing model. Usually, when API first approach is used, + the thing instance could not yet be instantiated. This method is not to be used at that time. + """ + if self.thing is None: + raise ValueError("Thing Model instance not attached to a Thing instance to generate metadata.") + self.id = self.thing.id + self.title = self.thing.__class__.__name__ self.context = ["https://www.w3.org/2022/wot/td/v1.1"] # default value of context is not being picked up although we only use exclude_unset=True - if self.instance.__doc__: - self.description = Schema.format_doc(self.instance.__doc__) + if self.thing.__doc__: + self.description = WoTSchema.format_doc(self.thing.__doc__) self.properties = dict() self.actions = dict() self.events = dict() @@ -66,7 +94,7 @@ def generate(self) -> "ThingModel": return self def produce(self) -> Thing: - """produce a Thing instance from the Thing Model, not implemented yet""" + """Produce a Thing instance from the Thing Model, not implemented yet.""" raise NotImplementedError("This will be implemented in a future release for an API first approach") # not the best code and logic, but works for now @@ -87,34 +115,48 @@ def produce(self) -> Thing: """list of event names to skip when generating the TD""" def add_interaction_affordances(self) -> None: - """add interaction affordances to thing model""" - for affordance, items, affordance_cls, skip_list in [ - ["properties", self.instance.properties.remote_objects.items(), PropertyAffordance, self.skip_properties], - ["actions", self.instance.actions.descriptors.items(), ActionAffordance, self.skip_actions], - ["events", self.instance.events.plain.items(), EventAffordance, self.skip_events], - ]: + """ + Add interaction affordances to the thing model. + + Raises + ------ + ValueError + If the thing instance is not attached to the thing model. Usually, when API first approach is used, + the thing instance could not yet be instantiated. This method is not to be used at that time. + """ + if self.thing is None: + raise ValueError("Thing Model instance not attached to a Thing instance to generate metadata.") + affordances: list[ + tuple[str, ItemsView[str, Any], type[PropertyAffordance | ActionAffordance | EventAffordance], list[str]] + ] = [ + ("properties", self.thing.properties.remote_objects.items(), PropertyAffordance, self.skip_properties), + ("actions", self.thing.actions.descriptors.items(), ActionAffordance, self.skip_actions), + ("events", self.thing.events.plain.items(), EventAffordance, self.skip_events), + ] + for affordance, items, affordance_cls, skip_list in affordances: for name, obj in items: if name in skip_list or name in self.skip_names: continue - if ( - name == "state" - and affordance == "properties" - and ( - not hasattr(self.instance, "state_machine") - or not isinstance(self.instance.state_machine, BoundFSM) - ) - ): + if name == "state" and affordance == "properties" and not hasattr(self.thing, "state_machine"): continue try: affordance_dict = getattr(self, affordance) - affordance_dict[name] = affordance_cls.generate(obj, self.instance) + affordance_dict[name] = affordance_cls.from_descriptor(obj, self.thing) except Exception as ex: if not self.ignore_errors: raise ex from None - self.instance.logger.error(f"Error while generating schema for {name} - {ex}") + if hasattr(self.thing, "logger"): + self.thing.logger.error(f"Error while generating schema for {name} - {ex}") def model_dump(self, **kwargs) -> dict[str, Any]: - """Return the JSON representation of the schema""" + """ + Return the JSON representation. + + Returns + ------- + dict[str, Any] + JSON representation + """ def dump_value(value): nonlocal kwargs diff --git a/hololinked/td/utils.py b/hololinked/metadata/td/utils.py similarity index 59% rename from hololinked/td/utils.py rename to hololinked/metadata/td/utils.py index 6d464ab0..7a38c071 100644 --- a/hololinked/td/utils.py +++ b/hololinked/metadata/td/utils.py @@ -1,9 +1,11 @@ -from typing import Optional +"""utility functions for the TD module.""" +from __future__ import annotations -def get_summary(docs: str) -> Optional[str]: + +def get_summary(docs: str) -> str: """ - Return the first line of the dosctring of an object + Return the first line of the docstring of an object. Parameters ---------- diff --git a/hololinked/server/http/controllers.py b/hololinked/server/http/controllers.py index b978c80f..84cb6bba 100644 --- a/hololinked/server/http/controllers.py +++ b/hololinked/server/http/controllers.py @@ -20,7 +20,7 @@ default_thing_execution_context, ) from ...core.zmq.payloads import PreserializedData, SerializableData -from ...td import ( +from ...metadata.td import ( ActionAffordance, EventAffordance, InteractionAffordance, diff --git a/hololinked/server/http/server.py b/hololinked/server/http/server.py index 15fd31f0..423958e5 100644 --- a/hololinked/server/http/server.py +++ b/hololinked/server/http/server.py @@ -18,10 +18,10 @@ from ...core.property import Property from ...core.thing import Thing, ThingMeta from ...core.zmq.brokers import MessageMappedZMQClientPool +from ...metadata.td import ActionAffordance, EventAffordance, PropertyAffordance # from tornado_http2.server import Server as TornadoHTTP2Server from ...param.parameters import ClassSelector, IPAddress -from ...td import ActionAffordance, EventAffordance, PropertyAffordance from ...utils import ( get_current_async_loop, issubklass, @@ -176,7 +176,7 @@ def __init__( self.add_things(*(things or [])) def setup(self) -> None: - """check if all the requirements are met before starting the server, auto invoked by listen()""" + """Check if all the requirements are met before starting the server, auto invoked by listen()""" # Add only those code here that needs to be redone always before restarting the server. # One time creation attributes/activities must be in init @@ -278,7 +278,7 @@ def add_property( if not issubklass(handler, BaseHandler): raise TypeError(f"handler should be subclass of BaseHandler, given type {type(handler)}") if isinstance(property, Property): - property = property.to_affordance() + property = property.to_metadata() read_http_method = write_http_method = delete_http_method = None http_methods = self.router.adapt_http_methods(http_methods) if len(http_methods) == 1: @@ -329,7 +329,7 @@ def add_action( raise TypeError(f"handler should be subclass of BaseHandler, given type {type(handler)}") http_methods = self.router.adapt_http_methods(http_method) if isinstance(action, Action): - action = action.to_affordance() # type: ActionAffordance + action = action.to_metadata() # type: ActionAffordance kwargs["resource"] = action kwargs["config"] = self.config kwargs["logger"] = self.logger @@ -364,7 +364,7 @@ def add_event( if not issubklass(handler, BaseHandler): raise TypeError(f"handler should be subclass of BaseHandler, given type {type(handler)}") if isinstance(event, Event): - event = event.to_affordance() + event = event.to_metadata() kwargs["resource"] = event kwargs["config"] = self.config kwargs["logger"] = self.logger @@ -568,8 +568,8 @@ def add_interaction_affordances( ) # RW multiple properties handler - read_properties = Thing._get_properties.to_affordance(Thing) - write_properties = Thing._set_properties.to_affordance(Thing) + read_properties = Thing._get_properties.to_metadata(Thing) + write_properties = Thing._set_properties.to_metadata(Thing) read_properties.override_defaults(thing_id=get_thing_model_action.thing_id) write_properties.override_defaults(thing_id=get_thing_model_action.thing_id) self.server.add_action( @@ -584,7 +584,7 @@ def add_interaction_affordances( # can add an entire thing instance at once def add_thing(self, thing: Thing) -> None: """ - internal method to add a thing instance to be served by the HTTP server. Iterates through the + Internal method to add a thing instance to be served by the HTTP server. Iterates through the interaction affordances and adds a route for each property, action and event. """ # Prepare affordance lists with error handling (single loop) @@ -654,7 +654,7 @@ def __contains__( if rule[0] == item: return True elif isinstance(item, (Property, Action, Event)): - item = item.to_affordance() + item = item.to_metadata() if isinstance(item, (PropertyAffordance, ActionAffordance, EventAffordance)): for rule in self.app.wildcard_router.rules: if rule.target_kwargs.get("resource", None) == item: @@ -728,13 +728,13 @@ def get_basepath(self, authority: str = None, use_localhost: bool = False) -> st basepath = property(fget=get_basepath, doc="basepath of the server") def adapt_route(self, interaction_affordance_name: str) -> str: - """adapt the URL path to default conventions""" + """Adapt the URL path to default conventions""" if interaction_affordance_name == "get_thing_model": return "/resources/wot-tm" return f"/{pep8_to_dashed_name(interaction_affordance_name)}" def adapt_http_methods(self, http_methods: Any): - """comply the supplied HTTP method to the router to a tuple and check if the method is supported""" + """Comply the supplied HTTP method to the router to a tuple and check if the method is supported""" if isinstance(http_methods, str): http_methods = (http_methods,) if not isinstance(http_methods, tuple): diff --git a/hololinked/server/http/services.py b/hololinked/server/http/services.py index 99d23d86..7fe4003c 100644 --- a/hololinked/server/http/services.py +++ b/hololinked/server/http/services.py @@ -9,13 +9,13 @@ from ...constants import JSONSerializable, Operations from ...core.zmq.message import ERROR, INVALID_MESSAGE, TIMEOUT from ...core.zmq.payloads import SerializableData -from ...td import ( +from ...metadata.td import ( ActionAffordance, EventAffordance, InteractionAffordance, PropertyAffordance, ) -from ...td.forms import Form +from ...metadata.td.forms import Form from ..repository import BrokerThing # noqa: F401 from ..security import ( APIKeySecurity, @@ -304,7 +304,7 @@ def add_top_level_forms( def add_security_definitions(self, TD: dict[str, JSONSerializable]) -> None: """Adds security definitions to the TD""" - from ...td.security_definitions import ( + from ...metadata.td.security_definitions import ( APIKeySecurityScheme, BasicSecurityScheme, NoSecurityScheme, diff --git a/hololinked/server/mqtt/controllers.py b/hololinked/server/mqtt/controllers.py index c2f788de..92acc01e 100644 --- a/hololinked/server/mqtt/controllers.py +++ b/hololinked/server/mqtt/controllers.py @@ -6,7 +6,7 @@ from hololinked import Serializers from ...core.zmq.message import EventMessage # noqa: F401 -from ...td import EventAffordance, PropertyAffordance +from ...metadata.td import EventAffordance, PropertyAffordance from ..repository import BrokerThing # noqa: F401 diff --git a/hololinked/server/mqtt/server.py b/hololinked/server/mqtt/server.py index c4209420..0dc12dfe 100644 --- a/hololinked/server/mqtt/server.py +++ b/hololinked/server/mqtt/server.py @@ -6,8 +6,8 @@ import structlog from ...core import Thing as CoreThing +from ...metadata.td.interaction_affordance import EventAffordance, PropertyAffordance from ...param.parameters import ClassSelector, String -from ...td.interaction_affordance import EventAffordance, PropertyAffordance from ...utils import get_current_async_loop from ..server import BaseProtocolServer from .config import RuntimeConfig @@ -161,7 +161,7 @@ async def setup(self) -> None: eventloop.create_task(self.start_publishers(thing)) def stop(self): - """stop publishing, the client is not closed automatically""" + """Stop publishing, the client is not closed automatically""" for publisher in self.publishers.values(): publisher.stop() diff --git a/hololinked/server/mqtt/services.py b/hololinked/server/mqtt/services.py index 9632925b..6e9b356b 100644 --- a/hololinked/server/mqtt/services.py +++ b/hololinked/server/mqtt/services.py @@ -5,7 +5,7 @@ import structlog from ...constants import Operations -from ...td.interaction_affordance import EventAffordance, PropertyAffordance +from ...metadata.td.interaction_affordance import EventAffordance, PropertyAffordance class ThingDescriptionService: diff --git a/hololinked/server/repository.py b/hololinked/server/repository.py index cb68cf1f..20c3244b 100644 --- a/hololinked/server/repository.py +++ b/hololinked/server/repository.py @@ -1,4 +1,6 @@ -from typing import Any, Optional +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, Optional import structlog import zmq.asyncio @@ -22,10 +24,13 @@ default_thing_execution_context, ) from ..core.zmq.message import EMPTY_BYTE -from ..td.interaction_affordance import EventAffordance, PropertyAffordance from ..utils import uuid_hex +if TYPE_CHECKING: + from hololinked.core.interfaces import EventMetadata, PropertyMetadata + + class BrokerThing(BaseModel): """Repository Layer of a Thing over the internal message broker""" @@ -208,7 +213,7 @@ async def recv_response( timeout=timeout, ) - def subscribe_event(self, resource: EventAffordance | PropertyAffordance) -> AsyncEventConsumer: + def subscribe_event(self, resource: EventMetadata | PropertyMetadata) -> AsyncEventConsumer: """ Subscribe to events from a `Thing` through the internal pub-sub broker. @@ -329,7 +334,7 @@ async def consume_broker_queue( # fetch ZMQ INPROC TD Thing.get_thing_model # type: Action - FetchTMAffordance = Thing.get_thing_model.to_affordance() + FetchTMAffordance = Thing.get_thing_model.to_metadata() FetchTMAffordance.override_defaults(thing_id=thing_id, name="get_thing_description") fetch_td = ZMQAction( resource=FetchTMAffordance, diff --git a/hololinked/server/security.py b/hololinked/server/security.py index 413418b2..2a47ed89 100644 --- a/hololinked/server/security.py +++ b/hololinked/server/security.py @@ -14,8 +14,8 @@ from pydantic import BaseModel, PrivateAttr, field_serializer, field_validator -from ..config import global_config -from ..utils import uuid_hex +from hololinked.config import global_config +from hololinked.utils import uuid_hex logger = structlog.get_logger() @@ -77,7 +77,7 @@ def __init__(self, username: str, password: str, expect_base64: bool = True, nam def validate_input(self, username: str, password: str) -> bool: """ - plain validate a username and password + Plain validate a username and password Returns ------- @@ -168,7 +168,7 @@ def __init__(self, username: str, password: str, expect_base64: bool = True, nam def validate_input(self, username: str, password: str) -> bool: """ - plain validate a username and password + Plain validate a username and password Returns ------- @@ -373,7 +373,7 @@ def save(self, record: APIKeyRecord, filename: str = "apikeys.json", override: b json.dump(existing_data, file, indent=4) def load(self) -> None: - """load the security scheme data from persistent storage""" + """Load the security scheme data from persistent storage""" if not os.path.exists(self.file): return with open(self.file, "r") as file: diff --git a/hololinked/server/server.py b/hololinked/server/server.py index 14957938..445cab0b 100644 --- a/hololinked/server/server.py +++ b/hololinked/server/server.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import asyncio import logging import threading @@ -5,26 +7,23 @@ from io import StringIO from types import SimpleNamespace # noqa: F401 +from typing import TYPE_CHECKING import structlog +from hololinked.utils import ( + cancel_pending_tasks_in_current_loop, + forkable, + get_current_async_loop, + uuid_hex, +) + from ..config import global_config from ..core import Action, Event, Property, Thing from ..core.properties import ClassSelector, Integer, TypedList from ..core.zmq.rpc_server import ZMQ_TRANSPORTS, RPCServer from ..param import Parameterized from ..param.parameters import String -from ..td.interaction_affordance import ( - ActionAffordance, - EventAffordance, - PropertyAffordance, -) -from ..utils import ( - cancel_pending_tasks_in_current_loop, - forkable, - get_current_async_loop, - uuid_hex, -) from .repository import ( BrokerThing, consume_broker_pubsub, @@ -32,6 +31,14 @@ ) +if TYPE_CHECKING: + from hololinked.core.interfaces import ( + ActionMetadata, + EventMetadata, + PropertyMetadata, + ) + + class BaseProtocolServer(Parameterized): """ Base class for protocol specific servers. @@ -75,13 +82,13 @@ def add_things(self, *things: Thing) -> None: for thing in things: self.add_thing(thing) - def add_property(self, property: PropertyAffordance | Property) -> None: + def add_property(self, property: PropertyMetadata | Property) -> None: raise NotImplementedError("Not implemented for this protocol") - def add_action(self, action: ActionAffordance | Action) -> None: + def add_action(self, action: ActionMetadata | Action) -> None: raise NotImplementedError("Not implemented for this protocol") - def add_event(self, event: EventAffordance | Event) -> None: + def add_event(self, event: EventMetadata | Event) -> None: raise NotImplementedError("Not implemented for this protocol") async def _instantiate_broker( @@ -164,7 +171,7 @@ def stop(self): @forkable def run(*servers: BaseProtocolServer, forked: bool = False, print_welcome_message: bool = True) -> None: """ - run servers and serve your things + Run servers and serve your things Parameters ---------- @@ -223,7 +230,7 @@ async def shutdown(): def stop(): - """shutdown all running servers started with run()""" + """Shutdown all running servers started with run()""" if hasattr(run, "shutdown_event"): run.shutdown_event.set() return @@ -282,7 +289,7 @@ def parse_params(id: str, access_points: list[tuple[str, str | int | dict | list def _print_welcome_message(servers: list[BaseProtocolServer]) -> None: - """prints a welcome message to the console/log""" + """Prints a welcome message to the console/log""" from . import HTTPServer, MQTTPublisher buffer = StringIO() diff --git a/hololinked/td/metadata.py b/hololinked/td/metadata.py deleted file mode 100644 index 4870a755..00000000 --- a/hololinked/td/metadata.py +++ /dev/null @@ -1,27 +0,0 @@ -from typing import Optional - -from pydantic import Field - -from .base import Schema - - -class Link(Schema): - """ - Represents a link in the link section of the TD - schema - https://www.w3.org/TR/wot-thing-description11/#link - """ - - href: str - anchor: Optional[str] - rel: Optional[str] - type: Optional[str] = Field(default="application/json") - - -class VersionInfo(Schema): - """ - Represents version info. - schema - https://www.w3.org/TR/wot-thing-description11/#versioninfo - """ - - instance: str - model: str diff --git a/hololinked/utils.py b/hololinked/utils.py index 557c9df1..4cd29e17 100644 --- a/hololinked/utils.py +++ b/hololinked/utils.py @@ -54,12 +54,12 @@ def get_IP_from_interface(interface_name: str = "Ethernet", adapter_name: str | def uuid_hex() -> str: - """generate a random UUID hex string of 8 characters""" + """Generate a random UUID hex string of 8 characters""" return uuid4().hex[:8] def format_exception_as_json(exc: Exception) -> dict[str, Any]: - """return exception as a JSON serializable dictionary""" + """Return exception as a JSON serializable dictionary""" return dict( message=str(exc), type=repr(exc).split("(", 1)[0], @@ -82,7 +82,7 @@ def pep8_to_dashed_name(word: str) -> str: def get_current_async_loop() -> asyncio.AbstractEventLoop: - """get or automatically create an asnyc loop for the current thread""" + """Get or automatically create an asnyc loop for the current thread""" try: loop = asyncio.get_event_loop() except RuntimeError: @@ -92,7 +92,7 @@ def get_current_async_loop() -> asyncio.AbstractEventLoop: def run_coro_sync(coro: Coroutine) -> Any: - """try to run coroutine synchronously, raises runtime error if event loop is already running""" + """Try to run coroutine synchronously, raises runtime error if event loop is already running""" eventloop = get_current_async_loop() if eventloop.is_running(): raise RuntimeError( @@ -105,7 +105,7 @@ def run_coro_sync(coro: Coroutine) -> Any: def run_callable_somehow(method: Callable | Coroutine) -> Any: """ - run method if synchronous, or when async, either schedule a coroutine + Run method if synchronous, or when async, either schedule a coroutine or run it until its complete """ if inspect.isawaitable(method): @@ -158,7 +158,7 @@ def print_pending_tasks_in_current_loop(): def set_global_event_loop_policy(use_uvloop: bool = False) -> None: - """set global event loop policy, optionally using uvloop if available and on linux/macos""" + """Set global event loop policy, optionally using uvloop if available and on linux/macos""" if sys.platform.lower().startswith("win"): asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy()) @@ -201,7 +201,7 @@ def get_signature(callable: Callable) -> tuple[list[str], list[type]]: def getattr_without_descriptor_read(instance: Any, key: str) -> Any: """ - supply to inspect._get_members (not inspect.get_members) to avoid calling + Supply to inspect._get_members (not inspect.get_members) to avoid calling __get__ on descriptors, especially whey they are hardware attributes that would invoke a hardware read. """ @@ -307,7 +307,7 @@ def get_sanitized_filename_from_random_string(a_string: str, extension: str) -> def generate_main_script_log_filename(self, app_name: str | None = None) -> str | None: - """returns the main script filename if available""" + """Returns the main script filename if available""" import __main__ if not app_name: @@ -350,7 +350,7 @@ def __call__(cls, *args, **kwargs): class MappableSingleton(Singleton): - """Singleton with dict-like access to attributes""" + """Singleton with dict-like access to attributes.""" def __setitem__(self, key, value) -> None: setattr(self, key, value) @@ -490,7 +490,6 @@ def pydantic_validate_args_kwargs( ValidationError If the arguments are invalid """ - field_names = list(model.model_fields.keys()) data = {} @@ -597,7 +596,7 @@ def wrapper(*args, **kwargs): def get_all_sub_things_recusively(thing) -> list: - """get all sub things recursively from a thing""" + """Get all sub things recursively from a thing""" sub_things = [thing] for sub_thing in thing.sub_things.values(): sub_things.extend(get_all_sub_things_recusively(sub_thing)) diff --git a/tests/test_06_actions.py b/tests/test_06_actions.py index 6be91b62..d6115a8d 100644 --- a/tests/test_06_actions.py +++ b/tests/test_06_actions.py @@ -12,8 +12,8 @@ ) from hololinked.core.dataklasses import ActionInfoValidator from hololinked.core.thing import action +from hololinked.metadata.td.interaction_affordance import ActionAffordance from hololinked.schema_validators import JSONSchemaValidator -from hololinked.td.interaction_affordance import ActionAffordance from hololinked.utils import isclassmethod @@ -375,7 +375,7 @@ def test_05_thing_cls_actions(thing: TestThing): def test_06_action_affordance(thing: TestThing): """Test if action affordance is correctly created""" assert isinstance(thing.action_echo, BoundAction) - affordance = thing.action_echo.to_affordance() + affordance = thing.action_echo.to_metadata() assert isinstance(affordance, ActionAffordance) assert affordance.idempotent is None assert affordance.synchronous is True @@ -385,7 +385,7 @@ def test_06_action_affordance(thing: TestThing): assert affordance.description is None assert isinstance(thing.action_echo_with_classmethod, BoundAction) - affordance = thing.action_echo_with_classmethod.to_affordance() + affordance = thing.action_echo_with_classmethod.to_metadata() assert isinstance(affordance, ActionAffordance) assert affordance.idempotent is None assert affordance.synchronous is True @@ -395,7 +395,7 @@ def test_06_action_affordance(thing: TestThing): assert affordance.description is None assert isinstance(thing.action_echo_async, BoundAction) - affordance = thing.action_echo_async.to_affordance() + affordance = thing.action_echo_async.to_metadata() assert isinstance(affordance, ActionAffordance) assert affordance.idempotent is None assert affordance.synchronous is True @@ -405,7 +405,7 @@ def test_06_action_affordance(thing: TestThing): assert affordance.description is None assert isinstance(thing.action_echo_async_with_classmethod, BoundAction) - affordance = thing.action_echo_async_with_classmethod.to_affordance() + affordance = thing.action_echo_async_with_classmethod.to_metadata() assert isinstance(affordance, ActionAffordance) assert affordance.idempotent is None assert affordance.synchronous is True @@ -415,7 +415,7 @@ def test_06_action_affordance(thing: TestThing): assert affordance.description is None assert isinstance(thing.parameterized_action, BoundAction) - affordance = thing.parameterized_action.to_affordance() + affordance = thing.parameterized_action.to_metadata() assert isinstance(affordance, ActionAffordance) assert affordance.idempotent is None assert affordance.synchronous is True @@ -425,7 +425,7 @@ def test_06_action_affordance(thing: TestThing): assert affordance.description is None assert isinstance(thing.parameterized_action_without_call, BoundAction) - affordance = thing.parameterized_action_without_call.to_affordance() + affordance = thing.parameterized_action_without_call.to_metadata() assert isinstance(affordance, ActionAffordance) assert affordance.idempotent is True assert affordance.synchronous is True @@ -435,7 +435,7 @@ def test_06_action_affordance(thing: TestThing): assert affordance.description is None assert isinstance(thing.parameterized_action_async, BoundAction) - affordance = thing.parameterized_action_async.to_affordance() + affordance = thing.parameterized_action_async.to_metadata() assert isinstance(affordance, ActionAffordance) assert affordance.idempotent is None assert affordance.synchronous is True @@ -445,7 +445,7 @@ def test_06_action_affordance(thing: TestThing): assert affordance.description is None assert isinstance(thing.json_schema_validated_action, BoundAction) - affordance = thing.json_schema_validated_action.to_affordance() + affordance = thing.json_schema_validated_action.to_metadata() assert isinstance(affordance, ActionAffordance) assert affordance.idempotent is None assert affordance.synchronous is True diff --git a/tests/test_08_events.py b/tests/test_08_events.py index e7a03858..b273116c 100644 --- a/tests/test_08_events.py +++ b/tests/test_08_events.py @@ -2,7 +2,7 @@ from hololinked.core.events import Event, EventDispatcher from hololinked.core.zmq.brokers import EventPublisher -from hololinked.td.interaction_affordance import EventAffordance +from hololinked.metadata.td.interaction_affordance import EventAffordance from hololinked.utils import uuid_hex @@ -69,7 +69,7 @@ def test_02_observable_events(): def test_03_event_affordance(): """Test event affordance generation""" thing = TestThing(id=f"test-event-affordance-{uuid_hex()}") - event = TestThing.test_event.to_affordance(thing) + event = TestThing.test_event.to_metadata(thing) assert isinstance(event, EventAffordance) diff --git a/tests/test_09_rpc_broker.py b/tests/test_09_rpc_broker.py index 1183212e..c9da89dc 100644 --- a/tests/test_09_rpc_broker.py +++ b/tests/test_09_rpc_broker.py @@ -21,8 +21,8 @@ SyncZMQClient, ) from hololinked.core.zmq.rpc_server import RPCServer -from hololinked.td import ActionAffordance, EventAffordance, PropertyAffordance -from hololinked.td.forms import Form +from hololinked.metadata.td import ActionAffordance, EventAffordance, PropertyAffordance +from hololinked.metadata.td.forms import Form from hololinked.utils import get_all_sub_things_recusively, uuid_hex @@ -359,7 +359,7 @@ def test_11_exposed_actions(self, thing: TestThing, sync_client: SyncZMQClient): assert isinstance(thing.action_echo, BoundAction) action_echo = ZMQAction( - resource=thing.action_echo.to_affordance(), + resource=thing.action_echo.to_metadata(), sync_client=client, async_client=None, logger=structlog.get_logger(), @@ -369,7 +369,7 @@ def test_11_exposed_actions(self, thing: TestThing, sync_client: SyncZMQClient): assert isinstance(thing.action_echo_with_classmethod, BoundAction) action_echo_with_classmethod = ZMQAction( - resource=thing.action_echo_with_classmethod.to_affordance(), + resource=thing.action_echo_with_classmethod.to_metadata(), sync_client=client, async_client=None, logger=structlog.get_logger(), @@ -379,7 +379,7 @@ def test_11_exposed_actions(self, thing: TestThing, sync_client: SyncZMQClient): assert isinstance(thing.action_echo_async, BoundAction) action_echo_async = ZMQAction( - resource=thing.action_echo_async.to_affordance(), + resource=thing.action_echo_async.to_metadata(), sync_client=client, async_client=None, logger=structlog.get_logger(), @@ -389,7 +389,7 @@ def test_11_exposed_actions(self, thing: TestThing, sync_client: SyncZMQClient): assert isinstance(thing.action_echo_async_with_classmethod, BoundAction) action_echo_async_with_classmethod = ZMQAction( - resource=thing.action_echo_async_with_classmethod.to_affordance(), + resource=thing.action_echo_async_with_classmethod.to_metadata(), sync_client=client, async_client=None, logger=structlog.get_logger(), @@ -399,7 +399,7 @@ def test_11_exposed_actions(self, thing: TestThing, sync_client: SyncZMQClient): assert isinstance(thing.parameterized_action, BoundAction) parameterized_action = ZMQAction( - resource=thing.parameterized_action.to_affordance(), + resource=thing.parameterized_action.to_metadata(), sync_client=client, async_client=None, logger=structlog.get_logger(), @@ -409,7 +409,7 @@ def test_11_exposed_actions(self, thing: TestThing, sync_client: SyncZMQClient): assert isinstance(thing.parameterized_action_async, BoundAction) parameterized_action_async = ZMQAction( - resource=thing.parameterized_action_async.to_affordance(), + resource=thing.parameterized_action_async.to_metadata(), sync_client=client, async_client=None, logger=structlog.get_logger(), @@ -419,7 +419,7 @@ def test_11_exposed_actions(self, thing: TestThing, sync_client: SyncZMQClient): assert isinstance(thing.parameterized_action_without_call, BoundAction) parameterized_action_without_call = ZMQAction( - resource=thing.parameterized_action_without_call.to_affordance(), + resource=thing.parameterized_action_without_call.to_metadata(), sync_client=client, async_client=None, logger=structlog.get_logger(), @@ -432,7 +432,7 @@ def test_11_exposed_actions(self, thing: TestThing, sync_client: SyncZMQClient): def test_12_json_schema_validation(self, thing: TestThing, sync_client: SyncZMQClient): assert isinstance(thing.json_schema_validated_action, BoundAction) - action_affordance = thing.json_schema_validated_action.to_affordance() + action_affordance = thing.json_schema_validated_action.to_metadata() json_schema_validated_action = ZMQAction( resource=action_affordance, sync_client=sync_client, @@ -463,7 +463,7 @@ def test_12_json_schema_validation(self, thing: TestThing, sync_client: SyncZMQC def test_13_pydantic_validation(self, thing: TestThing, sync_client: SyncZMQClient): assert isinstance(thing.pydantic_validated_action, BoundAction) - action_affordance = thing.pydantic_validated_action.to_affordance() + action_affordance = thing.pydantic_validated_action.to_metadata() pydantic_validated_action = ZMQAction( resource=action_affordance, sync_client=sync_client, @@ -525,7 +525,7 @@ def test_14_property_abstractions(self, thing: TestThing, sync_client: SyncZMQCl descriptor = thing.properties["number_prop"] # Property type check is omitted since Property is not imported number_prop = ZMQProperty( - resource=descriptor.to_affordance(thing), + resource=descriptor.to_metadata(thing), sync_client=sync_client, async_client=None, logger=structlog.get_logger(), @@ -540,7 +540,7 @@ def test_14_property_abstractions(self, thing: TestThing, sync_client: SyncZMQCl def test_15_json_schema_property(self, thing: TestThing, sync_client: SyncZMQClient): """Test json schema based property""" json_schema_prop = ZMQProperty( - resource=TestThing.json_schema_prop.to_affordance(thing), + resource=TestThing.json_schema_prop.to_metadata(thing), sync_client=sync_client, async_client=None, logger=structlog.get_logger(), @@ -558,7 +558,7 @@ def test_15_json_schema_property(self, thing: TestThing, sync_client: SyncZMQCli def test_16_pydantic_model_property(self, thing: TestThing, sync_client: SyncZMQClient): """Test pydantic model based property""" pydantic_prop = ZMQProperty( - resource=TestThing.pydantic_prop.to_affordance(thing), + resource=TestThing.pydantic_prop.to_metadata(thing), sync_client=sync_client, async_client=None, logger=structlog.get_logger(), @@ -575,7 +575,7 @@ def test_16_pydantic_model_property(self, thing: TestThing, sync_client: SyncZMQ assert "validation error for PydanticProp" in str(ex.value) pydantic_simple_prop = ZMQProperty( - resource=TestThing.pydantic_simple_prop.to_affordance(thing), + resource=TestThing.pydantic_simple_prop.to_metadata(thing), sync_client=sync_client, async_client=None, logger=structlog.get_logger(), @@ -588,7 +588,7 @@ def test_16_pydantic_model_property(self, thing: TestThing, sync_client: SyncZMQ assert "validation error for 'int'" in str(ex.value) def test_17_creation_defaults(self, thing: TestThing, server: RPCServer): - """test server configuration defaults""" + """Test server configuration defaults""" all_things = get_all_sub_things_recusively(thing) # assert len(all_things) > 1 # run the test only if there are sub things for thing in all_things: @@ -618,9 +618,8 @@ def test_18_sync_client_event_stream( event_name: str, expected_data: Any, ): - """test if event can be streamed by a synchronous threaded client""" - - resource = getattr(TestThing, event_name).to_affordance(thing) # type: EventAffordance + """Test if event can be streamed by a synchronous threaded client""" + resource = getattr(TestThing, event_name).to_metadata(thing) # type: EventAffordance form = Form() form.href = server.event_publisher.socket_address @@ -676,8 +675,8 @@ async def test_19_async_client_event_stream( event_name: str, expected_data: Any, ): - """test if event can be streamed by an asynchronous client in an async loop""" - resource = getattr(TestThing, event_name).to_affordance(thing) # type: EventAffordance + """Test if event can be streamed by an asynchronous client in an async loop""" + resource = getattr(TestThing, event_name).to_metadata(thing) # type: EventAffordance form = Form() form.href = thing.rpc_server.event_publisher.socket_address diff --git a/tests/test_10_thing_description.py b/tests/test_10_thing_description.py index 2b411945..1b95c42a 100644 --- a/tests/test_10_thing_description.py +++ b/tests/test_10_thing_description.py @@ -14,8 +14,8 @@ Selector, String, ) -from hololinked.td.data_schema import DataSchema -from hololinked.td.interaction_affordance import ( +from hololinked.metadata.td.data_schema import DataSchema +from hololinked.metadata.td.interaction_affordance import ( ActionAffordance, EventAffordance, InteractionAffordance, @@ -56,13 +56,6 @@ def test_01_associated_objects(thing): assert isinstance(affordance.objekt, Property) assert affordance.name == OceanOpticsSpectrometer.integration_time.name - affordance = PropertyAffordance() - assert affordance.owner is None - assert affordance.objekt is None - assert affordance.name is None - assert affordance.thing_id is None - assert affordance.thing_cls is None - affordance = ActionAffordance() with pytest.raises(ValueError) as ex: affordance.objekt = OceanOpticsSpectrometer.integration_time @@ -91,7 +84,7 @@ def test_01_associated_objects(thing): def test_02_number_schema(thing): - schema = OceanOpticsSpectrometer.integration_time.to_affordance(owner_inst=thing) + schema = OceanOpticsSpectrometer.integration_time.to_metadata(owner_inst=thing) assert isinstance(schema, PropertyAffordance) assert schema.type == "number" @@ -104,7 +97,7 @@ def test_02_number_schema(thing): metadata=dict(unit="ms"), ) integration_time.__set_name__(OceanOpticsSpectrometer, "integration_time") - schema = integration_time.to_affordance(owner_inst=thing) + schema = integration_time.to_metadata(owner_inst=thing) assert isinstance(schema, PropertyAffordance) assert schema.type == "number" assert schema.minimum == integration_time.bounds[0] @@ -116,7 +109,7 @@ def test_02_number_schema(thing): _ = schema.exclusiveMaximum integration_time.inclusive_bounds = (False, False) integration_time.step = None - schema = integration_time.to_affordance(owner_inst=thing) + schema = integration_time.to_metadata(owner_inst=thing) assert schema.exclusiveMinimum == integration_time.bounds[0] assert schema.exclusiveMaximum == integration_time.bounds[1] with pytest.raises(AttributeError): @@ -126,7 +119,7 @@ def test_02_number_schema(thing): with pytest.raises(AttributeError): _ = schema.multipleOf integration_time.allow_None = True - schema = integration_time.to_affordance(owner_inst=thing) + schema = integration_time.to_metadata(owner_inst=thing) assert any(subtype["type"] == "null" for subtype in schema.oneOf) assert any(subtype["type"] == "number" for subtype in schema.oneOf) assert len(schema.oneOf) == 2 @@ -145,7 +138,7 @@ def test_02_number_schema(thing): def test_03_string_schema(thing): - schema = OceanOpticsSpectrometer.status.to_affordance(owner_inst=thing) + schema = OceanOpticsSpectrometer.status.to_metadata(owner_inst=thing) assert isinstance(schema, PropertyAffordance) status = String( @@ -154,12 +147,12 @@ def test_03_string_schema(thing): doc="status of the spectrometer", ) status.__set_name__(OceanOpticsSpectrometer, "status") - schema = status.to_affordance(owner_inst=thing) + schema = status.to_metadata(owner_inst=thing) assert isinstance(schema, PropertyAffordance) assert schema.type == "string" assert schema.pattern == status.regex status.allow_None = True - schema = status.to_affordance(owner_inst=thing) + schema = status.to_metadata(owner_inst=thing) assert any(subtype["type"] == "null" for subtype in schema.oneOf) assert any(subtype["type"] == "string" for subtype in schema.oneOf) assert len(schema.oneOf) == 2 @@ -170,16 +163,16 @@ def test_03_string_schema(thing): def test_04_boolean_schema(thing): - schema = OceanOpticsSpectrometer.nonlinearity_correction.to_affordance(owner_inst=thing) + schema = OceanOpticsSpectrometer.nonlinearity_correction.to_metadata(owner_inst=thing) assert isinstance(schema, PropertyAffordance) nonlinearity_correction = Boolean(default=True, doc="nonlinearity correction enabled") nonlinearity_correction.__set_name__(OceanOpticsSpectrometer, "nonlinearity_correction") - schema = nonlinearity_correction.to_affordance(owner_inst=thing) + schema = nonlinearity_correction.to_metadata(owner_inst=thing) assert isinstance(schema, PropertyAffordance) assert schema.type == "boolean" nonlinearity_correction.allow_None = True - schema = nonlinearity_correction.to_affordance(owner_inst=thing) + schema = nonlinearity_correction.to_metadata(owner_inst=thing) assert any(subtype["type"] == "null" for subtype in schema.oneOf) assert any(subtype["type"] == "boolean" for subtype in schema.oneOf) assert len(schema.oneOf) == 2 @@ -188,7 +181,7 @@ def test_04_boolean_schema(thing): def test_05_array_schema(thing): - schema = OceanOpticsSpectrometer.wavelengths.to_affordance(owner_inst=thing) + schema = OceanOpticsSpectrometer.wavelengths.to_metadata(owner_inst=thing) assert isinstance(schema, PropertyAffordance) wavelengths = List( @@ -199,7 +192,7 @@ def test_05_array_schema(thing): doc="wavelength bins of measurement", ) wavelengths.__set_name__(OceanOpticsSpectrometer, "wavelengths") - schema = wavelengths.to_affordance(owner_inst=thing) + schema = wavelengths.to_metadata(owner_inst=thing) assert isinstance(schema, BaseModel) assert isinstance(schema, DataSchema) assert isinstance(schema, PropertyAffordance) @@ -209,7 +202,7 @@ def test_05_array_schema(thing): if OceanOpticsSpectrometer.wavelengths.default is not None: assert schema.default == OceanOpticsSpectrometer.wavelengths.default OceanOpticsSpectrometer.wavelengths.allow_None = True - schema = OceanOpticsSpectrometer.wavelengths.to_affordance(owner_inst=thing) + schema = OceanOpticsSpectrometer.wavelengths.to_metadata(owner_inst=thing) assert any(subtype["type"] == "null" for subtype in schema.oneOf) assert any(subtype["type"] == "array" for subtype in schema.oneOf) assert len(schema.oneOf) == 2 @@ -221,7 +214,7 @@ def test_05_array_schema(thing): for bounds in [(5, 1000), (None, 100), (50, None), (51, 101)]: wavelengths.bounds = bounds wavelengths.allow_None = False - schema = wavelengths.to_affordance(owner_inst=thing) + schema = wavelengths.to_metadata(owner_inst=thing) if bounds[0] is not None: assert schema.minItems == bounds[0] else: @@ -232,7 +225,7 @@ def test_05_array_schema(thing): assert not hasattr(schema, "maxItems") or schema.maxItems is None wavelengths.bounds = bounds wavelengths.allow_None = True - schema = wavelengths.to_affordance(owner_inst=thing) + schema = wavelengths.to_metadata(owner_inst=thing) subtype = next(subtype for subtype in schema.oneOf if subtype["type"] == "array") if bounds[0] is not None: assert subtype["minItems"] == bounds[0] @@ -247,7 +240,7 @@ def test_05_array_schema(thing): def test_06_enum_schema(thing): - schema = OceanOpticsSpectrometer.trigger_mode.to_affordance(owner_inst=thing) + schema = OceanOpticsSpectrometer.trigger_mode.to_metadata(owner_inst=thing) assert isinstance(schema, PropertyAffordance) trigger_mode = Selector( @@ -258,7 +251,7 @@ def test_06_enum_schema(thing): 3 = Ext. Trigger Synchro/ Shutter mode, 4 = Ext. Trigger Edge""", ) trigger_mode.__set_name__(OceanOpticsSpectrometer, "trigger_mode") - schema = trigger_mode.to_affordance(owner_inst=thing) + schema = trigger_mode.to_metadata(owner_inst=thing) assert isinstance(schema, PropertyAffordance) assert schema.type == "integer" assert schema.default == 0 @@ -267,7 +260,7 @@ def test_06_enum_schema(thing): trigger_mode.allow_None = True trigger_mode.default = 3 trigger_mode.objects = [0, 1, 2, 3, 4, "0", "1", "2", "3", "4"] - schema = trigger_mode.to_affordance(owner_inst=thing) + schema = trigger_mode.to_metadata(owner_inst=thing) assert isinstance(schema, PropertyAffordance) assert not hasattr(schema, "type") or schema.type is None assert schema.default == 3 @@ -288,13 +281,13 @@ def test_07_class_selector_custom_schema(thing): doc="last measurement intensity (in arbitrary units)", ) last_intensity.__set_name__(OceanOpticsSpectrometer, "last_intensity") - schema = last_intensity.to_affordance(owner_inst=thing) + schema = last_intensity.to_metadata(owner_inst=thing) assert isinstance(schema, PropertyAffordance) assert schema.type == "object" assert schema.properties == Intensity.schema["properties"] last_intensity.allow_None = True - schema = last_intensity.to_affordance(owner_inst=thing) + schema = last_intensity.to_metadata(owner_inst=thing) assert isinstance(schema, PropertyAffordance) assert not hasattr(schema, "type") or schema.type is None subschema = next(subtype for subtype in schema.oneOf if subtype.get("type", None) == "object") @@ -306,13 +299,13 @@ def test_07_class_selector_custom_schema(thing): def test_08_json_schema_properties(thing): json_schema_prop = TestThing.json_schema_prop # type: Property json_schema_prop.allow_None = False - schema = json_schema_prop.to_affordance(owner_inst=thing) + schema = json_schema_prop.to_metadata(owner_inst=thing) assert isinstance(schema, PropertyAffordance) for key in json_schema_prop.model: assert getattr(schema, key, NotImplemented) == json_schema_prop.model[key] json_schema_prop.allow_None = True - schema = json_schema_prop.to_affordance(owner_inst=thing) + schema = json_schema_prop.to_metadata(owner_inst=thing) assert isinstance(schema, PropertyAffordance) subschema = next( subtype @@ -327,7 +320,7 @@ def test_08_json_schema_properties(thing): def test_09_pydantic_properties(thing): pydantic_prop = TestThing.pydantic_prop # type: Property pydantic_prop.allow_None = False - schema = pydantic_prop.to_affordance(owner_inst=thing) + schema = pydantic_prop.to_metadata(owner_inst=thing) assert isinstance(schema, PropertyAffordance) if issubklass(pydantic_prop.model, BaseModel): assert schema.type == "object" @@ -335,7 +328,7 @@ def test_09_pydantic_properties(thing): assert field in schema.properties pydantic_prop.allow_None = True - schema = pydantic_prop.to_affordance(owner_inst=thing) + schema = pydantic_prop.to_metadata(owner_inst=thing) assert isinstance(schema, PropertyAffordance) subschema = next(subtype for subtype in schema.oneOf if subtype.get("type", None) == "object") assert isinstance(subschema, dict) @@ -344,12 +337,12 @@ def test_09_pydantic_properties(thing): pydantic_simple_prop = TestThing.pydantic_simple_prop # type: Property # its an integer pydantic_simple_prop.allow_None = False - schema = pydantic_simple_prop.to_affordance(owner_inst=thing) + schema = pydantic_simple_prop.to_metadata(owner_inst=thing) assert isinstance(schema, PropertyAffordance) assert schema.type == "integer" pydantic_simple_prop.allow_None = True - schema = pydantic_simple_prop.to_affordance(owner_inst=thing) + schema = pydantic_simple_prop.to_metadata(owner_inst=thing) assert isinstance(schema, PropertyAffordance) subschema = next(subtype for subtype in schema.oneOf if subtype.get("type", None) == "integer") assert subschema["type"] == "integer"